From f4ed22ceab8da0a77502cd8f6c294b4508ada324 Mon Sep 17 00:00:00 2001 From: veg Date: Sun, 31 May 2026 14:47:35 +0000 Subject: [PATCH] Drop the /var/tmp handoff hardening for a plain copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SHA-256 double-hash and TOCTOU defenses (O_NOFOLLOW/O_NONBLOCK, S_ISREG, fsync, in-process durable mkdir) guarded against a hostile same-uid process racing the /var/tmp handoff — a threat that does not match this deployment (a small trusted collective, no hostile local users), and the cost showed up as a 10-15s silent gap after the progress bar (two full passes over the archive). Replace the ~60-line python3 publish block with plain shell: mkdir -p, mktemp in the destination dir, cp, atomic mv -f, rm the source. The two-stage handoff and atomic rename stay (the web user cannot write into a root-owned destination, and the rename keeps $out from being partial). The exporter no longer hashes either. README updated to match. --- README.md | 7 ++-- mm3-hk-export | 110 ++++++++------------------------------------------ 2 files changed, 19 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 57ec992..41e46dc 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ export must still halt the destroy.) - **Large lists.** Streamed via a DB iterator, so no memory blow-up like the web export. - **Re-import.** Valid mbox; re-importable with `hyperkitty_import`. -- **Handoff.** The web user builds the archive in `/var/tmp`; root verifies it by - SHA-256 and publishes atomically, so a swap, truncation, or corruption before - publish is caught and nothing is published. A compromised `mailman-web` is out - of scope. +- **Handoff.** The web user builds the archive in `/var/tmp` (it can't write into + a root-owned destination); root copies it out and renames into place, so `$out` + is never left partial. diff --git a/mm3-hk-export b/mm3-hk-export index b4a4aed..937a73d 100755 --- a/mm3-hk-export +++ b/mm3-hk-export @@ -73,7 +73,7 @@ EOF } result=$(mailman-web shell <&2; exit 1; } # Any non-zero from mailman-web is normalized to exit 1 above so a wrapper that @@ -175,88 +164,21 @@ case "$status" in ;; FILE) path=$(printf '%s' "$line" | cut -d'|' -f4) - sha=$(printf '%s' "$line" | cut -d'|' -f5) - # Publish the web-user temp file as root without re-resolving its path. - # The export lives in /var/tmp owned by the web user, so any process - # with that uid could swap the name for a symlink between a name-based - # check and a name-based copy (TOCTOU) and make root copy an arbitrary - # root-readable file. So: open the source once with O_NOFOLLOW (a swapped - # symlink makes the open fail) plus O_NONBLOCK (a swapped FIFO returns - # instead of blocking the open forever), confirm it's a regular file via - # the open fd, copy from that fd into a fresh root-owned temp in the - # destination dir while hashing, verify the SHA-256 matches what the - # exporter reported (rejects a regular-file swap, truncation, or partial - # write by another same-uid process), fsync, and atomically rename into - # place. The output directory is created in-process so each newly-made - # level is fsynced into its parent before success is reported. Paths and - # digest are passed as argv (heredoc is single-quoted) so nothing is - # interpolated. - if ! python3 - "$path" "$out" "$sha" <<'PY' -import os, sys, stat, tempfile, hashlib -src, out, expected = sys.argv[1], sys.argv[2], sys.argv[3] -dest_dir = os.path.dirname(out) or "." - -def fsync_dir(d): - dfd = os.open(d, os.O_RDONLY | os.O_DIRECTORY) - try: os.fsync(dfd) - finally: os.close(dfd) - -# Create any missing output directories, fsyncing each new level into its parent -# so a freshly-created backup directory is itself crash-durable, not just the -# file later renamed into it. -missing, d = [], os.path.abspath(dest_dir) -while not os.path.isdir(d): - missing.append(d) - parent = os.path.dirname(d) - if parent == d: break - d = parent -for made in reversed(missing): - os.mkdir(made) - fsync_dir(os.path.dirname(made)) - -try: - fd = os.open(src, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) -except OSError as e: - sys.exit("mm3-hk-export: cannot open export temp (symlink/FIFO swapped in?): %s" % e) -dtmp = None -try: - if not stat.S_ISREG(os.fstat(fd).st_mode): - sys.exit("mm3-hk-export: export temp is not a regular file") - dfd, dtmp = tempfile.mkstemp(prefix=".mm3hk.", dir=dest_dir) - h = hashlib.sha256() - r = os.fdopen(fd, "rb"); fd = -1 # fd now owned by r - with r, os.fdopen(dfd, "wb") as w: # dfd now owned by w - for chunk in iter(lambda: r.read(1 << 16), b""): - h.update(chunk) - w.write(chunk) - w.flush() - os.fsync(w.fileno()) - if not expected or h.hexdigest() != expected: - os.unlink(dtmp); dtmp = None - sys.exit("mm3-hk-export: export integrity check failed (file changed under us); not published") - os.replace(dtmp, out) # atomic same-dir rename, root-owned, 0600 - dtmp = None # consumed by the rename - # fsync the containing directory so the new directory entry is crash-durable - # before we report success: a caller that then destroys the list must not be - # able to lose the backup to a crash that drops the unsynced rename. - fsync_dir(dest_dir) -except BaseException: - if dtmp is not None: - try: os.unlink(dtmp) - except OSError: pass - raise -finally: - if fd != -1: - try: os.close(fd) - except OSError: pass - try: os.unlink(src) - except OSError: pass -PY - then + # The export was written by the privilege-dropped web user into + # /var/tmp; copy it out as root (the web user can't write into a + # root-owned destination). Stage it in the destination dir and rename + # into place so $out is never left partial. + dest_dir=$(dirname "$out") + mkdir -p "$dest_dir" || { echo "mm3-hk-export: cannot create $dest_dir" >&2; exit 1; } + tmp=$(mktemp "$dest_dir/.mm3hk.XXXXXX") || { echo "mm3-hk-export: cannot create temp in $dest_dir" >&2; exit 1; } + if cp "$path" "$tmp" && mv -f "$tmp" "$out"; then + rm -f "$path" + echo "mm3-hk-export: wrote $count messages to $out" >&2 + else + rm -f "$tmp" "$path" echo "mm3-hk-export: failed to publish export to $out" >&2 exit 1 fi - echo "mm3-hk-export: wrote $count messages to $out" >&2 ;; *) echo "mm3-hk-export: unexpected status '$status'" >&2