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>
103 lines
4.0 KiB
Python
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)
|