fix(security): make research path lookup CodeQL-friendly (#5129)

* fix(security): make research path lookup CodeQL-friendly

* fix(security): avoid duplicate research path scans

* fix(research): preserve active completed spinoff query
This commit is contained in:
Alexandre Teixeira
2026-07-04 19:17:45 +01:00
committed by GitHub
parent 897e6950af
commit e3750fcdcb
2 changed files with 397 additions and 71 deletions
+318 -26
View File
@@ -1,6 +1,6 @@
"""Path-confinement regression tests for research routes.
Covers the CodeQL py/path-injection alert cluster (#552-#567) in
Covers the CodeQL py/path-injection alert cluster (#552-#567 and #594) in
routes/research/research_routes.py:
- _owns_in_memory disk fallback (alerts #552, #553)
- _assert_owns_research (alerts #554, #555)
@@ -20,7 +20,11 @@ import pytest
from fastapi import HTTPException
from routes.research_routes import setup_research_routes
from routes.research.research_routes import _confine_research_path
from routes.research.research_routes import (
_find_owned_research_path,
_find_research_path,
_require_research_path,
)
@pytest.fixture(autouse=True)
@@ -58,15 +62,30 @@ def _research_handler():
# ---------------------------------------------------------------------------
# Helper-level tests — _confine_research_path
# Helper-level tests
# ---------------------------------------------------------------------------
def test_confine_allows_valid_session_id(tmp_path, monkeypatch):
def test_find_returns_existing_trusted_research_path(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
expected = _write_research(data_dir, "rp-abc123de4567", owner="alice")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_research_path("rp-abc123de4567") == expected.resolve()
def test_find_returns_none_for_missing_valid_session_id(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir()
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
path = _confine_research_path("rp-abc123de4567")
assert path == (data_dir / "rp-abc123de4567.json").resolve()
assert _find_research_path("rp-missing12345") is None
def test_require_returns_404_for_missing_valid_session_id(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
data_dir.mkdir()
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
with pytest.raises(HTTPException) as exc:
_require_research_path("rp-missing12345")
assert exc.value.status_code == 404
@pytest.mark.parametrize("bad_id", [
@@ -79,17 +98,45 @@ def test_confine_allows_valid_session_id(tmp_path, monkeypatch):
"rp-bad.json", # dot not in allowed charset
"a" * 129, # exceeds length limit
])
def test_confine_rejects_bad_session_ids(tmp_path, monkeypatch, bad_id):
data_dir = tmp_path / "deep_research"
data_dir.mkdir()
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
def test_find_rejects_bad_session_ids_before_enumeration(monkeypatch, bad_id):
storage_root = MagicMock()
monkeypatch.setattr(
"routes.research.research_routes._research_storage_root",
MagicMock(return_value=storage_root),
)
with pytest.raises(HTTPException) as exc:
_confine_research_path(bad_id)
_find_research_path(bad_id)
assert exc.value.status_code == 400
storage_root.glob.assert_not_called()
def test_confine_rejects_symlink_escape(tmp_path, monkeypatch):
"""A symlink inside DEEP_RESEARCH_DIR that resolves outside is rejected."""
def test_find_matches_names_from_trusted_enumeration_without_joining_input(
tmp_path, monkeypatch
):
"""Pin the CodeQL-friendly lookup: match a glob result, never root / input."""
data_dir = tmp_path / "deep_research"
expected = _write_research(data_dir, "rp-abc123de4567", owner="alice").resolve()
class EnumeratedRoot:
def glob(self, pattern):
assert pattern == "*.json"
return [expected]
def __fspath__(self):
return str(data_dir.resolve())
def __truediv__(self, _other):
raise AssertionError("user-derived path segment was joined to root")
monkeypatch.setattr(
"routes.research.research_routes._research_storage_root",
lambda: EnumeratedRoot(),
)
assert _find_research_path("rp-abc123de4567") == expected
def test_find_ignores_symlink_escape(tmp_path, monkeypatch):
"""A matching symlink that resolves outside is not a trusted file."""
data_dir = tmp_path / "deep_research"
outside = tmp_path / "outside"
data_dir.mkdir()
@@ -102,9 +149,24 @@ def test_confine_rejects_symlink_escape(tmp_path, monkeypatch):
link.symlink_to(target)
except (AttributeError, NotImplementedError, OSError) as e:
pytest.skip(f"symlinks unavailable: {e}")
with pytest.raises(HTTPException) as exc:
_confine_research_path("rp-linktest1234")
assert exc.value.status_code == 400
assert _find_research_path("rp-linktest1234") is None
def test_find_owned_returns_path_for_matching_owner(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
expected = _write_research(data_dir, "rp-ownedalice1", owner="alice")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_owned_research_path("rp-ownedalice1", "alice") == expected.resolve()
def test_find_owned_returns_none_for_other_owner(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
_write_research(data_dir, "rp-ownedbybob12", owner="bob")
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
assert _find_owned_research_path("rp-ownedbybob12", "alice") is None
# ---------------------------------------------------------------------------
@@ -120,6 +182,24 @@ def test_detail_returns_data_for_owner(tmp_path):
assert out["query"] == "valid query"
def test_detail_returns_404_for_missing_valid_id():
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="rp-missing12345", request=_request("alice")))
assert exc.value.status_code == 404
def test_detail_hides_other_owners_research_with_404(tmp_path):
data_dir = tmp_path / "deep_research"
_write_research(data_dir, "rp-ownedbybob12", owner="bob")
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="rp-ownedbybob12", request=_request("alice")))
assert exc.value.status_code == 404
# ---------------------------------------------------------------------------
# Route-level tests — traversal and injection rejected
# ---------------------------------------------------------------------------
@@ -214,28 +294,76 @@ def test_detail_traversal_does_not_read_outside_file(tmp_path, monkeypatch):
# Route-level symlink escape test
# ---------------------------------------------------------------------------
def test_detail_rejects_symlink_escape(tmp_path, monkeypatch):
"""research_detail rejects a confined-format ID whose JSON is a symlink to outside."""
def _write_outside_symlink(tmp_path, session_id: str, data: dict):
data_dir = tmp_path / "deep_research"
outside_dir = tmp_path / "outside"
data_dir.mkdir(parents=True)
outside_dir.mkdir()
outside_file = outside_dir / "rp-linktest5678.json"
outside_file.write_text(
json.dumps({"owner": "alice", "result": "secret"}), encoding="utf-8"
)
link = data_dir / "rp-linktest5678.json"
outside_file = outside_dir / f"{session_id}.json"
outside_file.write_text(json.dumps(data), encoding="utf-8")
link = data_dir / f"{session_id}.json"
try:
link.symlink_to(outside_file)
except (AttributeError, NotImplementedError, OSError) as e:
pytest.skip(f"symlinks unavailable: {e}")
return data_dir, outside_file
def test_detail_rejects_symlink_escape(tmp_path, monkeypatch):
"""research_detail never reads a matching symlink outside the root."""
data_dir, _ = _write_outside_symlink(
tmp_path,
"rp-linktest5678",
{"owner": "alice", "result": "secret"},
)
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/detail/{session_id}", "GET")
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id="rp-linktest5678", request=_request("alice")))
assert exc.value.status_code == 400
assert exc.value.status_code == 404
def test_archive_does_not_write_through_symlink_escape(tmp_path, monkeypatch):
data_dir, outside_file = _write_outside_symlink(
tmp_path,
"rp-linkarchive1",
{"owner": "alice", "archived": False},
)
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}/archive", "POST")
with pytest.raises(HTTPException) as exc:
asyncio.run(
target(
session_id="rp-linkarchive1",
request=_request("alice"),
archived=True,
)
)
assert exc.value.status_code == 404
assert json.loads(outside_file.read_text(encoding="utf-8"))["archived"] is False
def test_delete_does_not_unlink_symlink_escape(tmp_path, monkeypatch):
data_dir, outside_file = _write_outside_symlink(
tmp_path,
"rp-linkdelete12",
{"owner": "alice"},
)
link = data_dir / "rp-linkdelete12.json"
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
router = setup_research_routes(_research_handler())
target = _route(router, "/api/research/{session_id}", "DELETE")
out = asyncio.run(
target(session_id="rp-linkdelete12", request=_request("alice"))
)
assert out == {"deleted": False}
assert link.is_symlink()
assert outside_file.exists()
# ---------------------------------------------------------------------------
@@ -243,14 +371,15 @@ def test_detail_rejects_symlink_escape(tmp_path, monkeypatch):
# ---------------------------------------------------------------------------
def test_owner_scoped_paths_stay_within_research_root(tmp_path, monkeypatch):
"""Owner-scoped session IDs never produce paths outside DEEP_RESEARCH_DIR."""
"""Owner-scoped persisted files resolve within DEEP_RESEARCH_DIR."""
data_dir = tmp_path / "deep_research"
data_dir.mkdir(parents=True)
monkeypatch.setattr("routes.research_routes.DEEP_RESEARCH_DIR", str(data_dir))
root = data_dir.resolve()
for session_id in ("rp-abc123456789", "rp-000000000001", "abc-xyz-123"):
path = _confine_research_path(session_id)
_write_research(data_dir, session_id, owner="alice")
path = _require_research_path(session_id)
assert path.resolve().is_relative_to(root), (
f"{session_id!r} produced path outside research root: {path}"
)
@@ -271,3 +400,166 @@ def test_spinoff_rejects_traversal(bad_id):
with pytest.raises(HTTPException) as exc:
asyncio.run(target(session_id=bad_id, request=_request("alice")))
assert exc.value.status_code == 400
def test_result_peek_uses_single_disk_lookup_for_completed_result(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
path = _write_research(
data_dir,
"rp-peeksingle1",
owner="alice",
result="saved result",
sources=["s1"],
raw_findings=["f1"],
category="security",
).resolve()
calls = []
def fake_find_owned(session_id, user):
calls.append((session_id, user))
return path
monkeypatch.setattr(
"routes.research.research_routes._find_owned_research_path",
fake_find_owned,
)
handler = _research_handler()
handler.get_result.return_value = None
router = setup_research_routes(handler)
target = _route(router, "/api/research/result-peek/{session_id}", "POST")
out = asyncio.run(target(session_id="rp-peeksingle1", request=_request("alice")))
assert out["result"] == "saved result"
assert out["sources"] == ["s1"]
assert out["raw_findings"] == ["f1"]
assert out["category"] == "security"
assert calls == [("rp-peeksingle1", "alice")]
def test_spinoff_uses_single_disk_lookup_for_completed_result(tmp_path, monkeypatch):
data_dir = tmp_path / "deep_research"
path = _write_research(
data_dir,
"rp-spinsingle1",
owner="alice",
result="saved report",
sources=["s1", "s2"],
query="original query",
).resolve()
calls = []
def fake_find_owned(session_id, user):
calls.append((session_id, user))
return path
class FakeSession:
endpoint_url = ""
model = ""
headers = {}
def __init__(self):
self.messages = []
def add_message(self, message):
self.messages.append(message)
class FakeSessionManager:
def __init__(self):
self.created = None
def get_session(self, session_id):
raise KeyError(session_id)
def create_session(self, **kwargs):
self.created = FakeSession()
return self.created
def save_sessions(self):
pass
monkeypatch.setattr(
"routes.research.research_routes._find_owned_research_path",
fake_find_owned,
)
monkeypatch.setattr(
"routes.research.research_routes.resolve_endpoint",
lambda *_args, **_kwargs: ("http://endpoint/v1", "model", {}),
)
handler = _research_handler()
handler.get_result.return_value = None
handler.get_sources.return_value = []
session_manager = FakeSessionManager()
router = setup_research_routes(handler, session_manager=session_manager)
target = _route(router, "/api/research/spinoff/{session_id}", "POST")
out = asyncio.run(target(session_id="rp-spinsingle1", request=_request("alice")))
assert out["name"] == "Follow-up: original query"
assert out["source_count"] == 2
assert calls == [("rp-spinsingle1", "alice")]
assert session_manager.created is not None
assert session_manager.created.messages
def test_spinoff_reads_saved_query_for_done_active_task(tmp_path, monkeypatch):
session_id = "rp-activedone1"
data_dir = tmp_path / "deep_research"
_write_research(
data_dir,
session_id,
owner="alice",
result="saved report",
sources=["s1"],
query="completed query",
)
class FakeSession:
endpoint_url = ""
model = ""
headers = {}
def __init__(self):
self.messages = []
def add_message(self, message):
self.messages.append(message)
class FakeSessionManager:
def __init__(self):
self.created = None
def get_session(self, session_id):
raise KeyError(session_id)
def create_session(self, **kwargs):
self.created = FakeSession()
return self.created
def save_sessions(self):
pass
monkeypatch.setattr(
"routes.research.research_routes.resolve_endpoint",
lambda *_args, **_kwargs: ("http://endpoint/v1", "model", {}),
)
handler = _research_handler()
handler._active_tasks[session_id] = {"owner": "alice", "status": "done"}
handler.get_result.return_value = None
handler.get_sources.return_value = []
session_manager = FakeSessionManager()
router = setup_research_routes(handler, session_manager=session_manager)
target = _route(router, "/api/research/spinoff/{session_id}", "POST")
out = asyncio.run(target(session_id=session_id, request=_request("alice")))
assert out["name"] == "Follow-up: completed query"
assert out["source_count"] == 1
assert session_manager.created is not None
primer = session_manager.created.messages[0].content
assert "completed query" in primer
assert "(not recorded)" not in primer