83 lines
2.9 KiB
Python
83 lines
2.9 KiB
Python
"""Safe path join: prevent traversal outside a base directory.
|
|
|
|
safe_join(base, *parts) returns an absolute path that is guaranteed
|
|
to live inside base (resolved). Raises PathTraversalError on any attempt
|
|
to escape (absolute parts, '..', symlinks that resolve outside, etc.).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path, PurePath
|
|
|
|
|
|
class PathTraversalError(ValueError):
|
|
"""Raised when a joined path escapes the base directory."""
|
|
|
|
|
|
def _is_under(child: Path, parent: Path) -> bool:
|
|
try:
|
|
child.relative_to(parent)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def safe_join(base, *parts) -> str:
|
|
"""Join *parts onto base, rejecting anything that escapes base.
|
|
|
|
- base must be an existing directory (or creatable? we require existing).
|
|
- Each part is treated as a relative path; absolute parts and '..'
|
|
segments that escape are rejected.
|
|
- Symlinks are resolved; if a symlink points outside base, rejected.
|
|
- Empty parts are ignored.
|
|
- Returns a normalized absolute string path.
|
|
"""
|
|
base_path = Path(base).resolve(strict=True)
|
|
if not base_path.is_dir():
|
|
raise PathTraversalError(f"base is not a directory: {base}")
|
|
|
|
# Reject absolute parts up front (they would replace base semantics).
|
|
for p in parts:
|
|
if p is None:
|
|
continue
|
|
pp = PurePath(p)
|
|
if pp.is_absolute():
|
|
raise PathTraversalError(f"absolute part not allowed: {p!r}")
|
|
# Also reject drive/root markers on any platform.
|
|
if pp.drive or pp.root:
|
|
raise PathTraversalError(f"rooted part not allowed: {p!r}")
|
|
|
|
# Build candidate without resolving first, to detect traversal segments.
|
|
candidate = base_path
|
|
for p in parts:
|
|
if p is None or str(p) == "":
|
|
continue
|
|
# Normalize separators; reject NUL.
|
|
s = str(p)
|
|
if "\x00" in s:
|
|
raise PathTraversalError("NUL byte in part")
|
|
candidate = candidate.joinpath(s)
|
|
|
|
# Resolve; if a component doesn't exist, resolve still normalizes lexicaly
|
|
# for the non-existent tail, but existing symlinks are resolved.
|
|
try:
|
|
resolved = candidate.resolve(strict=False)
|
|
except (OSError, RuntimeError) as e:
|
|
raise PathTraversalError(f"cannot resolve path: {e}") from e
|
|
|
|
if not _is_under(resolved, base_path):
|
|
raise PathTraversalError(f"path escapes base: {resolved}")
|
|
|
|
# Extra check: ensure no intermediate '..' escaped even if final lands inside
|
|
# (e.g. symlink tricks). Walk lexical parts.
|
|
lexical = base_path
|
|
for p in parts:
|
|
if p is None or str(p) == "":
|
|
continue
|
|
lexical = lexical.joinpath(str(p))
|
|
# lexical may contain '..'; compute normalized
|
|
norm = os.path.normpath(str(lexical))
|
|
if not _is_under(Path(norm), base_path):
|
|
raise PathTraversalError(f"lexical path escapes base: {norm}")
|
|
|
|
return str(resolved) |