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.
This commit is contained in:
veg 2026-05-31 01:51:17 +00:00
parent c2b304e4a9
commit 7b7fcde84a

View file

@ -134,31 +134,51 @@ case "$status" in
;;
FILE)
path=$(printf '%s' "$line" | cut -d'|' -f4)
# The web user created $path (mkstemp, 0600) in /var/tmp. Refuse to
# publish anything that isn't a plain regular file — guards against a
# symlink being swapped in before root reads it.
if [ -L "$path" ] || [ ! -f "$path" ]; then
echo "mm3-hk-export: refusing to publish non-regular export file: $path" >&2
rm -f "$path"
mkdir -p "$(dirname "$out")"
# 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 outright), confirm it's a regular file via
# the open fd, copy from that fd into a fresh root-owned temp in the
# destination dir, fsync, and atomically rename into place. Paths are
# passed as argv (heredoc is single-quoted) so nothing is interpolated.
if ! python3 - "$path" "$out" <<'PY'
import os, sys, stat, shutil, tempfile
src, out = sys.argv[1], sys.argv[2]
try:
fd = os.open(src, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as e:
sys.exit("mm3-hk-export: cannot open export temp (symlink 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=os.path.dirname(out) or ".")
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
shutil.copyfileobj(r, w)
w.flush()
os.fsync(w.fileno())
os.replace(dtmp, out) # atomic same-dir rename, root-owned, 0600
dtmp = None # consumed by the rename
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
echo "mm3-hk-export: failed to publish export to $out" >&2
exit 1
fi
dest_dir=$(dirname "$out")
mkdir -p "$dest_dir"
# Copy into a root-owned temp file *on the destination filesystem* so the
# final publish is an atomic same-fs rename (not a cross-fs copy+unlink
# that could leave a partial $out or clobber a good backup on failure),
# and so the artifact ends up owned by root, not the web user.
tmp=$(mktemp "$dest_dir/.mm3hk.XXXXXX") || {
echo "mm3-hk-export: could not create temp file in $dest_dir" >&2
rm -f "$path"; exit 1
}
if ! cp -- "$path" "$tmp"; then
echo "mm3-hk-export: failed to copy export into $dest_dir" >&2
rm -f "$tmp" "$path"; exit 1
fi
rm -f "$path"
chmod 600 "$tmp"
mv -- "$tmp" "$out"
echo "mm3-hk-export: wrote $count messages to $out" >&2
;;
*)