diff --git a/scripts/extract.sh b/scripts/extract.sh index 3673878..8e344b9 100755 --- a/scripts/extract.sh +++ b/scripts/extract.sh @@ -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- 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 diff --git a/tests/build-fixtures.sh b/tests/build-fixtures.sh index 5e199ff..7c00708 100755 --- a/tests/build-fixtures.sh +++ b/tests/build-fixtures.sh @@ -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//shadow (file) <- mail//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:" diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 0170ac5..faa9db8 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -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