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

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