Files
cpanel-importer/scripts/lib/strip-escaping-links.py
T
shadowdaoandClaude Opus 5.5 72b8fa9d08 extract: post-extract realpath check of every symlink; classifier refuses link chains (N2-1 review)
The lexical relative-target check missed chains: `a -> b/../..` with
`b -> ../..`, or `l1 -> l2/../../..`, each resolve inside the archive
lexically but above the extract through the other link.

- scripts/lib/strip-escaping-links.py (new, the authority): after tar,
  lstat-walk the WHOLE extract (never following links), realpath(strict)
  every symlink, and remove + record (stripped_escaping_symlink) any that
  resolves outside the extract or to it, dangles or loops. Repeats to a
  fixed point; a walk error fails the extract. Own-account absolute
  /home[N]/<user>/ targets are kept (they cannot resolve in the sandbox and
  resolve only inside the customer's site container on WHP).
- scan-symlinks.php: two passes; a relative link whose resolution passes
  through another symlink entry of the archive is DANGEROUS.
- fixtures/tests: cpmove-chain (both shapes) and a hand-built tree for the
  post-extract pass (chains, /etc, dangling .bak sidecar, escaping .bak,
  loop, link to the top). 3 chain checks fail on a226b49.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:47:04 -07:00

103 lines
4.0 KiB
Python

#!/usr/bin/python3 -I
# strip-escaping-links.py — post-extract symlink authority (N2-1).
#
# python3 -I strip-escaping-links.py <dest> <actions.json> [username ...]
#
# The pre-extract classifier (scan-symlinks.php) resolves a relative target
# LEXICALLY, so a chain through another link (`a -> b/..` with `b -> x/y/z`,
# or `l1 -> l2/../../..`) can slip past it. This pass is the authority: it
# lstat-walks the WHOLE extracted tree (never following a link) and asks the
# kernel (realpath strict) where every symlink really resolves. A link is
# removed and recorded as a `stripped_escaping_symlink` action when:
# - it resolves outside <dest>, or to <dest> itself;
# - it is dangling (strict resolution fails), or loops.
# Exception: an ABSOLUTE target under /home[N]/<username>/ for one of the
# given usernames (cPanel's own-account links, e.g. wp-content ->
# /home/<u>/shared). Those cannot resolve inside this sandbox by design, and
# WHP resolves them only inside the customer's own site container; the
# pre-extract scan calls them BENIGN_USER_INTERNAL. Every other absolute
# target either dangles here or resolves into the container's own /etc,
# /usr, ... — both outside <dest> — so it is stripped.
#
# Repeats until a pass strips nothing (removing one link can leave another
# that resolved through it dangling). Any walk error fails closed (exit 1).
# stdout: number of links stripped. Exit 0 ok, 1 error, 2 usage.
import json
import os
import re
import stat
import sys
def main(argv):
if len(argv) < 3:
sys.stderr.write("usage: strip-escaping-links.py <dest> <actions.json> [username ...]\n")
return 2
dest = os.path.realpath(argv[1])
actions_path = argv[2]
users = [u for u in argv[3:] if u]
user_res = [re.compile(r'\A/home\d*/' + re.escape(u) + r'(/|\Z)') for u in users]
if not os.path.isdir(dest):
sys.stderr.write(f"strip-escaping-links: not a directory: {dest}\n")
return 1
def walk_error(err):
raise err
stripped = []
while True:
this_pass = 0
for dirpath, dirnames, filenames in os.walk(dest, topdown=True, onerror=walk_error, followlinks=False):
for name in list(dirnames) + list(filenames):
p = os.path.join(dirpath, name)
st = os.lstat(p)
if not stat.S_ISLNK(st.st_mode):
continue
target = os.readlink(p)
if target.startswith('/') and any(r.match(target) for r in user_res):
continue
reason = None
try:
real = os.path.realpath(p, strict=True)
if not real.startswith(dest + '/'):
reason = 'resolves outside the extract'
except OSError:
reason = 'dangling or looping'
if reason is None:
continue
os.unlink(p)
if name in dirnames:
dirnames.remove(name)
stripped.append({
'action': 'stripped_escaping_symlink',
'archive_path': os.path.relpath(p, dest),
'target': target,
'reason': reason,
})
sys.stderr.write(f" STRIP {os.path.relpath(p, dest)} -> {target!r} ({reason})\n")
this_pass += 1
if this_pass == 0:
break
if stripped:
try:
with open(actions_path) as fh:
doc = json.load(fh)
except (OSError, ValueError):
doc = {'actions': []}
doc.setdefault('actions', []).extend(stripped)
doc['count'] = len(doc['actions'])
with open(actions_path, 'w') as fh:
json.dump(doc, fh, indent=2)
print(len(stripped))
return 0
if __name__ == '__main__':
try:
sys.exit(main(sys.argv))
except OSError as e:
sys.stderr.write(f"strip-escaping-links: walk failed: {e}\n")
sys.exit(1)