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,
port=args.port,
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("--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(
"--insecure", action="store_true",
help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)",
)
dashboard_parser.set_defaults(func=cmd_dashboard)
# =========================================================================

View file

@ -10,6 +10,7 @@ Usage:
"""
import asyncio
import hmac
import json
import logging
import secrets
@ -47,7 +48,7 @@ from gateway.status import get_running_pid, read_runtime_status
try:
from fastapi import FastAPI, HTTPException, Request
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 pydantic import BaseModel
except ImportError:
@ -84,6 +85,44 @@ app.add_middleware(
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
@ -607,17 +646,6 @@ async def update_config(body: ConfigUpdate):
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")
async def get_env_vars():
env_on_disk = load_env()
@ -671,9 +699,7 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
- Audit logging
"""
# --- Token check ---
auth = request.headers.get("authorization", "")
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
_require_token(request)
# --- Rate limit ---
now = time.time()
@ -944,9 +970,7 @@ async def list_oauth_providers():
@app.delete("/api/providers/oauth/{provider_id}")
async def disconnect_oauth_provider(provider_id: str, request: Request):
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
auth = request.headers.get("authorization", "")
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
_require_token(request)
valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
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")
async def start_oauth_login(provider_id: str, request: Request):
"""Initiate an OAuth login flow. Token-protected."""
auth = request.headers.get("authorization", "")
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
_require_token(request)
_gc_oauth_sessions()
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid:
@ -1552,9 +1574,7 @@ class OAuthSubmitBody(BaseModel):
@app.post("/api/providers/oauth/{provider_id}/submit")
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
"""Submit the auth code for PKCE flows. Token-protected."""
auth = request.headers.get("authorization", "")
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
_require_token(request)
if provider_id == "anthropic":
return await asyncio.get_event_loop().run_in_executor(
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}")
async def cancel_oauth_session(session_id: str, request: Request):
"""Cancel a pending OAuth session. Token-protected."""
auth = request.headers.get("authorization", "")
if auth != f"Bearer {_SESSION_TOKEN}":
raise HTTPException(status_code=401, detail="Unauthorized")
_require_token(request)
with _oauth_sessions_lock:
sess = _oauth_sessions.pop(session_id, None)
if sess is None:
@ -1932,7 +1950,12 @@ async def get_usage_analytics(days: int = 30):
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():
@application.get("/{full_path:path}")
async def no_frontend(full_path: str):
@ -1942,6 +1965,20 @@ def mount_spa(application: FastAPI):
)
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.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:

View file

@ -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
# ---------------------------------------------------------------------------

View file

@ -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<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) {
const text = await res.text().catch(() => res.statusText);
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> {
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 = {