fix(security): fix file_tools.py validate_path startswith bypass #7478 #7801
@@ -11,7 +11,6 @@ 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
|
||||
@@ -481,7 +480,15 @@ class LLMExecuteActor:
|
||||
sandbox_root: str,
|
||||
llm_output: str,
|
||||
) -> None:
|
||||
"""Write generated file contents to the sandbox directory."""
|
||||
"""Write generated file contents to the sandbox directory.
|
||||
|
||||
Uses semantic path containment via os.path.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.
|
||||
|
||||
See issue #7478 — startswith bypass in path containment checks.
|
||||
"""
|
||||
|
||||
pattern = re.compile(
|
||||
r"FILE:\s*(.+?)\s*\n```[^\n]*\n(.*?)```",
|
||||
@@ -491,17 +498,14 @@ class LLMExecuteActor:
|
||||
for match in pattern.finditer(llm_output):
|
||||
path = match.group(1).strip()
|
||||
content = match.group(2)
|
||||
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:
|
||||
full_path = os.path.normpath(os.path.join(sandbox_root, path))
|
||||
rel = os.path.relpath(full_path, sandbox_root)
|
||||
# Path traversal guard: reject paths escaping sandbox
|
||||
if rel.startswith(".." + os.sep) or rel == "..":
|
||||
logger.warning(
|
||||
"Rejected path traversal in LLM output",
|
||||
path=path,
|
||||
resolved=str(full_path),
|
||||
resolved=full_path,
|
||||
)
|
||||
continue
|
||||
os.makedirs(os.path.dirname(full_path), exist_ok=True)
|
||||
|
||||
@@ -161,10 +161,22 @@ 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 equal to or a child of *root*.
|
||||
|
||||
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.
|
||||
|
||||
See issue #7478 — startswith bypass in path containment checks.
|
||||
"""
|
||||
if path == root:
|
||||
return True
|
||||
return path.startswith(root + "/")
|
||||
try:
|
||||
relative = posixpath.relpath(path, root)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return not relative.startswith(".." + posixpath.sep) and relative != ".."
|
||||
|
||||
|
||||
def _relative_to(path: str, root: str) -> str:
|
||||
|
||||
Reference in New Issue
Block a user