Files
cpanel-importer/scripts/lib/strip-escaping-links.py
shadowdaoandClaude Opus 5.5 36d4949d34 extract: own-account links judged re-rooted, normalized, for the trusted username only (N2-1 review 2)
- strip-escaping-links.py: the /home[N]/<user>/ exemption ran on the RAW
  target (`/home/u/../../etc/shadow` was exempt) and on usernames taken
  from the archive's own top-dir names (an archive could name itself
  cpmove-root). Now only the username WHP passes (extract.sh <username> =
  IMPORT_USERNAME, validated) is trusted; the target must be normalized; and
  it is RE-ROOTED at the link's <top>/homedir in the extract and kept only
  if that resolves (realpath strict) inside it. No trusted name: nothing
  exempt.
- scan-symlinks.php: same trusted-name rule; a non-normalized absolute
  target is DANGEROUS outright (never prefix-compared).
- tests: /home/u/../../etc/shadow, /home/u/./x, re-rooted dangling,
  re-rooted through an escaping link, another user's /home, an
  archive-named user (cpmove-root) via extract.sh, no/invalid trusted name.
  8 checks fail on 72b8fa9.

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

136 lines
5.6 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.
# Own-account absolute links (cPanel's wp-content -> /home/<u>/shared and
# the like) are judged RE-ROOTED, never against the container's own /home:
# a target that is already normalized (no '.', '..' or empty component)
# and lies under /home[N]/<user>/ for the TRUSTED username only (the WHP
# account name WHP passes as extract.sh's <username>; names read from the
# archive are never trusted) is mapped to <dest>/<top>/homedir/<rest>, where
# <top> is the link's own top-level dir, and kept only if THAT resolves
# (realpath strict) inside <top>/homedir. Every other absolute target either
# dangles here or resolves into the container's own /etc, /usr, ... — both
# outside <dest> — so it is stripped. A link to the extract itself is
# stripped too.
#
# 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 posixpath
import re
import stat
import sys
def main(argv):
if len(argv) < 3:
sys.stderr.write("usage: strip-escaping-links.py <dest> <actions.json> [trusted-username]\n")
return 2
dest = os.path.realpath(argv[1])
actions_path = argv[2]
trusted = argv[3] if len(argv) > 3 else ''
if not re.fullmatch(r'[a-z_][a-z0-9_-]{0,31}', trusted):
trusted = '' # no (valid) trusted name: exempt nothing
own_re = re.compile(r'\A/home\d*/' + re.escape(trusted) + r'(?:/(.*))?\Z') if trusted else None
def own_account_ok(link_path, target):
"""True when an absolute own-account target, re-rooted at the link's
<top>/homedir, resolves inside that homedir."""
if own_re is None or posixpath.normpath(target) != target.rstrip('/') or '/../' in target + '/' or '/./' in target + '/':
return False
m = own_re.match(target.rstrip('/'))
if not m:
return False
rel = os.path.relpath(link_path, dest).split(os.sep)
if len(rel) < 2:
return False
home = os.path.join(dest, rel[0], 'homedir')
try:
hst = os.lstat(home)
if not stat.S_ISDIR(hst.st_mode):
return False
home_real = os.path.realpath(home, strict=True)
cand = os.path.join(home_real, m.group(1)) if m.group(1) else home_real
real = os.path.realpath(cand, strict=True)
except OSError:
return False
return real == home_real or real.startswith(home_real + '/')
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)
reason = None
if target.startswith('/') and own_re is not None and own_re.match(target.rstrip('/') or '/'):
if own_account_ok(p, target):
continue
reason = 'own-account target does not resolve inside the extracted homedir'
try:
if reason is None:
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)