Compare commits

...
Author SHA1 Message Date
oyasumi 6f88b7d7d5 Require viewer session for run data 2026-08-19 15:25:57 -04:00
oyasumi 8d3693df8c Expose viewer host option 2026-08-19 15:25:57 -04:00
4 changed files with 81 additions and 28 deletions
+5
View File
@@ -167,10 +167,15 @@ strix view
# ...or open a specific run by name # ...or open a specific run by name
strix view my-run-name strix view my-run-name
# Expose the viewer on all IPv4 interfaces at a fixed port
strix view --host 0.0.0.0 --port 8080 --no-open
``` ```
`strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step. `strix view` starts a lightweight local server (bound to `127.0.0.1` on a random port) and opens your browser to a private, tokened link. Nothing leaves your machine: the dashboard reads the run's files straight off disk, with no cloud account or upload required. The UI ships prebuilt with Strix, so there is no extra install and no JS build step.
Use `--host 0.0.0.0` to make the viewer reachable from other machines. Replace `0.0.0.0` in the printed URL with the server's reachable IP or hostname. The token in that URL grants access to the selected run's scan data, history, and steering, so only share it with trusted users and restrict the port with your firewall. Requests without the token-derived session cannot read run data.
### What's in the dashboard ### What's in the dashboard
- **Overview**: run status, target, and a severity breakdown of everything found so far. - **Overview**: run status, target, and a severity breakdown of everything found so far.
+5 -1
View File
@@ -45,7 +45,11 @@ def run_view(argv: list[str]) -> None:
default=0, default=0,
help="Port to serve on (default: an available ephemeral port).", help="Port to serve on (default: an available ephemeral port).",
) )
parser.add_argument("--host", default="127.0.0.1", help=argparse.SUPPRESS) parser.add_argument(
"--host",
default="127.0.0.1",
help="Host to bind to (default: 127.0.0.1; use 0.0.0.0 for all IPv4 interfaces).",
)
parser.add_argument( parser.add_argument(
"--no-open", "--no-open",
action="store_true", action="store_true",
+22 -20
View File
@@ -135,8 +135,9 @@ class _ViewerState:
# exchanged for a session cookie only when presented on the initial page # exchanged for a session cookie only when presented on the initial page
# load. It is the request-level authorization the review asked for: # load. It is the request-level authorization the review asked for:
# reachability of the port (e.g. when bound with ``--host``) is not # reachability of the port (e.g. when bound with ``--host``) is not
# enough to steer a live scan, trigger a report, or browse history -- # enough to read run data, steer a live scan, trigger a report, or
# the token is never handed to a caller who merely reaches ``/``. # browse history -- the token is never handed to a caller who merely
# reaches ``/``.
self.session_token = secrets.token_urlsafe(32) self.session_token = secrets.token_urlsafe(32)
# Finalized in ``serve()`` once the port is known (the server binds # Finalized in ``serve()`` once the port is known (the server binds
# after this state is constructed); see SESSION_COOKIE_PREFIX. # after this state is constructed); see SESSION_COOKIE_PREFIX.
@@ -234,11 +235,11 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self.end_headers() self.end_headers()
def _handle_api(self, path: str, query: dict[str, list[str]]) -> None: def _handle_api(self, path: str, query: dict[str, list[str]]) -> None:
# The launched run is always viewable with no verification. The # The cross-run history list (/api/runs) unlocks its entries only for
# cross-run history list (/api/runs) unlocks its entries only for a # a caller that holds this process's session capability *and* is
# caller that holds this process's session capability *and* is email # email verified, so merely reaching an exposed --host port never
# verified, so merely reaching an exposed --host port never leaks the # leaks the run list (the payload still advertises the count as a
# run list (the payload still advertises the count as a teaser). # teaser).
if path == "/api/runs": if path == "/api/runs":
unlocked = self._has_session() and auth.is_verified() unlocked = self._has_session() and auth.is_verified()
payload = build_runs_payload(state.base_dir, verified=unlocked) payload = build_runs_payload(state.base_dir, verified=unlocked)
@@ -253,6 +254,13 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._handle_auth_status() self._handle_auth_status()
return return
# All remaining GET endpoints expose run metadata or scan output.
# Require the capability even for the run used to launch the viewer;
# reachability of an exposed --host port must not grant data access.
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
run_values = query.get("run") run_values = query.get("run")
run_param = run_values[0] if run_values else None run_param = run_values[0] if run_values else None
run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir) run_dir = resolve_run_dir(state.base_dir, run_param, state.run_dir)
@@ -260,18 +268,12 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"}) self._send_json(HTTPStatus.NOT_FOUND, {"error": "unknown run"})
return return
# The launched run is always viewable. Any *other* run's data is part # Any run other than the one used to launch the viewer is part of the
# of the gated history: it needs this process's session capability # email-gated history. The session check above applies to both paths;
# (so merely reaching an exposed --host port is not enough) *and* # verification adds a second gate for historical run data.
# email verification -- otherwise knowing a run name would leak its if run_dir.resolve() != state.run_dir.resolve() and not auth.is_verified():
# metadata, vulnerabilities, report, and transcript. self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
if run_dir.resolve() != state.run_dir.resolve(): return
if not self._has_session():
self._send_json(HTTPStatus.FORBIDDEN, {"error": "forbidden"})
return
if not auth.is_verified():
self._send_json(HTTPStatus.UNAUTHORIZED, {"error": "unverified"})
return
if path == "/api/run": if path == "/api/run":
self._send_json(HTTPStatus.OK, read_run_summary(run_dir)) self._send_json(HTTPStatus.OK, read_run_summary(run_dir))
@@ -385,7 +387,7 @@ def _make_handler(state: _ViewerState) -> type[BaseHTTPRequestHandler]:
except auth.RelayError as exc: except auth.RelayError as exc:
self._send_relay_error(exc) self._send_relay_error(exc)
return return
# The password is returned only to the local (127.0.0.1) browser. # The password is returned only to a session-authorized browser.
self._send_json( self._send_json(
HTTPStatus.OK, HTTPStatus.OK,
{"ok": True, "password": password, "filename": filename}, {"ok": True, "password": password, "filename": filename},
+49 -7
View File
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING
from urllib.parse import urlsplit from urllib.parse import urlsplit
from strix.core.paths import latest_run_dir, runs_base_dir from strix.core.paths import latest_run_dir, runs_base_dir
from strix.interface.viewer.cli import run_view
from strix.interface.viewer.server import serve from strix.interface.viewer.server import serve
from strix.interface.viewer.transcript import ( from strix.interface.viewer.transcript import (
build_run_state, build_run_state,
@@ -48,6 +49,31 @@ def test_latest_run_dir_none_when_no_runs(tmp_path: Path, monkeypatch: pytest.Mo
assert runs_base_dir() == tmp_path / "strix_runs" assert runs_base_dir() == tmp_path / "strix_runs"
def test_view_cli_help_includes_host(capsys: pytest.CaptureFixture[str]) -> None:
try:
run_view(["--help"])
except SystemExit as exc:
assert exc.code == 0
else:
raise AssertionError("--help should exit")
help_text = capsys.readouterr().out
assert "--host HOST" in help_text
assert "0.0.0.0" in help_text
def test_server_can_bind_all_ipv4_interfaces(tmp_path: Path) -> None:
run_dir = _make_run(tmp_path, "remote", status="running", end_time=None)
httpd, url, _ = serve(run_dir, host="0.0.0.0", open_browser=False)
try:
assert httpd.server_address[0] == "0.0.0.0"
assert url == f"http://0.0.0.0:{httpd.server_address[1]}"
finally:
httpd.shutdown()
httpd.server_close()
def test_latest_run_dir_picks_newest_by_record_mtime( def test_latest_run_dir_picks_newest_by_record_mtime(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> None:
@@ -173,14 +199,15 @@ def test_server_serves_api_and_static(tmp_path: Path, monkeypatch: pytest.Monkey
(assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8") (assets / "assets" / "app.js").write_text("console.log(1)", encoding="utf-8")
monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets) monkeypatch.setattr("strix.interface.viewer.server.bundle_dir", lambda: assets)
httpd, url, _ = serve(run_dir, open_browser=False) httpd, url, token = serve(run_dir, open_browser=False)
try: try:
status, ctype, body = _get(f"{url}/api/run") cookie = _session_cookie(url, token)
status, ctype, body = _get(f"{url}/api/run", cookie=cookie)
assert status == 200 assert status == 200
assert "application/json" in ctype assert "application/json" in ctype
assert json.loads(body)["finished"] is True assert json.loads(body)["finished"] is True
status, _, body = _get(f"{url}/api/transcript") status, _, body = _get(f"{url}/api/transcript", cookie=cookie)
assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"} assert {a["id"] for a in json.loads(body)["agents"]} == {"root", "child"}
# Real asset is served. # Real asset is served.
@@ -429,6 +456,22 @@ def test_unauthorized_client_cannot_acquire_capability(
httpd.server_close() httpd.server_close()
def test_run_data_requires_session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "private", status="completed", end_time="2026-01-01T00:00:00Z")
_bundle(tmp_path, monkeypatch)
httpd, url, token = serve(run_dir, open_browser=False)
try:
cookie = _session_cookie(url, token)
for path in ("/api/run", "/api/vulnerabilities", "/api/report", "/api/transcript"):
assert _get_status(url + path) == 403, path
assert _get_status(url + path, cookie=f"{_cookie_name(url)}=wrong") == 403, path
assert _get_status(url + path, cookie=cookie) == 200, path
finally:
httpd.shutdown()
httpd.server_close()
def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_auth_status_reflects_expiry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
run_dir = _make_run(tmp_path, "status", status="running", end_time=None) run_dir = _make_run(tmp_path, "status", status="running", end_time=None)
_bundle(tmp_path, monkeypatch) _bundle(tmp_path, monkeypatch)
@@ -561,11 +604,10 @@ def test_historical_run_data_requires_verification(
httpd, url, token = serve(launched, open_browser=False) httpd, url, token = serve(launched, open_browser=False)
try: try:
# The launched run is always viewable, no verification and no cookie. # The launched run needs the session capability, but not email verification.
status, _, _ = _get(f"{url}/api/run") assert _get_status(f"{url}/api/run") == 403
assert status == 200
cookie = _session_cookie(url, token) cookie = _session_cookie(url, token)
assert _get_status(f"{url}/api/run", cookie=cookie) == 200
# A different run needs the session capability first: a cookie-less # A different run needs the session capability first: a cookie-less
# caller is forbidden even once the machine is verified. # caller is forbidden even once the machine is verified.