deer-flow/backend/tests/test_gateway_path_utils.py
Hyeonsang Cho 8e86729aa0
fix(gateway): confine artifact PUT to /mnt/user-data/outputs after path resolution (#5321)
* 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.
2026-09-10 15:01:56 +08:00

146 lines
6.0 KiB
Python

"""Tests for the shared outputs-confinement helpers in ``app.gateway.path_utils``.
The artifact editor (``PUT /artifacts``) and IM-channel attachment delivery
both must never touch anything outside ``/mnt/user-data/outputs``. The rule
used to be re-implemented per caller; these tests pin the single shared
implementation so the copies cannot drift again.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi import HTTPException
from app.gateway.path_utils import OUTPUTS_VIRTUAL_ROOT, normalize_outputs_virtual_path, resolve_outputs_confined_path
from deerflow.config.paths import Paths
THREAD_ID = "thread-1"
USER_ID = "user-1"
@pytest.fixture
def thread_dirs(tmp_path, monkeypatch) -> tuple[Path, Path]:
"""Point the global ``Paths`` at *tmp_path* and return (outputs, uploads)."""
paths = Paths(tmp_path)
monkeypatch.setattr("app.gateway.path_utils.get_paths", lambda: paths)
outputs = paths.sandbox_outputs_dir(THREAD_ID, user_id=USER_ID)
uploads = paths.sandbox_uploads_dir(THREAD_ID, user_id=USER_ID)
outputs.mkdir(parents=True)
uploads.mkdir(parents=True)
return outputs, uploads
def _symlink_or_skip(link: Path, target: Path) -> None:
try:
link.symlink_to(target)
except OSError:
pytest.skip("symlinks are unavailable on this platform")
class TestNormalizeOutputsVirtualPath:
def test_returns_canonical_absolute_virtual_path(self) -> None:
assert normalize_outputs_virtual_path("mnt/user-data/outputs/report.md") == "/mnt/user-data/outputs/report.md"
assert normalize_outputs_virtual_path("//mnt/user-data/outputs//a/./b.txt") == "/mnt/user-data/outputs/a/b.txt"
def test_collapses_dot_dot_that_stays_inside_outputs(self) -> None:
assert normalize_outputs_virtual_path("/mnt/user-data/outputs/nested/../note.txt") == "/mnt/user-data/outputs/note.txt"
@pytest.mark.parametrize(
"virtual_path",
[
"/mnt/user-data/uploads/secret.pdf",
"/mnt/user-data/workspace/config.py",
"/mnt/user-data/outputs/../uploads/secret.pdf",
"/mnt/user-data/outputsX/file.txt",
"/mnt/user-data/outputs",
"/mnt/user-data/outputs/",
"/invalid/path",
"",
],
)
def test_rejects_paths_outside_outputs(self, virtual_path: str) -> None:
with pytest.raises(HTTPException) as exc_info:
normalize_outputs_virtual_path(virtual_path)
assert exc_info.value.status_code == 400
def test_root_constant_matches_prefix(self) -> None:
assert OUTPUTS_VIRTUAL_ROOT == "/mnt/user-data/outputs"
class TestResolveOutputsConfinedPath:
def test_resolves_regular_file_under_outputs(self, thread_dirs) -> None:
outputs, _ = thread_dirs
target = outputs / "report.md"
target.write_text("hello", encoding="utf-8")
resolved = resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/report.md", user_id=USER_ID)
assert resolved == target.resolve()
def test_does_not_require_the_file_to_exist(self, thread_dirs) -> None:
# Existence is the caller's decision (the editor 404s, channels warn).
outputs, _ = thread_dirs
resolved = resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/missing.txt", user_id=USER_ID)
assert resolved == (outputs / "missing.txt").resolve()
def test_rejects_dot_dot_escape_lexically(self, thread_dirs) -> None:
_, uploads = thread_dirs
(uploads / "victim.txt").write_text("before", encoding="utf-8")
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/../uploads/victim.txt", user_id=USER_ID)
assert exc_info.value.status_code == 400
def test_rejects_symlink_pointing_at_sibling_directory(self, thread_dirs) -> None:
outputs, uploads = thread_dirs
victim = uploads / "victim.txt"
victim.write_text("before", encoding="utf-8")
_symlink_or_skip(outputs / "linked.txt", victim)
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/linked.txt", user_id=USER_ID)
assert exc_info.value.status_code == 400
def test_symlink_escaping_user_data_is_a_traversal(self, thread_dirs, tmp_path) -> None:
# Above ``user-data/`` the underlying resolver already refuses; that 403
# must not be downgraded by the outputs check layered on top.
outputs, _ = thread_dirs
outside = tmp_path / "outside.txt"
outside.write_text("outside", encoding="utf-8")
_symlink_or_skip(outputs / "linked.txt", outside)
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/linked.txt", user_id=USER_ID)
assert exc_info.value.status_code == 403
def test_symlinked_outputs_root_outside_user_data_is_a_traversal(self, thread_dirs, tmp_path) -> None:
# An outputs root re-pointed outside ``user-data/`` is refused by the
# underlying resolver (matching ``artifact_archive``), not accepted
# because both sides happen to resolve consistently.
outputs, _ = thread_dirs
real_outputs = tmp_path / "real-outputs"
real_outputs.mkdir()
(real_outputs / "report.md").write_text("hello", encoding="utf-8")
outputs.rmdir()
_symlink_or_skip(outputs, real_outputs)
with pytest.raises(HTTPException) as exc_info:
resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/report.md", user_id=USER_ID)
assert exc_info.value.status_code == 403
def test_defaults_to_effective_user(self, thread_dirs, monkeypatch) -> None:
outputs, _ = thread_dirs
monkeypatch.setattr("app.gateway.path_utils.get_effective_user_id", lambda: USER_ID)
resolved = resolve_outputs_confined_path(THREAD_ID, "/mnt/user-data/outputs/report.md")
assert resolved == (outputs / "report.md").resolve()