extract: strip every symlink and boundary-crossing hard link in the mail tree
Security review of 8217397: with IMPORT_INCLUDE_MAIL=true only
DANGEROUS-classified (absolute system-path) symlinks were stripped, so a
relative link (../../../../etc/shadow, ../other-domain/box) or a hard link
into/out of the mail tree survived into the extract.
- From the archive's member list (python tarfile, exact names/types):
exclude EVERY symlink under cpmove-*/homedir/mail, and every hard link
that crosses the mail-tree boundary (either direction), spans two
accounts' mail trees, or has ".." in its target. Recorded as
stripped_mail_link actions (deduped against the DANGEROUS strips).
- Hard links with name AND target inside the same mail tree are KEPT:
Dovecot copies messages between folders as hard links
(maildir_copy_with_hardlinks, default yes), so they are real mail.
- Belt: after extract, any symlink left under the mail tree is removed and
logged.
- Fixtures: relative file/folder symlinks, an in-tree Dovecot-style hard
link, hard links crossing in and out (cpmove-mail-xlink.tar.gz). 6 new
checks fail on 8217397's extract.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9232114af4
commit
4788ca4957
3 files changed
+124
-3
No files matched your search
@@ -106,6 +106,73 @@ fi
|
||||
# lists every entry in the tarball, mail included.
|
||||
if [[ "$IMPORT_INCLUDE_MAIL" == "true" ]]; then
|
||||
log "including mail tree (cpmove-*/homedir/mail) — IMPORT_INCLUDE_MAIL=true"
|
||||
# A Maildir has no legitimate symlinks. scan-symlinks.php only strips
|
||||
# DANGEROUS (absolute system-path) targets, so a relative symlink
|
||||
# (../../../../etc/shadow, ../other-domain/box) would survive into the
|
||||
# extract. Strip EVERY symlink entry under the mail tree. Hard links
|
||||
# are different: Dovecot copies a message between folders as a hard
|
||||
# link (maildir_copy_with_hardlinks, default yes), so a link whose name
|
||||
# AND target are both inside the same account's mail tree is real mail
|
||||
# and is kept. Any hard link crossing the mail-tree boundary (either
|
||||
# direction) or with a ".." in its target is stripped. Read from the
|
||||
# archive's own member list (python tarfile: exact names and types, no
|
||||
# `tar -tv` text parsing); recorded as actions like the DANGEROUS
|
||||
# strips; escaped for tar's glob-style --exclude-from.
|
||||
MAIL_LINKS=$(python3 - "$TARBALL" "$EXCLUDES_FILE" "$ACTIONS_OUT" <<'PY'
|
||||
import json, re, sys, tarfile
|
||||
tar_path, excl_path, actions_path = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
mail = re.compile(r'^(cpmove-[^/]+)/homedir/mail(/|$)')
|
||||
def norm(n):
|
||||
while n.startswith('./'):
|
||||
n = n[2:]
|
||||
return n.lstrip('/')
|
||||
def mail_owner(n):
|
||||
"""The cpmove-<user> whose mail tree holds n, else None ('..' never counts)."""
|
||||
if '..' in n.split('/'):
|
||||
return None
|
||||
m = mail.match(n)
|
||||
return m.group(1) if m else None
|
||||
# Entries the DANGEROUS pass already strips are not listed twice.
|
||||
try:
|
||||
with open(actions_path) as ah:
|
||||
already = {a.get('path') for a in json.load(ah).get('actions', [])}
|
||||
except Exception:
|
||||
already = set()
|
||||
found = []
|
||||
with tarfile.open(tar_path, 'r|*') as tf:
|
||||
for m in tf:
|
||||
name = norm(m.name)
|
||||
if m.issym() and mail.match(name):
|
||||
found.append((m.name, 'symlink', m.linkname))
|
||||
elif m.islnk():
|
||||
target = norm(m.linkname)
|
||||
in_name, in_target = mail_owner(name), mail_owner(target)
|
||||
crosses = (in_name or mail.match(name)) or (in_target or mail.match(target))
|
||||
if crosses and not (in_name is not None and in_name == in_target):
|
||||
found.append((m.name, 'hardlink', m.linkname))
|
||||
found = [f for f in found if f[0] not in already]
|
||||
with open(excl_path, 'a') as eh:
|
||||
for name, _, _ in found:
|
||||
eh.write(re.sub(r'([*?\[\\])', r'\\\1', name) + '\n')
|
||||
try:
|
||||
with open(actions_path) as ah:
|
||||
doc = json.load(ah)
|
||||
except Exception:
|
||||
doc = {'actions': [], 'count': 0}
|
||||
doc['actions'] += [
|
||||
{'action': 'stripped_mail_link', 'path': name, 'target': target,
|
||||
'reason': f'{kind} inside the mail tree (Maildirs have no legitimate links)'}
|
||||
for name, kind, target in found
|
||||
]
|
||||
doc['count'] = len(doc['actions'])
|
||||
with open(actions_path, 'w') as ah:
|
||||
json.dump(doc, ah, indent=2)
|
||||
for name, kind, target in found:
|
||||
sys.stderr.write(f' STRIP {kind}: {name} -> {target}\n')
|
||||
print(len(found))
|
||||
PY
|
||||
) || { log "mail-tree link scan failed; aborting"; exit 3; }
|
||||
log "stripped $MAIL_LINKS symlink / boundary-crossing hard-link entr(y/ies) in the mail tree"
|
||||
else
|
||||
{
|
||||
echo "cpmove-*/homedir/mail"
|
||||
@@ -143,5 +210,18 @@ tar --no-same-owner --no-same-permissions --no-overwrite-dir \
|
||||
--exclude-from="$EXCLUDES_FILE" \
|
||||
$TAR_FLAGS "$TARBALL"
|
||||
|
||||
if [[ "$IMPORT_INCLUDE_MAIL" == "true" ]]; then
|
||||
# Belt: no symlink may remain under the mail tree (the excludes above
|
||||
# should already guarantee it). Remove and log each one.
|
||||
LEFT=0
|
||||
while IFS= read -r -d '' p; do
|
||||
log " REMOVE symlink left in the mail tree after extract: ${p#"$DEST"/}"
|
||||
rm -f -- "$p"
|
||||
LEFT=$((LEFT + 1))
|
||||
done < <(find "$DEST" -mindepth 3 -maxdepth 3 -path "$DEST/cpmove-*/homedir/mail" -prune \
|
||||
-exec find {} -type l -print0 \;)
|
||||
[[ $LEFT -eq 0 ]] || log "removed $LEFT symlink(s) from the mail tree after extract"
|
||||
fi
|
||||
|
||||
log "extracted OK ($(find "$DEST" -type f | wc -l) files; $DANGEROUS_COUNT symlinks stripped)"
|
||||
exit 0
|
||||
+28
-3
@@ -23,7 +23,7 @@
|
||||
# must be stripped even when mail is included.
|
||||
#
|
||||
# Run: bash tests/build-fixtures.sh
|
||||
# Output: tests/fixtures/cpmove-{clean,alfa,mail}.tar.gz
|
||||
# Output: tests/fixtures/cpmove-{clean,alfa,mail,mail-xlink}.tar.gz
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -139,9 +139,34 @@ printf 'From: b@example.net\nSubject: two\n\narchived\n' \
|
||||
printf 'Archive\n' > "$MROOT/mail/$DOMAIN/alice/subscriptions"
|
||||
# DANGEROUS symlink inside the mail tree: stripped whether or not mail is included.
|
||||
ln -sf "/etc" "$MROOT/mail/$DOMAIN/alice/.evil"
|
||||
# Relative symlinks inside the mail tree (not DANGEROUS-classified): a file
|
||||
# link climbing out to /etc/shadow and a folder link to another domain's box.
|
||||
ln -sf "../../../../../../../../etc/shadow" "$MROOT/mail/$DOMAIN/alice/.evil-rel"
|
||||
mkdir -p "$MROOT/mail/other.example/bob"/{cur,new,tmp}
|
||||
ln -sf "../../other.example/bob" "$MROOT/mail/$DOMAIN/alice/.Other"
|
||||
# Hard links. With --sort=name below, the first name of an inode is stored
|
||||
# as the file and later names as hard-link entries:
|
||||
# .Archive/cur/1700000002... (file) <- cur/1700000002... Dovecot-style
|
||||
# copy inside the mail tree: legitimate, must be KEPT;
|
||||
# public_html/stolen.txt -> that message: crosses OUT of the mail tree
|
||||
# (cpmove-mail-xlink.tar.gz only, below);
|
||||
# etc/<domain>/shadow (file) <- mail/<domain>/alice/cur/leak: crosses IN.
|
||||
printf 'From: c@example.net\nSubject: three\n\ncopied\n' \
|
||||
> "$MROOT/mail/$DOMAIN/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S"
|
||||
ln "$MROOT/mail/$DOMAIN/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S" \
|
||||
"$MROOT/mail/$DOMAIN/alice/cur/1700000002.M3P1.host,S=41:2,S"
|
||||
ln "$MROOT/etc/$DOMAIN/shadow" "$MROOT/mail/$DOMAIN/alice/cur/leak"
|
||||
|
||||
tar -C "$MAIL_TMP" -czf "$FIXTURES_DIR/cpmove-mail.tar.gz" "cpmove-$USER"
|
||||
echo "wrote $FIXTURES_DIR/cpmove-mail.tar.gz ($(stat -c%s "$FIXTURES_DIR/cpmove-mail.tar.gz") bytes)"
|
||||
tar -C "$MAIL_TMP" --sort=name -czf "$FIXTURES_DIR/cpmove-mail.tar.gz" "cpmove-$USER"
|
||||
|
||||
# cpmove-mail-xlink.tar.gz: the same PLUS public_html/stolen.txt as a hard
|
||||
# link to a message (crosses OUT of the mail tree). Separate fixture: in
|
||||
# the default (no-mail) mode tar cannot create a link to an excluded
|
||||
# member and the extract fails (fail-closed, as before this change).
|
||||
ln "$MROOT/mail/$DOMAIN/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S" \
|
||||
"$MROOT/public_html/stolen.txt"
|
||||
tar -C "$MAIL_TMP" --sort=name -czf "$FIXTURES_DIR/cpmove-mail-xlink.tar.gz" "cpmove-$USER"
|
||||
echo "wrote $FIXTURES_DIR/cpmove-mail.tar.gz + cpmove-mail-xlink.tar.gz"
|
||||
|
||||
echo ""
|
||||
echo "fixtures built:"
|
||||
|
||||
@@ -52,9 +52,25 @@ check "opt-in: subscriptions extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/su
|
||||
check "opt-in: empty Maildir kept with cur/new/tmp" "[[ -d '$OUT/$U/homedir/mail/$D/empty/cur' && -d '$OUT/$U/homedir/mail/$D/empty/new' && -d '$OUT/$U/homedir/mail/$D/empty/tmp' ]]"
|
||||
check "opt-in: DANGEROUS symlink inside the mail tree stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.evil' && ! -L '$OUT/$U/homedir/mail/$D/alice/.evil' ]]"
|
||||
check "opt-in: the strip is recorded as an action" "grep -q 'homedir/mail/$D/alice/.evil' '$WORK/ex-mail.actions.json'"
|
||||
check "opt-in: relative file symlink (../..../etc/shadow) inside the mail tree stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.evil-rel' && ! -L '$OUT/$U/homedir/mail/$D/alice/.evil-rel' ]]"
|
||||
check "opt-in: relative folder symlink to another domain's box stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/.Other' && ! -L '$OUT/$U/homedir/mail/$D/alice/.Other' ]]"
|
||||
check "opt-in: no symlink at all left under the mail tree" "[[ -z \$(find '$OUT/$U/homedir/mail' -type l) ]]"
|
||||
check "opt-in: the DANGEROUS in-mail symlink is listed once, not twice" "[[ \$(grep -c 'alice/.evil\"' '$WORK/ex-mail.actions.json') == 1 ]]"
|
||||
check "opt-in: relative symlinks recorded as stripped_mail_link actions" "grep -q 'alice/.evil-rel' '$WORK/ex-mail.actions.json' && grep -q 'alice/.Other' '$WORK/ex-mail.actions.json'"
|
||||
check "opt-in: hard link INSIDE the mail tree (Dovecot copy) kept" "[[ -f '$OUT/$U/homedir/mail/$D/alice/cur/1700000002.M3P1.host,S=41:2,S' && -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S' ]]"
|
||||
check "opt-in: hard link crossing INTO the mail tree (message -> etc/shadow) stripped" "[[ ! -e '$OUT/$U/homedir/mail/$D/alice/cur/leak' ]]"
|
||||
check "opt-in: log says the mail tree was included" "grep -q 'including mail tree' '$LOG'"
|
||||
check "opt-in: no 'skipping mail tree' line" "! grep -q 'skipping mail tree' '$LOG'"
|
||||
|
||||
echo "hard link crossing OUT of the mail tree (cpmove-mail-xlink)"
|
||||
cp "$FIX/cpmove-mail-xlink.tar.gz" "$WORK/"
|
||||
OUT="$WORK/ex-xlink"
|
||||
IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$WORK/cpmove-mail-xlink.tar.gz" "$OUT" testuser "$WORK/ex-xlink.actions.json" >"$WORK/ex-xlink.log" 2>&1
|
||||
check "opt-in: public_html/stolen.txt (hard link to a message) stripped" "[[ ! -e '$OUT/$U/homedir/public_html/stolen.txt' ]]"
|
||||
check "opt-in: the linked message itself still extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000002.M3P1.host,S=41:2,S' ]]"
|
||||
check "default mode: same archive fails closed (tar cannot link to the excluded mail member)" \
|
||||
"! env -u IMPORT_INCLUDE_MAIL bash '$REPO/scripts/extract.sh' '$WORK/cpmove-mail-xlink.tar.gz' '$WORK/ex-xlink-default' testuser '$WORK/x.json' >/dev/null 2>&1"
|
||||
|
||||
echo "extract.sh with IMPORT_INCLUDE_MAIL=1 (only \"true\" opts in)"
|
||||
OUT="$WORK/ex-one"
|
||||
IMPORT_INCLUDE_MAIL=1 bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-one.actions.json" >/dev/null 2>&1
|
||||
|
||||
Reference in new issue
Block a user