mirror of
https://github.com/usestrix/strix.git
synced 2026-08-25 20:32:38 +02:00
Harden viewer authz: session capability, history gating, expiry
This commit is contained in:
+146
-4
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -18,6 +19,7 @@ from strix.viewer.transcript import (
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -89,9 +91,7 @@ def _get(url: str) -> tuple[int, str, bytes]:
|
||||
return resp.status, resp.headers.get("Content-Type", ""), resp.read()
|
||||
|
||||
|
||||
def test_server_serves_api_and_static(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "served", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
|
||||
assets = tmp_path / "bundle"
|
||||
@@ -189,9 +189,151 @@ def test_server_event_endpoint_forwards_email_funnel(
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_server_rejects_path_traversal(
|
||||
def _post(
|
||||
url: str, path: str, payload: Mapping[str, object], *, cookie: str | None = None
|
||||
) -> tuple[int, bytes]:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
req = urllib.request.Request( # noqa: S310 - localhost test server
|
||||
url + path, data=json.dumps(payload).encode(), headers=headers, method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp: # noqa: S310
|
||||
return resp.status, resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
return exc.code, exc.read()
|
||||
|
||||
|
||||
def _session_cookie(url: str) -> str:
|
||||
"""Fetch index.html and return its ``name=value`` session cookie."""
|
||||
with urllib.request.urlopen(url + "/") as resp: # noqa: S310 - localhost test server
|
||||
raw = str(resp.headers.get("Set-Cookie", ""))
|
||||
return raw.split(";", 1)[0]
|
||||
|
||||
|
||||
def _get_status(url: str) -> int:
|
||||
try:
|
||||
with urllib.request.urlopen(url) as resp: # noqa: S310 - localhost test server
|
||||
return int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
return int(exc.code)
|
||||
|
||||
|
||||
def _bundle(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assets = tmp_path / "bundle"
|
||||
assets.mkdir()
|
||||
(assets / "index.html").write_text("<!doctype html><div id=root></div>", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
|
||||
def test_index_sets_session_cookie_but_assets_do_not(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "cookie", status="running", end_time=None)
|
||||
assets = tmp_path / "bundle"
|
||||
(assets / "assets").mkdir(parents=True)
|
||||
(assets / "index.html").write_text("<!doctype html>index", encoding="utf-8")
|
||||
(assets / "assets" / "app.js").write_text("1", encoding="utf-8")
|
||||
monkeypatch.setattr("strix.viewer.server.bundle_dir", lambda: assets)
|
||||
|
||||
httpd, url = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
with urllib.request.urlopen(url + "/") as resp: # noqa: S310
|
||||
cookie = resp.headers.get("Set-Cookie", "")
|
||||
assert "strix_viewer_session=" in cookie
|
||||
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
|
||||
|
||||
with urllib.request.urlopen(url + "/assets/app.js") as resp: # noqa: S310
|
||||
assert resp.headers.get("Set-Cookie") is None
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_steer_requires_session_cookie(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
run_dir = _make_run(tmp_path, "steer", status="running", end_time=None)
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
delivered: list[tuple[str, str]] = []
|
||||
|
||||
def handler(agent_id: str, message: str) -> bool:
|
||||
delivered.append((agent_id, message))
|
||||
return True
|
||||
|
||||
httpd, url = serve(run_dir, open_browser=False, steer_handler=handler)
|
||||
try:
|
||||
body = {"agent_id": "root", "message": "focus on auth"}
|
||||
# No cookie: rejected before reaching the live coordinator.
|
||||
status, _ = _post(url, "/api/agents/steer", body)
|
||||
assert status == 403
|
||||
assert delivered == []
|
||||
|
||||
# With the session cookie the message is delivered.
|
||||
status, raw = _post(url, "/api/agents/steer", body, cookie=_session_cookie(url))
|
||||
assert status == 200
|
||||
assert json.loads(raw)["ok"] is True
|
||||
assert delivered == [("root", "focus on auth")]
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_report_send_requires_session_cookie(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
run_dir = _make_run(tmp_path, "report", status="completed", end_time="2026-01-01T00:00:00Z")
|
||||
_bundle(tmp_path, monkeypatch)
|
||||
|
||||
# A verified machine token exists, but that alone must not authorize a caller.
|
||||
monkeypatch.setattr("strix.viewer.auth.read_auth", lambda: {"email": "a@b.com", "token": "t"})
|
||||
|
||||
httpd, url = serve(run_dir, open_browser=False)
|
||||
try:
|
||||
# No cookie: forbidden before the machine token is ever consulted.
|
||||
status, _ = _post(url, "/api/report/send", {})
|
||||
assert status == 403
|
||||
|
||||
# With the cookie the request clears the session gate; it then reaches
|
||||
# the run resolver, so an unknown run is a 404 rather than a 403.
|
||||
status, _ = _post(
|
||||
url, "/api/report/send", {"run": "does-not-exist"}, cookie=_session_cookie(url)
|
||||
)
|
||||
assert status == 404
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_historical_run_data_requires_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)
|
||||
|
||||
verified = {"value": False}
|
||||
monkeypatch.setattr("strix.viewer.auth.is_verified", lambda: verified["value"])
|
||||
|
||||
httpd, url = serve(launched, open_browser=False)
|
||||
try:
|
||||
# The launched run is always viewable, no verification required.
|
||||
status, _, _ = _get(f"{url}/api/run")
|
||||
assert status == 200
|
||||
|
||||
# A different run's data is gated behind verification.
|
||||
assert _get_status(f"{url}/api/run?run=other") == 401
|
||||
|
||||
# Once verified, the historical run resolves.
|
||||
verified["value"] = True
|
||||
status, _, _ = _get(f"{url}/api/run?run=other")
|
||||
assert status == 200
|
||||
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"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
|
||||
Reference in New Issue
Block a user