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.
This commit is contained in:
veg 2026-05-31 01:36:54 +00:00
parent f7a9714e93
commit c2b304e4a9
2 changed files with 62 additions and 27 deletions

View file

@ -23,9 +23,10 @@ separate from Mailman **core**. That is why the export must go through
(Django site at `/usr/share/mailman3-web/`, HyperKitty installed).
- Run as **root**: `mailman-web` drops privileges to the web user to read the
archive, and the script (as root) moves the finished file into place.
- Optional, only to enrich the "not archived" notice: `curl` and the
`mailman-wrapper` core CLI (for a best-effort `archive_policy` lookup over the
core REST API). Missing either just falls back to a generic message.
- Optional, only to enrich the "not archived" notice: the `mailman-wrapper`
core CLI (for a best-effort `archive_policy` lookup via `mailman shell`,
reading core's DB directly, no REST, no credentials). Missing it just falls
back to a generic message.
## Usage
@ -64,14 +65,22 @@ output on stderr.
mkdir -p /root/mm3
for list in list1 list2; do
addr="$list@domain.tld"
mm3-hk-export "$addr" "/root/mm3/$list.mbox" # skips cleanly if no archive
mailman-wrapper members "$addr" > "/root/mm3/$list"
mailman-wrapper remove "$addr"
# Only destroy a list once BOTH the archive export and the member dump
# succeed. mm3-hk-export exits 0 (writing no file) for a list with no
# archive, so &&-gating still lets genuinely-empty lists through — it only
# blocks remove on a real failure (mailman-web error, disk full, bad mv).
if mm3-hk-export "$addr" "/root/mm3/$list.mbox" \
&& mailman-wrapper members "$addr" > "/root/mm3/$list.members"; then
mailman-wrapper remove "$addr"
else
echo "mm3-hk-export: backup FAILED for $addr — leaving list intact" >&2
fi
done
```
Export before `remove`. (HyperKitty's archive lives in a separate database and
is not deleted by core's `remove`, but exporting first keeps the intent clear.)
Export before `remove`, and never `remove` on a failed backup. (HyperKitty's
archive lives in a separate database and is not deleted by core's `remove`, but
a failed export must still halt the destroy step.)
## Caveats

View file

@ -10,10 +10,12 @@
# case where HyperKitty never created a MailingList row for it — nothing is
# written and the script exits 0 with a notice.
#
# Runs the export through Debian's `mailman-web` wrapper, which executes the
# Django site as the correct web user. The mbox is created by that (privilege-
# dropped) user via tempfile.mkstemp (0600, race-free) and then moved into
# place by this script, which is expected to run as root.
# Runs the export through Debian's mailman-web wrapper, which executes the
# Django site as the correct web user. The mbox is created by that
# privilege-dropped user via tempfile.mkstemp (0600, race-free); this script,
# running as root, then copies it into a root-owned temp file on the
# destination filesystem and atomically renames it into place, so the published
# backup is root-owned and $out is never left partial.
set -eu
@ -50,21 +52,22 @@ if ! printf '%s' "$addr" | grep -Eq '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+$'; then
exit 2
fi
# Best-effort: ask Mailman *core* (via its REST API) for a list's archive_policy.
# Used only to explain why a list has no HyperKitty archive. Echoes the policy
# string (never|private|public) on success, nothing on any failure — it must
# never abort the export. REST root + credentials are read live from
# `mailman info` so no secrets are baked into this script.
# Best-effort: ask Mailman core for a list's archive_policy, to explain why a
# list has no HyperKitty archive. Reads it straight from core's own DB through
# "mailman-wrapper shell", whose Python namespace pre-binds getUtility() and
# IListManager, so there is no REST call, no credential handling, and no curl
# dependency. Echoes never|private|public on success, nothing on any failure;
# it must never abort the export. The address is interpolated into the Python
# snippet below, gated by the same regex that guards the export heredoc.
core_archive_policy() {
command -v curl >/dev/null 2>&1 || return 0
command -v mailman-wrapper >/dev/null 2>&1 || return 0
info=$(mailman-wrapper info 2>/dev/null) || return 0
root=$(printf '%s\n' "$info" | sed -n 's/^REST root url: *//p')
creds=$(printf '%s\n' "$info" | sed -n 's/^REST credentials: *//p')
[ -n "$root" ] && [ -n "$creds" ] || return 0
lid=$(printf '%s' "$1" | sed 's/@/./')
curl -sf -u "$creds" "${root}lists/${lid}/config/archive_policy" 2>/dev/null \
| sed -n 's/.*"archive_policy": *"\([^"]*\)".*/\1/p'
mailman-wrapper shell 2>/dev/null <<EOF | sed -n 's/^MM3HKPOL|//p'
try:
ml = getUtility(IListManager).get("$1")
print("MM3HKPOL|%s" % (ml.archive_policy.name if ml else "NOLIST"))
except Exception:
pass
EOF
}
result=$(mailman-web shell <<EOF
@ -131,8 +134,31 @@ case "$status" in
;;
FILE)
path=$(printf '%s' "$line" | cut -d'|' -f4)
mkdir -p "$(dirname "$out")"
mv "$path" "$out"
# 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"
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
;;
*)