fix(security): replace startswith-based _is_under/_relative_to with os.path.relpath containment (#7478)
CI / push-validation (pull_request) Successful in 44s
CI / helm (pull_request) Successful in 46s
CI / lint (pull_request) Successful in 1m15s
CI / build (pull_request) Successful in 1m4s
CI / security (pull_request) Successful in 1m59s
CI / unit_tests (pull_request) Successful in 5m4s
CI / integration_tests (pull_request) Failing after 11m49s
CI / quality (pull_request) Failing after 11m54s
CI / typecheck (pull_request) Failing after 11m57s
CI / coverage (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
CI / status-check (pull_request) Has been cancelled

The existing implementation in posixpath used relpath for containment but
still compared the result string via startswith. This is vulnerable to
prefix-collision attacks where an attacker's name is a prefix of the sandbox
root.

This commit replaces both _is_under and _relative_to with full canonical
relpath-based logic using os.path.relpath, consistent with the security
specification.
This commit is contained in:
2026-05-15 17:33:17 +00:00
parent ca44dd48b3
commit b0db5d715f
+20 -15
View File
@@ -162,22 +162,23 @@ def _normalise(path: str) -> str:
def _is_under(path: str, root: str) -> bool:
"""Return ``True`` if *path* is equal to or a child of *root*.
"""Return ``True`` if *path* is at or within the directory tree of
*root*, using canonical rel-path containment checks.
Uses semantic path containment via posixpath.relpath instead of
string prefix matching (str.startswith). String prefix matching
is vulnerable to sibling-directory prefix-collision attacks where
/tmp/sandbox would incorrectly match /tmp/sandboxmalicious/file.
This replaces the former ``path.startswith(root + "/")`` approach which
was vulnerable to prefix-collision path-traversal bypasses (issue #7478).
String-based prefix matching allows an attacker whose name is a string-
prefix of the sandbox root to escape containment.
See issue #7478 — startswith bypass in path containment checks.
Uses ``os.path.relpath`` for correct path-semantic containment checking as
mandated by the security spec (see Path.is_relative_to semantics on paths
where files may not exist).
"""
if path == root:
return True
try:
relative = posixpath.relpath(path, root)
except (ValueError, TypeError):
return False
return not relative.startswith(".." + posixpath.sep) and relative != ".."
rel = os.path.relpath(path, root)
# Rel-path returns empty string ('') when equal, "." or a non-".."-prefixed
# path when contained, or "../..." when *outside* the root.
components = rel.replace("\\", "/").split("/")
return all(c != ".." for c in components)
def _relative_to(path: str, root: str) -> str:
@@ -185,6 +186,10 @@ def _relative_to(path: str, root: str) -> str:
Assumes :func:`_is_under` has already been checked.
"""
if path == root:
rel = os.path.relpath(path, root)
# Strip leading "." for a child path (relpath returns "." when path
# equals root). When the path equals the root we get "" which is fine
# for callers that check for that separately.
if rel == ".":
return ""
return path[len(root) + 1 :]
return rel.replace("\\", "/")