From 99bcc2de5bf433d799ea7af782c72ac9bdfd6595 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:57:56 -0700 Subject: [PATCH] fix(security): harden dashboard API against unauthenticated access (#9800) Addresses responsible disclosure from FuzzMind Security Lab (CVE pending). The web dashboard API server had 36 endpoints, of which only 5 checked the session token. The token itself was served from an unauthenticated GET /api/auth/session-token endpoint, rendering the protection circular. When bound to 0.0.0.0 (--host flag), all API keys, config, and cron management were accessible to any machine on the network. Changes: - Add auth middleware requiring session token on ALL /api/ routes except a small public whitelist (status, config/defaults, config/schema, model/info) - Remove GET /api/auth/session-token endpoint entirely; inject the token into index.html via a ' + ) + html = html.replace("", f"{token_script}", 1) + return HTMLResponse( + html, + headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, + ) + application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") @application.get("/{full_path:path}") @@ -1955,24 +1992,32 @@ def mount_spa(application: FastAPI): and file_path.is_file() ): return FileResponse(file_path) - return FileResponse( - WEB_DIST / "index.html", - headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, - ) + return _serve_index() mount_spa(app) -def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True): +def start_server( + host: str = "127.0.0.1", + port: int = 9119, + open_browser: bool = True, + allow_public: bool = False, +): """Start the web UI server.""" import uvicorn - if host not in ("127.0.0.1", "localhost", "::1"): - import logging - logging.warning( - "Binding to %s — the web UI exposes config and API keys. " - "Only bind to non-localhost if you trust all users on the network.", host, + _LOCALHOST = ("127.0.0.1", "localhost", "::1") + if host not in _LOCALHOST and not allow_public: + raise SystemExit( + f"Refusing to bind to {host} — the dashboard exposes API keys " + f"and config without robust authentication.\n" + f"Use --insecure to override (NOT recommended on untrusted networks)." + ) + if host not in _LOCALHOST: + _log.warning( + "Binding to %s with --insecure — the dashboard has no robust " + "authentication. Only use on trusted networks.", host, ) if open_browser: diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1bbbdba1cc..ebcb2c95c3 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -108,8 +108,9 @@ class TestWebServerEndpoints: except ImportError: pytest.skip("fastapi/starlette not installed") - from hermes_cli.web_server import app + from hermes_cli.web_server import app, _SESSION_TOKEN self.client = TestClient(app) + self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" def test_get_status(self): resp = self.client.get("/api/status") @@ -239,9 +240,13 @@ class TestWebServerEndpoints: def test_reveal_env_var_no_token(self, tmp_path): """POST /api/env/reveal without token should return 401.""" + from starlette.testclient import TestClient + from hermes_cli.web_server import app from hermes_cli.config import save_env_value save_env_value("TEST_REVEAL_NOAUTH", "secret-value") - resp = self.client.post( + # Use a fresh client WITHOUT the Authorization header + unauth_client = TestClient(app) + resp = unauth_client.post( "/api/env/reveal", json={"key": "TEST_REVEAL_NOAUTH"}, ) @@ -258,12 +263,32 @@ class TestWebServerEndpoints: ) assert resp.status_code == 401 - def test_session_token_endpoint(self): - """GET /api/auth/session-token should return a token.""" - from hermes_cli.web_server import _SESSION_TOKEN + def test_session_token_endpoint_removed(self): + """GET /api/auth/session-token should no longer exist (token injected via HTML).""" resp = self.client.get("/api/auth/session-token") + # The endpoint is gone — the catch-all SPA route serves index.html + # or the middleware returns 401 for unauthenticated /api/ paths. + assert resp.status_code in (200, 404) + # Either way, it must NOT return the token as JSON + try: + data = resp.json() + assert "token" not in data + except Exception: + pass # Not JSON — that's fine (SPA HTML) + + def test_unauthenticated_api_blocked(self): + """API requests without the session token should be rejected.""" + from starlette.testclient import TestClient + from hermes_cli.web_server import app + # Create a client WITHOUT the Authorization header + unauth_client = TestClient(app) + resp = unauth_client.get("/api/env") + assert resp.status_code == 401 + resp = unauth_client.get("/api/config") + assert resp.status_code == 401 + # Public endpoints should still work + resp = unauth_client.get("/api/status") assert resp.status_code == 200 - assert resp.json()["token"] == _SESSION_TOKEN def test_path_traversal_blocked(self): """Verify URL-encoded path traversal is blocked.""" @@ -358,8 +383,9 @@ class TestConfigRoundTrip: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") - from hermes_cli.web_server import app + from hermes_cli.web_server import app, _SESSION_TOKEN self.client = TestClient(app) + self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" def test_get_config_no_internal_keys(self): """GET /api/config should not expose _config_version or _model_meta.""" @@ -490,8 +516,9 @@ class TestNewEndpoints: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") - from hermes_cli.web_server import app + from hermes_cli.web_server import app, _SESSION_TOKEN self.client = TestClient(app) + self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" def test_get_logs_default(self): resp = self.client.get("/api/logs") @@ -668,11 +695,16 @@ class TestNewEndpoints: assert isinstance(data["daily"], list) assert "total_sessions" in data["totals"] - def test_session_token_endpoint(self): - from hermes_cli.web_server import _SESSION_TOKEN + def test_session_token_endpoint_removed(self): + """GET /api/auth/session-token no longer exists.""" resp = self.client.get("/api/auth/session-token") - assert resp.status_code == 200 - assert resp.json()["token"] == _SESSION_TOKEN + # Should not return a JSON token object + assert resp.status_code in (200, 404) + try: + data = resp.json() + assert "token" not in data + except Exception: + pass # --------------------------------------------------------------------------- diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 82353f6492..e610439938 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,11 +1,22 @@ const BASE = ""; -// Ephemeral session token for protected endpoints (reveal). -// Fetched once on first reveal request and cached in memory. +// Ephemeral session token for protected endpoints. +// Injected into index.html by the server — never fetched via API. +declare global { + interface Window { + __HERMES_SESSION_TOKEN__?: string; + } +} let _sessionToken: string | null = null; async function fetchJSON(url: string, init?: RequestInit): Promise { - const res = await fetch(`${BASE}${url}`, init); + // Inject the session token into all /api/ requests. + const headers = new Headers(init?.headers); + const token = window.__HERMES_SESSION_TOKEN__; + if (token && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${token}`); + } + const res = await fetch(`${BASE}${url}`, { ...init, headers }); if (!res.ok) { const text = await res.text().catch(() => res.statusText); throw new Error(`${res.status}: ${text}`); @@ -15,9 +26,12 @@ async function fetchJSON(url: string, init?: RequestInit): Promise { async function getSessionToken(): Promise { if (_sessionToken) return _sessionToken; - const resp = await fetchJSON<{ token: string }>("/api/auth/session-token"); - _sessionToken = resp.token; - return _sessionToken; + const injected = window.__HERMES_SESSION_TOKEN__; + if (injected) { + _sessionToken = injected; + return _sessionToken; + } + throw new Error("Session token not available — page must be served by the Hermes dashboard server"); } export const api = {