#!/usr/bin/env bash # # extract.sh — symlink scan + sanitized cpmove untar. # # Usage: extract.sh [] # # 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 (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 []}" DEST="${2:?usage: extract.sh []}" USERNAME="${3:?usage: extract.sh []}" 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" # Any failure after this point removes the (partial) extract before exiting: # it is the container's own scratch dir inside the bind-mounted output, and a # half-extracted tree, or one the link check refused, must not be left # behind for anything to read. Directories the archive made unreadable are # opened up first (find -P never follows links; -type d skips them) so rm # can descend. fail_extract() { local code="$1"; shift log "$*" cd / if [[ -n "$DEST" && "$DEST" != "/" && -d "$DEST" ]]; then find -P "$DEST" -type d ! -perm -u=rwx -exec chmod u+rwx {} \; 2>/dev/null || true rm -rf -- "$DEST" || log "WARN: could not remove the failed extract $DEST" log "removed the failed extract $DEST" fi exit "$code" } # --- 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 cat "$SYMLINK_REPORT" >&2 || true fail_extract 3 "scan-symlinks.php exited with usage/IO error; aborting (this is not a sanitize-able state)" 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 link policy and verify below apply to # /homedir/mail, not only cpmove-*: WHP accepts any top-level # dir name. (The default-mode exclude keeps its historical cpmove-* scope.) 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" # Link policy for the mail tree (scripts/lib/mail-tree-links.py): every # symlink in it, and every hard link crossing its boundary, is excluded # here; Dovecot's in-tree hard-link copies are kept. The archive is # refused outright if any member name could be read differently by the # classifier and by tar (control characters, non-canonical paths). # scan-symlinks.php alone only strips DANGEROUS absolute targets. MAIL_LINKS=$(python3 -I "$SCRIPT_DIR/lib/mail-tree-links.py" scan "$TARBALL" "$EXCLUDES_FILE" "$ACTIONS_OUT") \ || fail_extract 3 "mail-tree link scan refused the archive (see above); aborting — the same backup may import with 'Import without email'" 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's exit code is captured, not fatal on the spot: the mail-tree verify # below must run on whatever tar did write, and a failed tar must still get # its partial tree removed. TAR_RC=0 tar --no-same-owner --no-same-permissions --no-overwrite-dir \ --exclude-from="$EXCLUDES_FILE" \ $TAR_FLAGS "$TARBALL" || TAR_RC=$? # N2-1 authority for the WHOLE tree: the classifier above resolves relative # targets lexically, so a chain through another link (a -> b/.., b -> x/y/z) # can pass it. strip-escaping-links.py asks realpath() where every extracted # symlink really lands and removes (records) any that leave $DEST, point at # $DEST itself, dangle or loop — own-account absolute /home// targets # excepted. A walk error fails the extract closed. LINK_USERS=("$USERNAME") for top in "$DEST"/*/; do [[ -d "$top" && ! -L "${top%/}" ]] || continue t="$(basename "$top")"; t="${t#cpmove-}"; LINK_USERS+=("$t" "${t##*_}") done STRIP_RC=0 ESCAPING=$(python3 -I "$SCRIPT_DIR/lib/strip-escaping-links.py" "$DEST" "$ACTIONS_OUT" "${LINK_USERS[@]}") || STRIP_RC=$? if [[ $STRIP_RC -eq 0 && "${ESCAPING:-0}" -gt 0 ]]; then log "stripped $ESCAPING symlink(s) that resolve outside the extract, dangle or loop (post-extract check)" fi VERIFY_RC=0 if [[ "$IMPORT_INCLUDE_MAIL" == "true" ]]; then # The authority: whatever the classifier above decided, no symlink, no # hard link leaving the mail tree, and no uninspectable directory may # exist in the extract. Always runs, even after a failed tar. python3 -I "$SCRIPT_DIR/lib/mail-tree-links.py" verify "$DEST" || VERIFY_RC=$? fi if [[ $TAR_RC -ne 0 ]]; then fail_extract "$TAR_RC" "tar failed (rc=$TAR_RC)$( [[ $VERIFY_RC -ne 0 ]] && echo "; mail-tree verify also failed (rc=$VERIFY_RC)" ); aborting" fi if [[ $STRIP_RC -ne 0 ]]; then fail_extract 4 "post-extract symlink check failed (rc=$STRIP_RC); aborting" fi if [[ $VERIFY_RC -ne 0 ]]; then fail_extract 4 "extracted mail tree failed the link/readability check (see above); aborting — the same backup may import with 'Import without email'" fi log "extracted OK ($(find "$DEST" -type f | wc -l) files; $DANGEROUS_COUNT symlinks stripped)" exit 0