Drop the /var/tmp handoff hardening for a plain copy

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.
This commit is contained in:
veg 2026-05-31 14:47:35 +00:00
parent a39ca3157f
commit f4ed22ceab
2 changed files with 19 additions and 98 deletions

View file

@ -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.

View file

@ -73,7 +73,7 @@ EOF
}
result=$(mailman-web shell <<EOF
import sys, os, tempfile, gzip, hashlib, time
import sys, os, tempfile, gzip, time
from hyperkitty.models import MailingList
addr = "$addr"
GZIP = $gzip_py
@ -90,9 +90,7 @@ if n == 0:
print("MM3HK|EMPTY|0")
sys.exit(0)
fd, path = tempfile.mkstemp(prefix="mm3hk-", suffix=".mbox", dir="/var/tmp")
# w+b so we can read the bytes back through this same fd (the original inode,
# immune to any /var/tmp name-swap) to hash exactly what we wrote.
raw = os.fdopen(fd, "w+b")
raw = os.fdopen(fd, "wb")
# mtime=0 keeps the gzip header deterministic (no timestamp), matching the
# web export, which uses a bare zlib gzip stream.
fh = gzip.GzipFile(fileobj=raw, mode="wb", mtime=0) if GZIP else raw
@ -122,19 +120,10 @@ try:
finally:
if GZIP:
fh.close() # flush the gzip trailer into raw (does not close raw)
raw.flush()
raw.close()
if tty:
sys.stderr.write("\r\033[K"); sys.stderr.flush() # clear the bar
# Hash the on-disk bytes via the held fd, then hand root the digest so it can
# verify the file wasn't swapped/truncated by another same-uid process before
# it copies it. (A fully-compromised exporter is out of scope, since it controls
# the source DB, but this closes same-uid swaps, partial writes, and corruption.)
h = hashlib.sha256()
raw.seek(0)
for chunk in iter(lambda: raw.read(1 << 16), b""):
h.update(chunk)
raw.close()
print("MM3HK|FILE|%d|%s|%s" % (n, path, h.hexdigest()))
print("MM3HK|FILE|%d|%s" % (n, path))
EOF
) || { echo "mm3-hk-export: mailman-web shell failed; aborting, nothing exported" >&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