Security review of4788ca4(inline python link classifier): 1. Names like cpmove-x/./homedir/mail/..., homedir//mail, homedir/../homedir or /cpmove-x/... were matched differently by the classifier than by tar, so a mail-tree symlink could escape the strip (measured: the dot, dslash and abs fixtures extracted with exit 0 on4788ca4). 2. A newline in a member name split one --exclude-from pattern in two. Moved the policy to scripts/lib/mail-tree-links.py: - scan (pre-extract, an optimisation): REFUSES the archive (exit 3) on any control character in a member name or link target, or any non-canonical member name / hard-link target (absolute, '..', or normpath() differing after one leading './'). Symlink targets are not canonical-checked: ordinary cPanel links (../public_html) are legitimate outside the mail tree, and every symlink inside it is stripped anyway. - verify (post-extract, the authority): lstat-walks each mail tree; exit 4 on any symlink, or any regular file whose st_nlink exceeds its names inside that same mail tree (a link leaving it). In-tree Dovecot hard-link copies (maildir_copy_with_hardlinks) are kept; a blanket nlink>1 refusal would drop real mail. - Fixtures: cpmove-bad-{dot,dslash,dotdot,abs,newline}; verify tests for symlinks and hard links into/out of/between mail trees surviving the pre-filter. 14 of the new checks fail on4788ca4. Only in IMPORT_INCLUDE_MAIL=true mode; the default path is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
10 KiB
Bash
Executable File
163 lines
10 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# run-tests.sh — host-side regression tests for the sandbox scripts.
|
|
#
|
|
# Runs extract.sh and scan-files.php straight from the checkout (no image
|
|
# build): needs bash, php-cli, python3 and GNU tar. clamscan is replaced by
|
|
# a stub on PATH that records its argv and reports fixed "FOUND" hits, so
|
|
# the scan-files.php plumbing (exclude flags, quarantine, counts) is tested
|
|
# without ClamAV or signatures. The full image is covered by the docker
|
|
# commands in CONTRIBUTING.md.
|
|
#
|
|
# Run: bash tests/run-tests.sh (exit 0 = all pass)
|
|
|
|
set -euo pipefail
|
|
|
|
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
FIX="$REPO/tests/fixtures"
|
|
# Under /tmp: scan-files.php sets a container open_basedir that allows /tmp.
|
|
WORK="$(mktemp -d -p /tmp cpi-tests.XXXXXX)"
|
|
trap 'rm -rf "$WORK"' EXIT
|
|
|
|
FAILS=0
|
|
pass() { printf ' PASS %s\n' "$1"; }
|
|
fail() { printf ' FAIL %s\n' "$1"; FAILS=$((FAILS + 1)); }
|
|
check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; }
|
|
|
|
bash "$REPO/tests/build-fixtures.sh" >/dev/null
|
|
|
|
# Copied under /tmp: scan-symlinks.php opens the tarball under the same open_basedir.
|
|
cp "$FIX/cpmove-mail.tar.gz" "$WORK/"
|
|
MAILTAR="$WORK/cpmove-mail.tar.gz"
|
|
U=cpmove-testuser
|
|
D=example.com
|
|
|
|
# ---- extract.sh: default (no opt-in) skips the mail tree -------------------
|
|
echo "extract.sh without IMPORT_INCLUDE_MAIL"
|
|
OUT="$WORK/ex-default"
|
|
LOG="$WORK/ex-default.log"
|
|
env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-default.actions.json" >"$LOG" 2>&1
|
|
check "default: homedir/mail not extracted" "[[ ! -e '$OUT/$U/homedir/mail' ]]"
|
|
check "default: homedir/etc/<domain>/passwd still extracted" "[[ -f '$OUT/$U/homedir/etc/$D/passwd' ]]"
|
|
check "default: log says the mail tree was skipped" "grep -q 'skipping mail tree' '$LOG'"
|
|
|
|
# ---- extract.sh: opt-in includes it -----------------------------------------
|
|
echo "extract.sh with IMPORT_INCLUDE_MAIL=true"
|
|
OUT="$WORK/ex-mail"
|
|
LOG="$WORK/ex-mail.log"
|
|
IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$MAILTAR" "$OUT" testuser "$WORK/ex-mail.actions.json" >"$LOG" 2>&1
|
|
check "opt-in: INBOX message extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/cur/1700000000.M1P1.host,S=40:2,S' ]]"
|
|
check "opt-in: subfolder message extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/.Archive/cur/1700000001.M2P1.host,S=43:2,' ]]"
|
|
check "opt-in: subscriptions extracted" "[[ -f '$OUT/$U/homedir/mail/$D/alice/subscriptions' ]]"
|
|
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 "parser-differential archives are refused (IMPORT_INCLUDE_MAIL=true)"
|
|
for bad in dot dslash dotdot abs newline; do
|
|
cp "$FIX/cpmove-bad-$bad.tar.gz" "$WORK/"
|
|
OUT="$WORK/ex-bad-$bad"
|
|
rc=0
|
|
IMPORT_INCLUDE_MAIL=true bash "$REPO/scripts/extract.sh" "$WORK/cpmove-bad-$bad.tar.gz" "$OUT" testuser "$WORK/bad-$bad.json" >"$WORK/bad-$bad.log" 2>&1 || rc=$?
|
|
check "bad-$bad: extract exits non-zero (rc=$rc)" "[[ $rc -ne 0 ]]"
|
|
check "bad-$bad: refusal logged" "grep -q 'REFUSING archive' '$WORK/bad-$bad.log'"
|
|
check "bad-$bad: nothing extracted" "[[ -z \$(find '$OUT' -mindepth 1 2>/dev/null) ]]"
|
|
done
|
|
|
|
echo "post-extract authority: mail-tree-links.py verify"
|
|
V="$WORK/verify"
|
|
mkvt() { rm -rf "$V"; mkdir -p "$V/$U/homedir/mail/$D/a/cur" "$V/$U/homedir/mail/$D/a/.F/cur" "$V/$U/homedir/public_html" "$V/cpmove-other/homedir/mail/$D/b/cur"; echo m > "$V/$U/homedir/mail/$D/a/cur/1"; }
|
|
vrc() { local rc=0; python3 -I "$REPO/scripts/lib/mail-tree-links.py" verify "$V" >/dev/null 2>&1 || rc=$?; echo $rc; }
|
|
mkvt; check "verify: clean tree => 0" "[[ \$(vrc) == 0 ]]"
|
|
mkvt; ln "$V/$U/homedir/mail/$D/a/cur/1" "$V/$U/homedir/mail/$D/a/.F/cur/1"
|
|
check "verify: Dovecot hard link inside one mail tree => 0 (kept)" "[[ \$(vrc) == 0 ]]"
|
|
mkvt; ln -s ../../../../../etc "$V/$U/homedir/mail/$D/a/.evil"
|
|
check "verify: symlink in the mail tree (survived the pre-filter) => 4" "[[ \$(vrc) == 4 ]]"
|
|
mkvt; ln "$V/$U/homedir/mail/$D/a/cur/1" "$V/$U/homedir/public_html/out"
|
|
check "verify: hard link OUT of the mail tree (survived the pre-filter) => 4" "[[ \$(vrc) == 4 ]]"
|
|
mkvt; echo s > "$V/$U/homedir/shadow"; ln "$V/$U/homedir/shadow" "$V/$U/homedir/mail/$D/a/cur/in"
|
|
check "verify: hard link INTO the mail tree (survived the pre-filter) => 4" "[[ \$(vrc) == 4 ]]"
|
|
mkvt; ln "$V/$U/homedir/mail/$D/a/cur/1" "$V/cpmove-other/homedir/mail/$D/b/cur/1"
|
|
check "verify: hard link between two accounts' mail trees => 4" "[[ \$(vrc) == 4 ]]"
|
|
mkvt; rm -rf "$V/$U/homedir/mail"; ln -s /etc "$V/$U/homedir/mail"
|
|
check "verify: homedir/mail itself a symlink => 4" "[[ \$(vrc) == 4 ]]"
|
|
|
|
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
|
|
check "IMPORT_INCLUDE_MAIL=1: mail tree skipped" "[[ ! -e '$OUT/$U/homedir/mail' ]]"
|
|
|
|
# ---- scan-files.php with a stub clamscan ------------------------------------
|
|
# The stub reports one hit inside the mail tree and one in public_html.
|
|
STUBBIN="$WORK/bin"
|
|
mkdir -p "$STUBBIN"
|
|
cat > "$STUBBIN/clamscan" <<'STUB'
|
|
#!/usr/bin/env bash
|
|
printf '%s\n' "$@" > "$CLAMSCAN_ARGS_OUT"
|
|
dir="${!#}"
|
|
echo "$dir/cpmove-testuser/homedir/mail/example.com/alice/cur/1700000000.M1P1.host,S=40:2,S: Test.Mail.Sig FOUND"
|
|
echo "$dir/cpmove-testuser/homedir/public_html/index.php: Test.Php.Sig FOUND"
|
|
STUB
|
|
chmod +x "$STUBBIN/clamscan"
|
|
|
|
run_scan() { # <extract dir> <tag> [extra args]
|
|
local ex="$1" tag="$2"; shift 2
|
|
mkdir -p "$WORK/q-$tag"
|
|
CLAMSCAN_ARGS_OUT="$WORK/clam-$tag.args" PATH="$STUBBIN:$PATH" \
|
|
php "$REPO/scripts/scan-files.php" --extract "$ex" --quarantine "$WORK/q-$tag" \
|
|
--report "$WORK/scan-$tag.json" --import-id "t-$tag" "$@" 2>"$WORK/scan-$tag.log"
|
|
}
|
|
jq_php() { php -r '$r = json_decode(file_get_contents($argv[1]), true); echo json_encode($r[$argv[2]] ?? null);' "$1" "$2"; }
|
|
|
|
echo "scan-files.php --skip-mail (mail tree extracted)"
|
|
cp -a "$WORK/ex-mail" "$WORK/scan-skip"
|
|
run_scan "$WORK/scan-skip" skip --skip-mail
|
|
# Expected argv entry: the extract dir with ERE metacharacters escaped
|
|
# (the only one mktemp's path can hold is "."), then the mail-tree pattern.
|
|
SKIPDIR="$WORK/scan-skip"
|
|
EXCL="--exclude-dir=^${SKIPDIR//./\\.}/cpmove-[^/]+/homedir/mail(/.*)?\$"
|
|
if grep -qxF -- "$EXCL" "$WORK/clam-skip.args"; then
|
|
pass "--skip-mail: clamscan gets --exclude-dir for the mail tree"
|
|
else
|
|
fail "--skip-mail: clamscan gets --exclude-dir for the mail tree (want $EXCL; got: $(tr '\n' ' ' < "$WORK/clam-skip.args"))"
|
|
fi
|
|
check "--skip-mail: a hit inside the mail tree is NOT quarantined (message stays in the Maildir)" \
|
|
"[[ -f '$WORK/scan-skip/$U/homedir/mail/$D/alice/cur/1700000000.M1P1.host,S=40:2,S' ]]"
|
|
check "--skip-mail: a hit outside the mail tree is still quarantined" \
|
|
"[[ ! -e '$WORK/scan-skip/$U/homedir/public_html/index.php' && -f '$WORK/q-skip/$U/homedir/public_html/index.php' ]]"
|
|
check "--skip-mail: report files_quarantined = 1" "[[ \$(jq_php '$WORK/scan-skip.json' files_quarantined) == 1 ]]"
|
|
check "--skip-mail: report mail_scan_skipped = true" "[[ \$(jq_php '$WORK/scan-skip.json' mail_scan_skipped) == true ]]"
|
|
NONMAIL=$(cd "$WORK/scan-skip" && find . -type f ! -path "./$U/homedir/mail/*" | wc -l)
|
|
check "--skip-mail: files_scanned counts only non-mail files ($NONMAIL, after the quarantine move)" \
|
|
"[[ \$(jq_php '$WORK/scan-skip.json' files_scanned) == $((NONMAIL + 1)) ]]"
|
|
check "--skip-mail: the skip is logged with its reason" "grep -q 'NOT scanning the mail tree' '$WORK/scan-skip.log'"
|
|
|
|
echo "scan-files.php without --skip-mail (unchanged behaviour)"
|
|
cp -a "$WORK/ex-mail" "$WORK/scan-noskip"
|
|
run_scan "$WORK/scan-noskip" noskip
|
|
check "no flag: no --exclude-dir passed" "! grep -q -- '--exclude-dir' '$WORK/clam-noskip.args'"
|
|
check "no flag: report mail_scan_skipped = false" "[[ \$(jq_php '$WORK/scan-noskip.json' mail_scan_skipped) == false ]]"
|
|
check "no flag: both hits quarantined" "[[ \$(jq_php '$WORK/scan-noskip.json' files_quarantined) == 2 ]]"
|
|
|
|
echo
|
|
if [[ $FAILS -eq 0 ]]; then echo "ALL PASS"; else echo "$FAILS FAILURE(S)"; fi
|
|
exit $(( FAILS == 0 ? 0 : 1 ))
|