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 <script> tag at serve time instead
- Replace all inline token comparisons (!=) with hmac.compare_digest()
  to prevent timing side-channel attacks
- Block non-localhost binding by default; require --insecure flag to
  override (with warning log)
- Update frontend fetchJSON() to send Authorization header on all
  requests using the injected window.__HERMES_SESSION_TOKEN__

Credit: Callum (@0xca1x) and @migraine-sudo at FuzzMind Security Lab
This commit is contained in:
Teknium 2026-04-14 10:57:56 -07:00 committed by GitHub
parent b583210c97
commit 99bcc2de5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 152 additions and 56 deletions

View file

@ -4421,6 +4421,7 @@ def cmd_dashboard(args):
host=args.host, host=args.host,
port=args.port, port=args.port,
open_browser=not args.no_open, open_browser=not args.no_open,
allow_public=getattr(args, "insecure", False),
) )
@ -5932,6 +5933,10 @@ Examples:
dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)") dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)")
dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)") dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)")
dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically") dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically")
dashboard_parser.add_argument(
"--insecure", action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
)
dashboard_parser.set_defaults(func=cmd_dashboard) dashboard_parser.set_defaults(func=cmd_dashboard)
# ========================================================================= # =========================================================================

View file

@ -10,6 +10,7 @@ Usage:
""" """
import asyncio import asyncio
import hmac
import json import json
import logging import logging
import secrets import secrets
@ -47,7 +48,7 @@ from gateway.status import get_running_pid, read_runtime_status
try: try:
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel from pydantic import BaseModel
except ImportError: except ImportError:
@ -84,6 +85,44 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ---------------------------------------------------------------------------
# Endpoints that do NOT require the session token. Everything else under
# /api/ is gated by the auth middleware below. Keep this list minimal —
# only truly non-sensitive, read-only endpoints belong here.
# ---------------------------------------------------------------------------
_PUBLIC_API_PATHS: frozenset = frozenset({
"/api/status",
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
})
def _require_token(request: Request) -> None:
"""Validate the ephemeral session token. Raises 401 on mismatch.
Uses ``hmac.compare_digest`` to prevent timing side-channels.
"""
auth = request.headers.get("authorization", "")
expected = f"Bearer {_SESSION_TOKEN}"
if not hmac.compare_digest(auth.encode(), expected.encode()):
raise HTTPException(status_code=401, detail="Unauthorized")
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Require the session token on all /api/ routes except the public list."""
path = request.url.path
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
auth = request.headers.get("authorization", "")
expected = f"Bearer {_SESSION_TOKEN}"
if not hmac.compare_digest(auth.encode(), expected.encode()):
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
)
return await call_next(request)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Config schema — auto-generated from DEFAULT_CONFIG # Config schema — auto-generated from DEFAULT_CONFIG
@ -607,17 +646,6 @@ async def update_config(body: ConfigUpdate):
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@app.get("/api/auth/session-token")
async def get_session_token():
"""Return the ephemeral session token for this server instance.
The token protects sensitive endpoints (reveal). It's served to the SPA
which stores it in memory it's never persisted and dies when the server
process exits. CORS already restricts this to localhost origins.
"""
return {"token": _SESSION_TOKEN}
@app.get("/api/env") @app.get("/api/env")
async def get_env_vars(): async def get_env_vars():
env_on_disk = load_env() env_on_disk = load_env()
@ -671,9 +699,7 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
- Audit logging - Audit logging
""" """
# --- Token check --- # --- Token check ---
auth = request.headers.get("authorization", "") _require_token(request)
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
# --- Rate limit --- # --- Rate limit ---
now = time.time() now = time.time()
@ -944,9 +970,7 @@ async def list_oauth_providers():
@app.delete("/api/providers/oauth/{provider_id}") @app.delete("/api/providers/oauth/{provider_id}")
async def disconnect_oauth_provider(provider_id: str, request: Request): async def disconnect_oauth_provider(provider_id: str, request: Request):
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" """Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
auth = request.headers.get("authorization", "") _require_token(request)
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid_ids: if provider_id not in valid_ids:
@ -1518,9 +1542,7 @@ def _codex_full_login_worker(session_id: str) -> None:
@app.post("/api/providers/oauth/{provider_id}/start") @app.post("/api/providers/oauth/{provider_id}/start")
async def start_oauth_login(provider_id: str, request: Request): async def start_oauth_login(provider_id: str, request: Request):
"""Initiate an OAuth login flow. Token-protected.""" """Initiate an OAuth login flow. Token-protected."""
auth = request.headers.get("authorization", "") _require_token(request)
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
_gc_oauth_sessions() _gc_oauth_sessions()
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid: if provider_id not in valid:
@ -1552,9 +1574,7 @@ class OAuthSubmitBody(BaseModel):
@app.post("/api/providers/oauth/{provider_id}/submit") @app.post("/api/providers/oauth/{provider_id}/submit")
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request): async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
"""Submit the auth code for PKCE flows. Token-protected.""" """Submit the auth code for PKCE flows. Token-protected."""
auth = request.headers.get("authorization", "") _require_token(request)
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
if provider_id == "anthropic": if provider_id == "anthropic":
return await asyncio.get_event_loop().run_in_executor( return await asyncio.get_event_loop().run_in_executor(
None, _submit_anthropic_pkce, body.session_id, body.code, None, _submit_anthropic_pkce, body.session_id, body.code,
@ -1582,9 +1602,7 @@ async def poll_oauth_session(provider_id: str, session_id: str):
@app.delete("/api/providers/oauth/sessions/{session_id}") @app.delete("/api/providers/oauth/sessions/{session_id}")
async def cancel_oauth_session(session_id: str, request: Request): async def cancel_oauth_session(session_id: str, request: Request):
"""Cancel a pending OAuth session. Token-protected.""" """Cancel a pending OAuth session. Token-protected."""
auth = request.headers.get("authorization", "") _require_token(request)
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
with _oauth_sessions_lock: with _oauth_sessions_lock:
sess = _oauth_sessions.pop(session_id, None) sess = _oauth_sessions.pop(session_id, None)
if sess is None: if sess is None:
@ -1932,7 +1950,12 @@ async def get_usage_analytics(days: int = 30):
def mount_spa(application: FastAPI): def mount_spa(application: FastAPI):
"""Mount the built SPA. Falls back to index.html for client-side routing.""" """Mount the built SPA. Falls back to index.html for client-side routing.
The session token is injected into index.html via a ``<script>`` tag so
the SPA can authenticate against protected API endpoints without a
separate (unauthenticated) token-dispensing endpoint.
"""
if not WEB_DIST.exists(): if not WEB_DIST.exists():
@application.get("/{full_path:path}") @application.get("/{full_path:path}")
async def no_frontend(full_path: str): async def no_frontend(full_path: str):
@ -1942,6 +1965,20 @@ def mount_spa(application: FastAPI):
) )
return return
_index_path = WEB_DIST / "index.html"
def _serve_index():
"""Return index.html with the session token injected."""
html = _index_path.read_text()
token_script = (
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";</script>'
)
html = html.replace("</head>", f"{token_script}</head>", 1)
return HTMLResponse(
html,
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets")
@application.get("/{full_path:path}") @application.get("/{full_path:path}")
@ -1955,24 +1992,32 @@ def mount_spa(application: FastAPI):
and file_path.is_file() and file_path.is_file()
): ):
return FileResponse(file_path) return FileResponse(file_path)
return FileResponse( return _serve_index()
WEB_DIST / "index.html",
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
mount_spa(app) 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.""" """Start the web UI server."""
import uvicorn import uvicorn
if host not in ("127.0.0.1", "localhost", "::1"): _LOCALHOST = ("127.0.0.1", "localhost", "::1")
import logging if host not in _LOCALHOST and not allow_public:
logging.warning( raise SystemExit(
"Binding to %s — the web UI exposes config and API keys. " f"Refusing to bind to {host} — the dashboard exposes API keys "
"Only bind to non-localhost if you trust all users on the network.", host, 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: if open_browser:

View file

@ -108,8 +108,9 @@ class TestWebServerEndpoints:
except ImportError: except ImportError:
pytest.skip("fastapi/starlette not installed") 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 = TestClient(app)
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
def test_get_status(self): def test_get_status(self):
resp = self.client.get("/api/status") resp = self.client.get("/api/status")
@ -239,9 +240,13 @@ class TestWebServerEndpoints:
def test_reveal_env_var_no_token(self, tmp_path): def test_reveal_env_var_no_token(self, tmp_path):
"""POST /api/env/reveal without token should return 401.""" """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 from hermes_cli.config import save_env_value
save_env_value("TEST_REVEAL_NOAUTH", "secret-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", "/api/env/reveal",
json={"key": "TEST_REVEAL_NOAUTH"}, json={"key": "TEST_REVEAL_NOAUTH"},
) )
@ -258,12 +263,32 @@ class TestWebServerEndpoints:
) )
assert resp.status_code == 401 assert resp.status_code == 401
def test_session_token_endpoint(self): def test_session_token_endpoint_removed(self):
"""GET /api/auth/session-token should return a token.""" """GET /api/auth/session-token should no longer exist (token injected via HTML)."""
from hermes_cli.web_server import _SESSION_TOKEN
resp = self.client.get("/api/auth/session-token") 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.status_code == 200
assert resp.json()["token"] == _SESSION_TOKEN
def test_path_traversal_blocked(self): def test_path_traversal_blocked(self):
"""Verify URL-encoded path traversal is blocked.""" """Verify URL-encoded path traversal is blocked."""
@ -358,8 +383,9 @@ class TestConfigRoundTrip:
from starlette.testclient import TestClient from starlette.testclient import TestClient
except ImportError: except ImportError:
pytest.skip("fastapi/starlette not installed") 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 = TestClient(app)
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
def test_get_config_no_internal_keys(self): def test_get_config_no_internal_keys(self):
"""GET /api/config should not expose _config_version or _model_meta.""" """GET /api/config should not expose _config_version or _model_meta."""
@ -490,8 +516,9 @@ class TestNewEndpoints:
from starlette.testclient import TestClient from starlette.testclient import TestClient
except ImportError: except ImportError:
pytest.skip("fastapi/starlette not installed") 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 = TestClient(app)
self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}"
def test_get_logs_default(self): def test_get_logs_default(self):
resp = self.client.get("/api/logs") resp = self.client.get("/api/logs")
@ -668,11 +695,16 @@ class TestNewEndpoints:
assert isinstance(data["daily"], list) assert isinstance(data["daily"], list)
assert "total_sessions" in data["totals"] assert "total_sessions" in data["totals"]
def test_session_token_endpoint(self): def test_session_token_endpoint_removed(self):
from hermes_cli.web_server import _SESSION_TOKEN """GET /api/auth/session-token no longer exists."""
resp = self.client.get("/api/auth/session-token") resp = self.client.get("/api/auth/session-token")
assert resp.status_code == 200 # Should not return a JSON token object
assert resp.json()["token"] == _SESSION_TOKEN assert resp.status_code in (200, 404)
try:
data = resp.json()
assert "token" not in data
except Exception:
pass
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -1,11 +1,22 @@
const BASE = ""; const BASE = "";
// Ephemeral session token for protected endpoints (reveal). // Ephemeral session token for protected endpoints.
// Fetched once on first reveal request and cached in memory. // Injected into index.html by the server — never fetched via API.
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}
let _sessionToken: string | null = null; let _sessionToken: string | null = null;
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> { async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
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) { if (!res.ok) {
const text = await res.text().catch(() => res.statusText); const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`); throw new Error(`${res.status}: ${text}`);
@ -15,9 +26,12 @@ async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
async function getSessionToken(): Promise<string> { async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken; if (_sessionToken) return _sessionToken;
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token"); const injected = window.__HERMES_SESSION_TOKEN__;
_sessionToken = resp.token; if (injected) {
return _sessionToken; _sessionToken = injected;
return _sessionToken;
}
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
} }
export const api = { export const api = {