Files
cpanel-importer/scripts/extract.sh
T
shadowdaoandClaude Opus 5.5 4788ca4957 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>
2026-10-20 09:09:10 -07:00

228 lines
9.5 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# extract.sh — symlink scan + sanitized cpmove untar.
#
# Usage: extract.sh <tarball> <dest> <username> [<actions_out>]
#
# Env: IMPORT_INCLUDE_MAIL "true" = extract cpmove-*/homedir/mail too
# (WHP's mailbox import needs the Maildirs).
# Anything else (default) = skip the mail tree.
#
# Calls scripts/lib/scan-symlinks.php first, then untars the cpmove with
# every DANGEROUS-classified symlink entry stripped via tar --exclude.
# The stripped-symlinks list is written as JSON to <actions_out> (default
# $DEST/.cpanel-importer-stripped-symlinks.json) so the merge step in
# entrypoint.sh can fold the stripped entries into report.json's actions[].
#
# Sandbox-mode posture: never refuse. ALFA-class root symlinks and other
# DANGEROUS entries are silently excluded from extraction; the panel sees
# them as quarantine actions on the results page instead of an import abort.
set -euo pipefail
TARBALL="${1:?usage: extract.sh <tarball> <dest> <username> [<actions_out>]}"
DEST="${2:?usage: extract.sh <tarball> <dest> <username> [<actions_out>]}"
USERNAME="${3:?usage: extract.sh <tarball> <dest> <username> [<actions_out>]}"
ACTIONS_OUT="${4:-${DEST}/.cpanel-importer-stripped-symlinks.json}"
IMPORT_INCLUDE_MAIL="${IMPORT_INCLUDE_MAIL:-false}"
# Resolve sibling scripts relative to this file (/scripts in the image) so
# tests/run-tests.sh can run the same script from a checkout.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ts() { date -u +'%Y-%m-%dT%H:%M:%SZ'; }
log() { printf '[%s] extract: %s\n' "$(ts)" "$*"; }
[[ -f "$TARBALL" ]] || { log "tarball not found: $TARBALL"; exit 2; }
mkdir -p "$DEST"
# --- pre-extract symlink scan ---------------------------------------------
log "scanning tarball for dangerous symlinks (cpmove vector check)"
SYMLINK_REPORT=$(mktemp -p /tmp scan-symlinks.XXXXXX.json)
if ! php "$SCRIPT_DIR/lib/scan-symlinks.php" \
--tarball "$TARBALL" \
--username "$USERNAME" \
--report "$SYMLINK_REPORT"; then
log "scan-symlinks.php exited with usage/IO error; aborting (this is not a sanitize-able state)"
cat "$SYMLINK_REPORT" >&2 || true
exit 3
fi
# --- compute exclude list from dangerous findings -------------------------
# Build a newline-delimited list of archive_path strings for tar --exclude-
# from. Also build a JSON actions[] array so entrypoint.sh's merge step can
# fold the strip-actions into report.json without re-parsing scan-symlinks.
EXCLUDES_FILE=$(mktemp -p /tmp tar-excludes.XXXXXX)
DANGEROUS_COUNT=$(python3 - "$SYMLINK_REPORT" "$EXCLUDES_FILE" "$ACTIONS_OUT" <<'PY'
import json, sys
src, excl_path, actions_path = sys.argv[1], sys.argv[2], sys.argv[3]
try:
with open(src) as fh:
r = json.load(fh)
except Exception as e:
sys.stderr.write(f"failed to parse scan-symlinks report: {e}\n")
print(0)
sys.exit(0)
dangerous = [f for f in r.get('findings', []) if f.get('type') == 'DANGEROUS']
with open(excl_path, 'w') as eh:
for f in dangerous:
p = f.get('archive_path', '')
if p:
eh.write(p + '\n')
actions = [
{
'action': 'stripped_dangerous_symlink',
'path': f.get('archive_path', ''),
'target': f.get('target', ''),
'reason': f.get('reason', ''),
}
for f in dangerous
]
with open(actions_path, 'w') as ah:
json.dump({'actions': actions, 'count': len(actions)}, ah, indent=2)
print(len(dangerous))
PY
)
if [[ "$DANGEROUS_COUNT" -gt 0 ]]; then
log "stripping $DANGEROUS_COUNT dangerous symlink(s) via tar --exclude-from"
while IFS= read -r path; do
log " STRIP: $path"
done < "$EXCLUDES_FILE"
fi
# Mail tree (cpmove-*/homedir/mail). Skipped by default: a plain site
# import doesn't need it, and on real customer accounts the mail dir often
# dwarfs everything else (10+ GB of historical maildir). The exclude
# pattern uses tar's fnmatch globs and covers the whole tree at any depth.
#
# IMPORT_INCLUDE_MAIL=true (WHP passes it only when the import copies
# mailboxes) extracts it. The DANGEROUS-symlink strips computed above apply
# to the mail tree exactly as to the rest of the archive: scan-symlinks.php
# 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"
echo "cpmove-*/homedir/mail/*"
} >> "$EXCLUDES_FILE"
log "skipping mail tree (cpmove-*/homedir/mail) — IMPORT_INCLUDE_MAIL not set"
fi
# --- extract --------------------------------------------------------------
# Detect compression. cpmove can be .tar.gz / .tar.bz2 / .tar.
TAR_FLAGS="-xf"
case "$TARBALL" in
*.tar.gz|*.tgz) TAR_FLAGS="-xzf" ;;
*.tar.bz2|*.tbz2) TAR_FLAGS="-xjf" ;;
*.tar.xz|*.txz) TAR_FLAGS="-xJf" ;;
*.tar) TAR_FLAGS="-xf" ;;
esac
log "extracting with hardened tar flags into $DEST"
# Hardening flags (mirrored from CpanelBackupImporter::extractBackup):
# --no-same-owner / --no-same-permissions: drop archive-recorded
# uid/perm bits so the cpmove can't drop setuid binaries at us.
# --no-overwrite-dir: refuse to clobber existing directory metadata,
# closing one historical tar-symlink-escape vector.
# --exclude-from=$EXCLUDES_FILE: strip every DANGEROUS-classified
# symlink (target = /, /etc, /root, /boot, /proc, /sys, /dev).
# Empty file = no-op exclude. tar's --exclude pattern matching
# uses fnmatch but our archive_path entries don't contain glob
# metacharacters (they came verbatim from `tar -tvf`), so the
# match is effectively a literal-path skip.
# --absolute-names is NOT used — leading / in a member name is stripped.
cd "$DEST"
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