#!/usr/bin/python3 -I # strip-escaping-links.py — post-extract symlink authority (N2-1). # # python3 -I strip-escaping-links.py [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 , or to itself; # - it is dangling (strict resolution fails), or loops. # Own-account absolute links (cPanel's wp-content -> /home//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]// for the TRUSTED username only (the WHP # account name WHP passes as extract.sh's ; names read from the # archive are never trusted) is mapped to //homedir/, where # is the link's own top-level dir, and kept only if THAT resolves # (realpath strict) inside /homedir. Every other absolute target either # dangles here or resolves into the container's own /etc, /usr, ... — both # outside — 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 [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 /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)