mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
b583210c97
commit
99bcc2de5b
4 changed files with 152 additions and 56 deletions
|
|
@ -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)
|
||||
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue