diff --git a/scripts/lib/scan-symlinks.php b/scripts/lib/scan-symlinks.php index 43da433..0b2587c 100644 --- a/scripts/lib/scan-symlinks.php +++ b/scripts/lib/scan-symlinks.php @@ -90,6 +90,32 @@ $dangerousPrefixes = [ '/dev', ]; +/** + * Lexical resolution of a relative link from its own directory in the + * archive: a reason when it reaches/climbs above the archive root, or when a + * link under /homedir/ resolves outside that homedir; NULL otherwise. + */ +function relative_symlink_escape(string $archivePath, string $target): ?string { + $ap = preg_replace('#^(\./)+#', '', $archivePath); + $dir = array_values(array_filter(explode('/', (string) $ap), fn($x) => $x !== '' && $x !== '.')); + array_pop($dir); + $home = (count($dir) >= 2 && $dir[1] === 'homedir') ? [$dir[0], 'homedir'] : null; + $stack = $dir; + foreach (explode('/', $target) as $seg) { + if ($seg === '' || $seg === '.') continue; + if ($seg === '..') { + if (count($stack) <= 1) return 'escapes the archive root'; + array_pop($stack); + continue; + } + $stack[] = $seg; + } + if ($home !== null && (count($stack) < 2 || $stack[0] !== $home[0] || $stack[1] !== 'homedir')) { + return 'escapes the account homedir'; + } + return null; +} + $findings = []; $cpanelUsername = null; @@ -111,7 +137,23 @@ while (($line = fgets($fh)) !== false) { $archivePath = $parts[5]; $target = $right; - if ($target === '' || $target[0] !== '/') continue; + // N2-1: a RELATIVE target that climbs out of the archive (or out of + // the account's homedir/) is DANGEROUS (stripped), not safe: GNU tar + // extracts `cpmove-u/sds -> ../../../../root/.my.cnf` as-is. Same rule + // as WHP's CpanelBackupImporter::relativeSymlinkEscape(). + if ($target !== '' && $target[0] !== '/') { + $escape = relative_symlink_escape($archivePath, $target); + if ($escape !== null) { + $findings[] = [ + 'type' => 'DANGEROUS', + 'archive_path' => $archivePath, + 'target' => $target, + 'reason' => "relative target $escape", + ]; + } + continue; + } + if ($target === '') continue; if ($cpanelUsername === null) { if (preg_match('#^cpmove-([^/]+)/#', $archivePath, $m)) { diff --git a/tests/build-fixtures.sh b/tests/build-fixtures.sh index a6998af..5ffa644 100755 --- a/tests/build-fixtures.sh +++ b/tests/build-fixtures.sh @@ -53,8 +53,10 @@ EOF echo " "$root/cpmove-$USER/homedir/public_html/index.php" echo "Hello world." > "$root/cpmove-$USER/homedir/public_html/about.txt" - # benign user-internal relative symlink — must NOT trigger the scan - ln -sf "../public_html/about.txt" "$root/cpmove-$USER/homedir/about-shortcut" + # benign user-internal relative symlink — must NOT trigger the scan. + # (It used to be ../public_html/about.txt, which resolves OUTSIDE + # homedir/ — cpmove-$USER/public_html — and is now DANGEROUS, N2-1.) + ln -sf "public_html/about.txt" "$root/cpmove-$USER/homedir/about-shortcut" # one synthetic WordPress mysql dump with ENGINE=MyISAM + a clean siteurl cat > "$root/cpmove-$USER/mysql/${USER}_wp.sql" < public_html (inside homedir) stays. + +RELESC_TMP="$(mktemp -d)" +build_common_tree "$RELESC_TMP" +rm -rf "$RELESC_TMP/cpmove-$USER/sds" +ln -sf "../../../../../../../../etc/passwd" "$RELESC_TMP/cpmove-$USER/sds" +ln -sf "../../mysql/${USER}_wp.sql" "$RELESC_TMP/cpmove-$USER/homedir/public_html/dump.sql" +ln -sf "public_html" "$RELESC_TMP/cpmove-$USER/homedir/www" +tar -C "$RELESC_TMP" -czf "$FIXTURES_DIR/cpmove-relesc.tar.gz" "cpmove-$USER" +rm -rf "$RELESC_TMP" +echo "wrote $FIXTURES_DIR/cpmove-relesc.tar.gz" + # ---- cpmove-mail.tar.gz --------------------------------------------------- MAIL_TMP="$(mktemp -d)" diff --git a/tests/run-tests.sh b/tests/run-tests.sh index af902a6..b4b1790 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -62,6 +62,21 @@ check "opt-in: hard link crossing INTO the mail tree (message -> etc/shadow) str 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 "N2-1: relative symlinks that escape the archive / the homedir (cpmove-relesc)" +cp "$FIX/cpmove-relesc.tar.gz" "$WORK/" +php "$REPO/scripts/lib/scan-symlinks.php" --tarball "$WORK/cpmove-relesc.tar.gz" --report "$WORK/relesc-scan.json" --username testuser 2>/dev/null +relesc_type() { php -r '$r = json_decode(file_get_contents($argv[1]), true); foreach ($r["findings"] as $f) if ($f["archive_path"] === $argv[2]) { echo $f["type"]; exit; } echo "NONE";' "$WORK/relesc-scan.json" "$1"; } +check "relesc: sds -> ../../../../etc/passwd is DANGEROUS" "[[ \$(relesc_type '$U/sds') == DANGEROUS ]]" +check "relesc: homedir/public_html/dump.sql -> ../../mysql/... (escapes homedir) is DANGEROUS" "[[ \$(relesc_type '$U/homedir/public_html/dump.sql') == DANGEROUS ]]" +check "relesc: homedir/www -> public_html (inside homedir) is not a finding" "[[ \$(relesc_type '$U/homedir/www') == NONE ]]" +check "relesc: homedir/about-shortcut -> public_html/about.txt is not a finding" "[[ \$(relesc_type '$U/homedir/about-shortcut') == NONE ]]" +OUT="$WORK/ex-relesc" +env -u IMPORT_INCLUDE_MAIL bash "$REPO/scripts/extract.sh" "$WORK/cpmove-relesc.tar.gz" "$OUT" testuser "$WORK/ex-relesc.actions.json" >"$WORK/ex-relesc.log" 2>&1 +check "relesc: sds stripped from the extract" "[[ ! -e '$OUT/$U/sds' && ! -L '$OUT/$U/sds' ]]" +check "relesc: dump.sql link stripped from the extract" "[[ ! -L '$OUT/$U/homedir/public_html/dump.sql' ]]" +check "relesc: homedir/www link kept" "[[ -L '$OUT/$U/homedir/www' ]]" +check "relesc: both strips recorded" "grep -q '$U/sds' '$WORK/ex-relesc.actions.json' && grep -q 'public_html/dump.sql' '$WORK/ex-relesc.actions.json'" + echo "hard link crossing OUT of the mail tree (cpmove-mail-xlink)" cp "$FIX/cpmove-mail-xlink.tar.gz" "$WORK/" OUT="$WORK/ex-xlink"