2026-05-30 19:56:57 -07:00
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
#
|
2026-05-31 11:13:57 -07:00
|
|
|
# extract.sh — symlink scan + sanitized cpmove untar.
|
2026-05-30 19:56:57 -07:00
|
|
|
#
|
2026-05-31 11:13:57 -07:00
|
|
|
# Usage: extract.sh <tarball> <dest> <username> [<actions_out>]
|
2026-05-30 19:56:57 -07:00
|
|
|
#
|
2026-10-20 09:04:44 -07:00
|
|
|
# Env: IMPORT_INCLUDE_MAIL "true" = extract cpmove-*/homedir/mail too
|
|
|
|
|
# (WHP's mailbox import needs the Maildirs).
|
|
|
|
|
# Anything else (default) = skip the mail tree.
|
|
|
|
|
#
|
2026-05-31 11:13:57 -07:00
|
|
|
# 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.
|
2026-05-30 19:56:57 -07:00
|
|
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
2026-05-31 11:13:57 -07:00
|
|
|
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}"
|
2026-05-30 19:56:57 -07:00
|
|
|
|
2026-10-20 09:04:44 -07:00
|
|
|
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)"
|
|
|
|
|
|
2026-05-30 19:56:57 -07:00
|
|
|
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"
|
|
|
|
|
|
2026-10-20 09:18:34 -07:00
|
|
|
# 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"
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 19:56:57 -07:00
|
|
|
# --- pre-extract symlink scan ---------------------------------------------
|
|
|
|
|
|
|
|
|
|
log "scanning tarball for dangerous symlinks (cpmove vector check)"
|
|
|
|
|
SYMLINK_REPORT=$(mktemp -p /tmp scan-symlinks.XXXXXX.json)
|
2026-10-20 09:04:44 -07:00
|
|
|
if ! php "$SCRIPT_DIR/lib/scan-symlinks.php" \
|
2026-05-30 19:56:57 -07:00
|
|
|
--tarball "$TARBALL" \
|
|
|
|
|
--username "$USERNAME" \
|
|
|
|
|
--report "$SYMLINK_REPORT"; then
|
|
|
|
|
cat "$SYMLINK_REPORT" >&2 || true
|
2026-10-20 09:18:34 -07:00
|
|
|
fail_extract 3 "scan-symlinks.php exited with usage/IO error; aborting (this is not a sanitize-able state)"
|
2026-05-30 19:56:57 -07:00
|
|
|
fi
|
|
|
|
|
|
2026-05-31 11:13:57 -07:00
|
|
|
# --- 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
|
2026-05-30 19:56:57 -07:00
|
|
|
|
2026-10-20 09:04:44 -07:00
|
|
|
# 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
|
2026-10-20 09:59:41 -07:00
|
|
|
# mailboxes) extracts it. The link policy and verify below apply to
|
|
|
|
|
# <any top dir>/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
|
2026-10-20 09:04:44 -07:00
|
|
|
# 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"
|
2026-10-20 09:13:26 -07:00
|
|
|
# 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") \
|
2026-10-20 09:59:41 -07:00
|
|
|
|| fail_extract 3 "mail-tree link scan refused the archive (see above); aborting — the same backup may import with 'Import without email'"
|
2026-10-20 09:09:10 -07:00
|
|
|
log "stripped $MAIL_LINKS symlink / boundary-crossing hard-link entr(y/ies) in the mail tree"
|
2026-10-20 09:04:44 -07:00
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
echo "cpmove-*/homedir/mail"
|
|
|
|
|
echo "cpmove-*/homedir/mail/*"
|
|
|
|
|
} >> "$EXCLUDES_FILE"
|
|
|
|
|
log "skipping mail tree (cpmove-*/homedir/mail) — IMPORT_INCLUDE_MAIL not set"
|
|
|
|
|
fi
|
2026-05-31 11:57:32 -07:00
|
|
|
|
2026-05-30 19:56:57 -07:00
|
|
|
# --- 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.
|
2026-05-31 11:13:57 -07:00
|
|
|
# --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.
|
2026-05-30 19:56:57 -07:00
|
|
|
# --absolute-names is NOT used — leading / in a member name is stripped.
|
|
|
|
|
cd "$DEST"
|
2026-10-20 09:18:34 -07:00
|
|
|
# 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
|
2026-05-31 11:13:57 -07:00
|
|
|
tar --no-same-owner --no-same-permissions --no-overwrite-dir \
|
|
|
|
|
--exclude-from="$EXCLUDES_FILE" \
|
2026-10-20 09:18:34 -07:00
|
|
|
$TAR_FLAGS "$TARBALL" || TAR_RC=$?
|
2026-05-30 19:56:57 -07:00
|
|
|
|
2026-10-20 09:18:34 -07:00
|
|
|
VERIFY_RC=0
|
2026-10-20 09:09:10 -07:00
|
|
|
if [[ "$IMPORT_INCLUDE_MAIL" == "true" ]]; then
|
2026-10-20 09:18:34 -07:00
|
|
|
# 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 [[ $VERIFY_RC -ne 0 ]]; then
|
2026-10-20 09:59:41 -07:00
|
|
|
fail_extract 4 "extracted mail tree failed the link/readability check (see above); aborting — the same backup may import with 'Import without email'"
|
2026-10-20 09:09:10 -07:00
|
|
|
fi
|
|
|
|
|
|
2026-05-31 11:13:57 -07:00
|
|
|
log "extracted OK ($(find "$DEST" -type f | wc -l) files; $DANGEROUS_COUNT symlinks stripped)"
|
2026-05-30 19:56:57 -07:00
|
|
|
exit 0
|