90 lines
3.0 KiB
Python
90 lines
3.0 KiB
Python
"""Sandboxed path resolution: keep untrusted relative paths inside a root.
|
|
|
|
Guards against path traversal, absolute paths, NUL bytes, backslash smuggling,
|
|
and symlink escapes (resolved via realpath containment). All public functions
|
|
raise PathEscapeError on any unsafe input instead of returning an escaping path.
|
|
|
|
Public API:
|
|
PathEscapeError
|
|
safe_join(root, *parts) -> str
|
|
safe_resolve(root, rel) -> str
|
|
is_within(root, target) -> bool
|
|
realpath_root(root) -> str
|
|
"""
|
|
import os
|
|
|
|
__all__ = ["PathEscapeError", "safe_join", "safe_resolve", "is_within", "realpath_root"]
|
|
|
|
|
|
class PathEscapeError(ValueError):
|
|
pass
|
|
|
|
|
|
def realpath_root(root):
|
|
if not isinstance(root, str) or root == "":
|
|
raise PathEscapeError("empty root")
|
|
r = os.path.realpath(root)
|
|
if not os.path.isdir(r):
|
|
raise PathEscapeError("root is not an existing directory: %r" % root)
|
|
return r
|
|
|
|
|
|
def _check_part(part):
|
|
if not isinstance(part, str):
|
|
raise PathEscapeError("path component must be str, got %r" % (type(part),))
|
|
if part == "":
|
|
return # empty components are harmless in join
|
|
if "\x00" in part:
|
|
raise PathEscapeError("NUL byte in path component: %r" % part)
|
|
# normalize separators: reject backslashes on all platforms to avoid smuggling
|
|
if "\\" in part:
|
|
raise PathEscapeError("backslash in path component: %r" % part)
|
|
if part.startswith("/"):
|
|
raise PathEscapeError("absolute path component: %r" % part)
|
|
# split on / and validate each
|
|
for seg in part.split("/"):
|
|
if seg in ("", "."):
|
|
continue
|
|
if seg == "..":
|
|
raise PathEscapeError("traversal component '..' in %r" % part)
|
|
if seg.startswith("/"):
|
|
raise PathEscapeError("absolute segment in %r" % part)
|
|
|
|
|
|
def safe_join(root, *parts):
|
|
rr = realpath_root(root)
|
|
for p in parts:
|
|
_check_part(p)
|
|
# build candidate without resolving yet
|
|
candidate = os.path.join(rr, *parts) if parts else rr
|
|
# normalize (collapses . and redundant slashes) but does not resolve symlinks
|
|
candidate = os.path.normpath(candidate)
|
|
# containment check on the normalized (pre-symlink) form
|
|
if candidate != rr and not candidate.startswith(rr + os.sep):
|
|
raise PathEscapeError("joined path escapes root: %r" % candidate)
|
|
# now resolve symlinks and re-check containment
|
|
resolved = os.path.realpath(candidate)
|
|
if resolved != rr and not resolved.startswith(rr + os.sep):
|
|
raise PathEscapeError("symlink escape: %r -> %r" % (candidate, resolved))
|
|
return resolved
|
|
|
|
|
|
def safe_resolve(root, rel):
|
|
if isinstance(rel, (list, tuple)):
|
|
return safe_join(root, *rel)
|
|
return safe_join(root, rel)
|
|
|
|
|
|
def is_within(root, target):
|
|
try:
|
|
rr = realpath_root(root)
|
|
except PathEscapeError:
|
|
return False
|
|
if not isinstance(target, str) or target == "":
|
|
return False
|
|
try:
|
|
rt = os.path.realpath(target)
|
|
except (OSError, ValueError):
|
|
return False
|
|
return rt == rr or rt.startswith(rr + os.sep)
|