diff --git a/strix/viewer/server.py b/strix/viewer/server.py index 535bc002..9ef2927b 100644 --- a/strix/viewer/server.py +++ b/strix/viewer/server.py @@ -222,10 +222,14 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]: self.end_headers() def _handle_api(self, path: str, query: dict[str, list[str]]) -> None: - # The launched run is always viewable with no verification. Only the - # cross-run history list (/api/runs) is gated. + # The launched run is always viewable with no verification. The + # cross-run history list (/api/runs) unlocks its entries only for a + # caller that holds this process's session capability *and* is email + # verified, so merely reaching an exposed --host port never leaks the + # run list (the payload still advertises the count as a teaser). if path == "/api/runs": - payload = build_runs_payload(state.base_dir, verified=auth.is_verified()) + unlocked = self._has_session() and auth.is_verified() + payload = build_runs_payload(state.base_dir, verified=unlocked) self._send_json(HTTPStatus.OK, payload) return if path == "/api/capabilities": diff --git a/tests/test_viewer.py b/tests/test_viewer.py index 8510fd7c..6a8409a2 100644 --- a/tests/test_viewer.py +++ b/tests/test_viewer.py @@ -405,6 +405,39 @@ def test_historical_run_data_requires_verification( httpd.server_close() +def test_runs_list_requires_session_and_verification( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + launched = _make_run(tmp_path, "launched", status="completed", end_time="2026-01-01T00:00:00Z") + _make_run(tmp_path, "other", status="completed", end_time="2026-01-01T00:00:00Z") + _bundle(tmp_path, monkeypatch) + + monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: True) + + def _runs(cookie: str | None) -> dict[str, object]: + headers = {"Cookie": cookie} if cookie else {} + req = urllib.request.Request(f"{url}/api/runs", headers=headers) # noqa: S310 + with urllib.request.urlopen(req) as resp: # noqa: S310 - localhost test server + return dict(json.loads(resp.read())) + + httpd, url, token = serve(launched, open_browser=False) + try: + # A cookie-less caller (even with the machine verified) only sees the + # teaser count, never the run entries. + payload = _runs(None) + assert payload["locked"] is True + assert payload["count"] == 2 + assert payload["runs"] == [] + + # With the session cookie and verification, the entries unlock. + payload = _runs(_session_cookie(url, token)) + assert payload["locked"] is False + assert {r["name"] for r in payload["runs"]} == {"launched", "other"} # type: ignore[attr-defined] + finally: + httpd.shutdown() + httpd.server_close() + + def test_server_rejects_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: run_dir = _make_run(tmp_path, "guard", status="completed", end_time="2026-01-01T00:00:00Z") secret = tmp_path / "secret.txt"