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