Commit graph

5 commits

Author SHA1 Message Date
veg
ddb3bc6908 Verify export integrity with SHA-256 before publishing
The O_NOFOLLOW + S_ISREG handoff blocks a
symlink swap but still accepts a *regular-file* swap — a process holding
the web uid can replace/truncate the /var/tmp temp with different content
before root copies it, and root would publish it and exit 0, which the
destroy loop reads as authorization to remove the list.

The exporter now hashes the bytes it wrote (read back through its own
held fd — the original inode, immune to any name swap) and returns the
SHA-256 on the FILE status line. Root copies the source while hashing and
refuses to publish unless the digest matches; a swap/truncation/partial
write yields a mismatch, a non-zero exit, and no removal.

This does NOT defend a fully compromised mailman-web process (it controls
the source DB the archive is built from) — that is inherent and now
documented as out of scope. It closes same-uid swaps, partial writes, and
accidental corruption while staying in the existing architecture.

Verified: held-fd hash equals an independent by-name read (gzip + plain);
correct digest publishes (0600, content intact, source unlinked); wrong
digest, post-hash swap, and empty digest are all rejected with nothing
published; end-to-end NONE/EMPTY still exit 3, bad digest exits 1.
2026-05-31 12:28:09 +00:00
veg
7c9b13051d Make 'nothing archived' a distinct exit (3) and fsync the dir on publish
Two further hardening findings:

- Destroy-on-success was unsafe: the script exited 0 for empty/no-archive
  lists too, so the README's &&-gated loop could remove a list that
  produced no backup artifact. Give the no-file outcomes a distinct exit
  code 3 ("nothing to archive" — not an error). The destroy loop now
  removes only on exit 0 (archive written) + a successful member dump,
  and leaves exit-3 lists intact unless ALLOW_NO_ARCHIVE=1. Safe by
  default: an unexpected "no archive" can no longer authorise a delete.

- Publish wasn't crash-durable: the handoff fsynced file contents but not
  the parent directory, so the rename's directory entry could be lost to
  a crash after success was reported and the list removed. fsync the
  destination directory after os.replace(), before returning success.

Verified with stubbed mailman-web: NONE/EMPTY -> exit 3 (no file),
FILE -> exit 0 (0600, content intact, source temp unlinked), bad
address -> exit 2. sh -n + shellcheck clean, embedded python compiles.
2026-05-31 12:28:09 +00:00
veg
7b7fcde84a Close root-side TOCTOU in the export handoff
The prior handoff checked the /var/tmp
temp file by name (-L/-f) and then reopened it by name with cp: a
process holding the web uid could swap the path for a symlink in that
window and make root copy an arbitrary root-readable file into $out.

Replace the check-then-reopen with a single root-side python3 step that
opens the source ONCE with O_NOFOLLOW (a swapped symlink makes the open
fail), verifies it is a regular file via the open fd, copies from that
fd into a fresh root-owned mkstemp temp in the destination dir, fsyncs,
and atomically os.replace()s it into place. The path is never
re-resolved by name, so the TOCTOU is closed rather than narrowed.

Paths are passed as argv (heredoc single-quoted), so nothing is
interpolated into the snippet. Verified: regular file publishes 0600
with src removed and no leftover temp; symlink source is rejected
(ELOOP) with dest untouched; existing backups are atomically replaced.
2026-05-31 12:28:09 +00:00
veg
c2b304e4a9 Harden mm3-hk-export: safe handoff, no-cred policy lookup, gated destroy
Address three hardening findings:

- Gate the README backup-and-destroy loop so `mailman-wrapper remove`
  only runs after both the export and member dump succeed; a failed
  backup now leaves the list intact instead of falling through to an
  irreversible delete. Empty/no-archive lists still pass (exit 0 by
  design), so only real failures block removal.

- Replace the curl/REST archive_policy lookup with `mailman-wrapper
  shell`, reading archive_policy straight from core's DB via
  getUtility(IListManager). Eliminates REST credential handling
  entirely (no secret in argv / /proc), drops the curl dependency, and
  removes the @->. list-id conversion.

- Make the final publish a safe privilege handoff: reject non-regular /
  symlinked temp files, copy the web-user mkstemp output into a
  root-owned temp file on the destination filesystem, chmod 600, then
  atomically rename into place. Fixes inherited web-user ownership and
  the cross-filesystem mv that could leave a partial or clobbered
  backup.
2026-05-31 12:24:58 +00:00
veg
f7a9714e93 Add mm3-hk-export: HyperKitty archive -> mbox exporter
Scriptable equivalent of HyperKitty's web Download button for
backup-and-destroy loops. Runs the export through Debian's mailman-web
wrapper (HyperKitty lives in the web DB, separate from core), reproducing
the web view's Email.as_bytes() output.

- Handles the three archive states without writing partial/empty files:
  populated (writes mbox), row-but-empty, and no row at all.
- For a missing row, best-effort core REST lookup distinguishes
  archive_policy=never from 'nothing archived yet'.
- -z/--gzip matches the web .mbox.gz (deterministic header, mtime=0).
- Privilege-safe: web user writes a 0600 mkstemp file, root moves it.
- Address validated before interpolation into the embedded Python.
2026-05-31 12:24:58 +00:00