mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 08:00:10 +00:00
* fix(gateway): confine artifact PUT to /mnt/user-data/outputs after path resolution
The outputs-only guard on PUT /api/threads/{id}/artifacts/{path} was a
string-prefix check on the raw path. A percent-encoded `..`
(`outputs/%2e%2e/uploads/x.txt`) survives nginx's variable proxy_pass
untouched, is decoded by Starlette, passes the prefix check, and the
resolver only confines the result to `user-data/` -- so an owner could
overwrite a sibling upload or workspace file in their own thread.
Collapse dot segments before the prefix check, and re-check the resolved
host path against the resolved outputs root so a symlink planted inside
`outputs/` cannot redirect the write either. The normalized virtual path
is what the response echoes and what non-mounted sandboxes receive.
* refactor(gateway): share the outputs-confinement rule with channel attachments
Review follow-up on #5321: the "only under /mnt/user-data/outputs" rule was
implemented independently by the artifact editor and by IM-channel
attachment delivery, and the two copies had already drifted.
Move it into app/gateway/path_utils.py as normalize_outputs_virtual_path
(collapse `..` before the prefix check) and resolve_outputs_confined_path
(re-check the resolved host path against the resolved outputs root, which
also catches a symlink planted inside outputs/). PUT /artifacts and
ChannelManager._resolve_attachments both call the helper; artifact_archive
keeps its stricter ZIP-member rules layered on top.
Tests that previously stubbed resolve_thread_virtual_path for the editor now
stub resolve_outputs_confined_path, and the channel attachment tests patch
path_utils.get_paths, which the helper binds at import like the other
consumers. The confinement itself is pinned by tests/test_gateway_path_utils.py.
81 lines
3.6 KiB
Python
81 lines
3.6 KiB
Python
"""Shared path resolution for thread virtual paths (e.g. mnt/user-data/outputs/...)."""
|
|
|
|
import posixpath
|
|
from pathlib import Path
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
|
|
from deerflow.runtime.user_context import get_effective_user_id
|
|
|
|
OUTPUTS_VIRTUAL_ROOT = f"{VIRTUAL_PATH_PREFIX}/outputs"
|
|
_OUTPUTS_PREFIX = OUTPUTS_VIRTUAL_ROOT.lstrip("/") + "/"
|
|
_OUTPUTS_ONLY_DETAIL = f"Only files under {OUTPUTS_VIRTUAL_ROOT} are allowed"
|
|
|
|
|
|
def resolve_thread_virtual_path(thread_id: str, virtual_path: str, user_id: str | None = None) -> Path:
|
|
"""Resolve a virtual path to the actual filesystem path under thread user-data.
|
|
|
|
Args:
|
|
thread_id: The thread ID.
|
|
virtual_path: The virtual path as seen inside the sandbox
|
|
(e.g., /mnt/user-data/outputs/file.txt).
|
|
user_id: The user whose storage to resolve under. Defaults to the
|
|
effective user when not given; callers acting on behalf of a
|
|
specific owner (e.g. trusted internal callers) pass it explicitly.
|
|
|
|
Returns:
|
|
The resolved filesystem path.
|
|
|
|
Raises:
|
|
HTTPException: If the path is invalid or outside allowed directories.
|
|
"""
|
|
try:
|
|
return get_paths().resolve_virtual_path(thread_id, virtual_path, user_id=user_id or get_effective_user_id())
|
|
except ValueError as e:
|
|
status = 403 if "traversal" in str(e) else 400
|
|
raise HTTPException(status_code=status, detail=str(e))
|
|
|
|
|
|
def normalize_outputs_virtual_path(virtual_path: str) -> str:
|
|
"""Return *virtual_path* as a canonical ``/mnt/user-data/outputs/...`` path.
|
|
|
|
``.``/``..`` segments and duplicate slashes are collapsed *before* the
|
|
prefix check, so ``outputs/../uploads/x`` (or its percent-encoded form,
|
|
which nginx forwards untouched and Starlette decodes) is rejected as a
|
|
non-outputs path instead of slipping past a raw string-prefix test. The
|
|
outputs directory itself is not a file and is rejected too.
|
|
|
|
Raises:
|
|
HTTPException: 400 when the path is not strictly inside outputs.
|
|
"""
|
|
stripped = posixpath.normpath(virtual_path.lstrip("/")).lstrip("/")
|
|
if not stripped.startswith(_OUTPUTS_PREFIX):
|
|
raise HTTPException(status_code=400, detail=_OUTPUTS_ONLY_DETAIL)
|
|
return f"/{stripped}"
|
|
|
|
|
|
def resolve_outputs_confined_path(thread_id: str, virtual_path: str, user_id: str | None = None) -> Path:
|
|
"""Resolve *virtual_path* and guarantee it lives inside the thread's outputs dir.
|
|
|
|
``resolve_thread_virtual_path`` only confines to ``user-data/``. Callers
|
|
that must never touch uploads, workspace, or tool results (the artifact
|
|
editor, IM-channel attachment delivery) go through this helper so the
|
|
outputs rule lives in one place: the path is normalized lexically first,
|
|
then the resolved host path is checked against the resolved outputs root,
|
|
which also catches a symlink planted inside ``outputs/``.
|
|
|
|
Existence is not checked; callers decide how a missing file surfaces.
|
|
|
|
Raises:
|
|
HTTPException: 400 when the path is not strictly inside outputs; 403
|
|
when the underlying resolver detects traversal above ``user-data/``.
|
|
"""
|
|
normalized = normalize_outputs_virtual_path(virtual_path)
|
|
resolved_user_id = user_id or get_effective_user_id()
|
|
actual_path = resolve_thread_virtual_path(thread_id, normalized, user_id=resolved_user_id)
|
|
outputs_root = get_paths().sandbox_outputs_dir(thread_id, user_id=resolved_user_id).resolve()
|
|
if actual_path == outputs_root or not actual_path.is_relative_to(outputs_root):
|
|
raise HTTPException(status_code=400, detail=_OUTPUTS_ONLY_DETAIL)
|
|
return actual_path
|