fix(security): fix file_tools.py validate_path startswith bypass #7478 #11214

Merged
HAL9000 merged 1 commits from fix/m1-security-fix-startswith-bypass into master 2026-05-14 23:37:33 +00:00
3 changed files with 29 additions and 10 deletions
@@ -11,6 +11,7 @@ from __future__ import annotations
import os
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
import structlog
@@ -490,13 +491,17 @@ class LLMExecuteActor:
for match in pattern.finditer(llm_output):
path = match.group(1).strip()
content = match.group(2)
full_path = os.path.normpath(os.path.join(sandbox_root, path))
# Path traversal guard: reject paths escaping sandbox
if not full_path.startswith(sandbox_root + os.sep):
full_path = Path(os.path.normpath(os.path.join(sandbox_root, path)))
# Path traversal guard: use relative_to (not string prefix) to
# avoid the *prefix-collision bypass* on symlink resolution.
sandbox_root_resolved = Path(sandbox_root).resolve()
try:
full_path.relative_to(sandbox_root_resolved)
except ValueError:
logger.warning(
"Rejected path traversal in LLM output",
path=path,
resolved=full_path,
resolved=str(full_path),
)
continue
os.makedirs(os.path.dirname(full_path), exist_ok=True)
+10 -4
View File
@@ -179,13 +179,19 @@ class BaseResourceHandler:
Raises:
PermissionError: If *path* escapes the root via ``..`` or
symlink resolution.
Uses :meth:`Path.relative_to` (not string prefix matching) to
avoid the *prefix-collision bypass* exemplified by
``startswith(root + os.sep)`` on symlinks and resolved paths.
"""
root = Path(location).resolve()
target = (root / path).resolve()
# Use root + os.sep to prevent prefix collision bypass:
# e.g. root=/tmp/foo must not match target=/tmp/foobar/secret
if target != root and not str(target).startswith(str(root) + os.sep):
raise PermissionError(f"Path '{path}' escapes resource root '{location}'")
try:
target.relative_to(root)
except ValueError as exc:
raise PermissionError(
f"Path '{path}' escapes resource root '{location}'"
) from exc
return target
def read(self, *, resource: Resource, path: str = "") -> Content:
+10 -2
View File
@@ -69,6 +69,10 @@ def validate_sandbox_path(path_str: str, sandbox_root: str | None = None) -> Pat
Raises ``ValueError`` with the rejected path when traversal is
detected or the resolved path falls outside the sandbox root.
Uses :meth:`Path.relative_to` (not string prefix matching) to avoid
the *prefix-collision bypass*: a target like ``/tmp/abc123-escape``
would incorrectly pass ``startswith("/tmp/abc123")``.
"""
if not path_str:
raise ValueError("Path must not be empty")
@@ -77,8 +81,12 @@ def validate_sandbox_path(path_str: str, sandbox_root: str | None = None) -> Pat
root = root.resolve()
target = (root / path_str).resolve()
if not str(target).startswith(str(root)):
raise ValueError(f"Path traversal detected: '{path_str}' escapes sandbox root")
try:
target.relative_to(root)
except ValueError as exc:
raise ValueError(
f"Path traversal detected: '{path_str}' escapes sandbox root"
) from exc
return target