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:
parent
f7a9714e93
commit
c2b304e4a9
2 changed files with 62 additions and 27 deletions
25
README.md
25
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
;;
|
||||
*)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue