mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-21 07:26:50 +00:00
fix(sandbox): prevent local custom mount symlink escapes (#2558)
* fix(sandbox): prevent local custom mount symlink escapes Fixes #2506 * fix(sandbox): harden custom mount symlink handling * fix(sandbox): format internal symlink directory listings
This commit is contained in:
@@ -22,6 +22,13 @@ def list_dir(path: str, max_depth: int = 2) -> list[str]:
|
||||
if not root_path.is_dir():
|
||||
return result
|
||||
|
||||
def _is_within_root(candidate: Path) -> bool:
|
||||
try:
|
||||
candidate.relative_to(root_path)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _traverse(current_path: Path, current_depth: int) -> None:
|
||||
"""Recursively traverse directories up to max_depth."""
|
||||
if current_depth > max_depth:
|
||||
@@ -32,8 +39,23 @@ def list_dir(path: str, max_depth: int = 2) -> list[str]:
|
||||
if should_ignore_name(item.name):
|
||||
continue
|
||||
|
||||
if item.is_symlink():
|
||||
try:
|
||||
item_resolved = item.resolve()
|
||||
if not _is_within_root(item_resolved):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
post_fix = "/" if item_resolved.is_dir() else ""
|
||||
result.append(str(item_resolved) + post_fix)
|
||||
continue
|
||||
|
||||
item_resolved = item.resolve()
|
||||
if not _is_within_root(item_resolved):
|
||||
continue
|
||||
|
||||
post_fix = "/" if item.is_dir() else ""
|
||||
result.append(str(item.resolve()) + post_fix)
|
||||
result.append(str(item_resolved) + post_fix)
|
||||
|
||||
# Recurse into subdirectories if not at max depth
|
||||
if item.is_dir() and current_depth < max_depth:
|
||||
|
||||
@@ -5,6 +5,7 @@ import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from deerflow.sandbox.local.list_dir import list_dir
|
||||
from deerflow.sandbox.sandbox import Sandbox
|
||||
@@ -20,6 +21,11 @@ class PathMapping:
|
||||
read_only: bool = False
|
||||
|
||||
|
||||
class ResolvedPath(NamedTuple):
|
||||
path: str
|
||||
mapping: PathMapping | None
|
||||
|
||||
|
||||
class LocalSandbox(Sandbox):
|
||||
@staticmethod
|
||||
def _shell_name(shell: str) -> str:
|
||||
@@ -91,7 +97,23 @@ class LocalSandbox(Sandbox):
|
||||
|
||||
return best_mapping.read_only
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
def _find_path_mapping(self, path: str) -> tuple[PathMapping, str] | None:
|
||||
path_str = str(path)
|
||||
|
||||
for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path.rstrip("/") or "/"), reverse=True):
|
||||
container_path = mapping.container_path.rstrip("/") or "/"
|
||||
if container_path == "/":
|
||||
if path_str.startswith("/"):
|
||||
return mapping, path_str.lstrip("/")
|
||||
continue
|
||||
|
||||
if path_str == container_path or path_str.startswith(container_path + "/"):
|
||||
relative = path_str[len(container_path) :].lstrip("/")
|
||||
return mapping, relative
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_path_with_mapping(self, path: str) -> ResolvedPath:
|
||||
"""
|
||||
Resolve container path to actual local path using mappings.
|
||||
|
||||
@@ -99,22 +121,30 @@ class LocalSandbox(Sandbox):
|
||||
path: Path that might be a container path
|
||||
|
||||
Returns:
|
||||
Resolved local path
|
||||
Resolved local path and the matched mapping, if any
|
||||
"""
|
||||
path_str = str(path)
|
||||
|
||||
# Try each mapping (longest prefix first for more specific matches)
|
||||
for mapping in sorted(self.path_mappings, key=lambda m: len(m.container_path), reverse=True):
|
||||
container_path = mapping.container_path
|
||||
local_path = mapping.local_path
|
||||
if path_str == container_path or path_str.startswith(container_path + "/"):
|
||||
# Replace the container path prefix with local path
|
||||
relative = path_str[len(container_path) :].lstrip("/")
|
||||
resolved = str(Path(local_path) / relative) if relative else local_path
|
||||
return resolved
|
||||
mapping_match = self._find_path_mapping(path_str)
|
||||
if mapping_match is None:
|
||||
return ResolvedPath(path_str, None)
|
||||
|
||||
# No mapping found, return original path
|
||||
return path_str
|
||||
mapping, relative = mapping_match
|
||||
local_root = Path(mapping.local_path).resolve()
|
||||
resolved_path = (local_root / relative).resolve() if relative else local_root
|
||||
|
||||
try:
|
||||
resolved_path.relative_to(local_root)
|
||||
except ValueError as exc:
|
||||
raise PermissionError(errno.EACCES, "Access denied: path escapes mounted directory", path_str) from exc
|
||||
|
||||
return ResolvedPath(str(resolved_path), mapping)
|
||||
|
||||
def _resolve_path(self, path: str) -> str:
|
||||
return self._resolve_path_with_mapping(path).path
|
||||
|
||||
def _is_resolved_path_read_only(self, resolved: ResolvedPath) -> bool:
|
||||
return bool(resolved.mapping and resolved.mapping.read_only) or self._is_read_only_path(resolved.path)
|
||||
|
||||
def _reverse_resolve_path(self, path: str) -> str:
|
||||
"""
|
||||
@@ -309,8 +339,14 @@ class LocalSandbox(Sandbox):
|
||||
def list_dir(self, path: str, max_depth=2) -> list[str]:
|
||||
resolved_path = self._resolve_path(path)
|
||||
entries = list_dir(resolved_path, max_depth)
|
||||
# Reverse resolve local paths back to container paths in output
|
||||
return [self._reverse_resolve_paths_in_output(entry) for entry in entries]
|
||||
# Reverse resolve local paths back to container paths and preserve
|
||||
# list_dir's trailing "/" marker for directories.
|
||||
result: list[str] = []
|
||||
for entry in entries:
|
||||
is_dir = entry.endswith(("/", "\\"))
|
||||
reversed_entry = self._reverse_resolve_path(entry.rstrip("/\\")) if is_dir else self._reverse_resolve_path(entry)
|
||||
result.append(f"{reversed_entry}/" if is_dir and not reversed_entry.endswith("/") else reversed_entry)
|
||||
return result
|
||||
|
||||
def read_file(self, path: str) -> str:
|
||||
resolved_path = self._resolve_path(path)
|
||||
@@ -329,8 +365,9 @@ class LocalSandbox(Sandbox):
|
||||
raise type(e)(e.errno, e.strerror, path) from None
|
||||
|
||||
def write_file(self, path: str, content: str, append: bool = False) -> None:
|
||||
resolved_path = self._resolve_path(path)
|
||||
if self._is_read_only_path(resolved_path):
|
||||
resolved = self._resolve_path_with_mapping(path)
|
||||
resolved_path = resolved.path
|
||||
if self._is_resolved_path_read_only(resolved):
|
||||
raise OSError(errno.EROFS, "Read-only file system", path)
|
||||
try:
|
||||
dir_path = os.path.dirname(resolved_path)
|
||||
@@ -384,8 +421,9 @@ class LocalSandbox(Sandbox):
|
||||
], truncated
|
||||
|
||||
def update_file(self, path: str, content: bytes) -> None:
|
||||
resolved_path = self._resolve_path(path)
|
||||
if self._is_read_only_path(resolved_path):
|
||||
resolved = self._resolve_path_with_mapping(path)
|
||||
resolved_path = resolved.path
|
||||
if self._is_resolved_path_read_only(resolved):
|
||||
raise OSError(errno.EROFS, "Read-only file system", path)
|
||||
try:
|
||||
dir_path = os.path.dirname(resolved_path)
|
||||
|
||||
Reference in New Issue
Block a user