From c2b304e4a986af8cd8fe86e015e6675dc7c783a8 Mon Sep 17 00:00:00 2001 From: veg Date: Sun, 31 May 2026 01:36:54 +0000 Subject: [PATCH] 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. --- README.md | 25 +++++++++++++------- mm3-hk-export | 64 ++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5cc8d4d..c12600c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/mm3-hk-export b/mm3-hk-export index 1e5be38..22f4d8f 100755 --- a/mm3-hk-export +++ b/mm3-hk-export @@ -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 <&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 ;; *)