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:
parent
a39ca3157f
commit
f4ed22ceab
2 changed files with 19 additions and 98 deletions
|
|
@ -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.
|
||||
|
|
|
|||
110
mm3-hk-export
110
mm3-hk-export
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue