diff --git a/hermes_cli/main.py b/hermes_cli/main.py
index 7347dc4a3..721e68143 100644
--- a/hermes_cli/main.py
+++ b/hermes_cli/main.py
@@ -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)
# =========================================================================
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index f73104ce8..09eb697d1 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -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 ``'
+ )
+ 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 1bbbdba1c..ebcb2c95c 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 82353f649..e61043993 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 = {