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 = {