From 1cc0bdd5f306effd91ec23f0c8d7044acf22473c Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:59:02 -0600 Subject: [PATCH] fix(dashboard): avoid auth header collision with reverse proxies --- hermes_cli/web_server.py | 28 +++++++++---- tests/hermes_cli/test_web_server.py | 63 ++++++++++++++++++++++------- web/src/lib/api.ts | 21 ++++++---- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 10b92f69a..ca473b0a5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -71,6 +71,7 @@ app = FastAPI(title="Hermes Agent", version=__version__) # Injected into the SPA HTML so only the legitimate web UI can use it. # --------------------------------------------------------------------------- _SESSION_TOKEN = secrets.token_urlsafe(32) +_SESSION_HEADER_NAME = "X-Hermes-Session-Token" # Simple rate limiter for the reveal endpoint _reveal_timestamps: List[float] = [] @@ -104,14 +105,29 @@ _PUBLIC_API_PATHS: frozenset = frozenset({ }) -def _require_token(request: Request) -> None: - """Validate the ephemeral session token. Raises 401 on mismatch. +def _has_valid_session_token(request: Request) -> bool: + """True if the request carries a valid dashboard session token. - Uses ``hmac.compare_digest`` to prevent timing side-channels. + The dedicated session header avoids collisions with reverse proxies that + already use ``Authorization`` (for example Caddy ``basic_auth``). We still + accept the legacy Bearer path for backward compatibility with older + dashboard bundles. """ + session_header = request.headers.get(_SESSION_HEADER_NAME, "") + if session_header and hmac.compare_digest( + session_header.encode(), + _SESSION_TOKEN.encode(), + ): + return True + auth = request.headers.get("authorization", "") expected = f"Bearer {_SESSION_TOKEN}" - if not hmac.compare_digest(auth.encode(), expected.encode()): + return hmac.compare_digest(auth.encode(), expected.encode()) + + +def _require_token(request: Request) -> None: + """Validate the ephemeral session token. Raises 401 on mismatch.""" + if not _has_valid_session_token(request): raise HTTPException(status_code=401, detail="Unauthorized") @@ -205,9 +221,7 @@ 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 and not path.startswith("/api/plugins/"): - auth = request.headers.get("authorization", "") - expected = f"Bearer {_SESSION_TOKEN}" - if not hmac.compare_digest(auth.encode(), expected.encode()): + if not _has_valid_session_token(request): return JSONResponse( status_code=401, content={"detail": "Unauthorized"}, diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 572549bd4..1f3a78b99 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -110,12 +110,12 @@ class TestWebServerEndpoints: import hermes_state from hermes_constants import get_hermes_home - from hermes_cli.web_server import app, _SESSION_TOKEN + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") self.client = TestClient(app) - self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN def test_get_status(self): resp = self.client.get("/api/status") @@ -221,12 +221,12 @@ class TestWebServerEndpoints: def test_reveal_env_var(self, tmp_path): """POST /api/env/reveal should return the real unredacted value.""" from hermes_cli.config import save_env_value - from hermes_cli.web_server import _SESSION_TOKEN + from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN save_env_value("TEST_REVEAL_KEY", "super-secret-value-12345") resp = self.client.post( "/api/env/reveal", json={"key": "TEST_REVEAL_KEY"}, - headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, + headers={_SESSION_HEADER_NAME: _SESSION_TOKEN}, ) assert resp.status_code == 200 data = resp.json() @@ -235,11 +235,11 @@ class TestWebServerEndpoints: def test_reveal_env_var_not_found(self): """POST /api/env/reveal should 404 for unknown keys.""" - from hermes_cli.web_server import _SESSION_TOKEN + from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN resp = self.client.post( "/api/env/reveal", json={"key": "NONEXISTENT_KEY_XYZ"}, - headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, + headers={_SESSION_HEADER_NAME: _SESSION_TOKEN}, ) assert resp.status_code == 404 @@ -249,7 +249,7 @@ class TestWebServerEndpoints: from hermes_cli.web_server import app from hermes_cli.config import save_env_value save_env_value("TEST_REVEAL_NOAUTH", "secret-value") - # Use a fresh client WITHOUT the Authorization header + # Use a fresh client WITHOUT the dashboard session header unauth_client = TestClient(app) resp = unauth_client.post( "/api/env/reveal", @@ -260,14 +260,47 @@ class TestWebServerEndpoints: def test_reveal_env_var_bad_token(self, tmp_path): """POST /api/env/reveal with wrong token should return 401.""" from hermes_cli.config import save_env_value + from hermes_cli.web_server import _SESSION_HEADER_NAME save_env_value("TEST_REVEAL_BADAUTH", "secret-value") resp = self.client.post( "/api/env/reveal", json={"key": "TEST_REVEAL_BADAUTH"}, - headers={"Authorization": "Bearer wrong-token-here"}, + headers={_SESSION_HEADER_NAME: "wrong-token-here"}, ) assert resp.status_code == 401 + def test_reveal_env_var_custom_session_header_ignores_proxy_authorization(self, tmp_path): + """A valid dashboard session header should coexist with proxy auth.""" + from hermes_cli.config import save_env_value + from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN + + save_env_value("TEST_REVEAL_PROXY_AUTH", "secret-value") + resp = self.client.post( + "/api/env/reveal", + json={"key": "TEST_REVEAL_PROXY_AUTH"}, + headers={ + _SESSION_HEADER_NAME: _SESSION_TOKEN, + "Authorization": "Basic dXNlcjpwYXNz", + }, + ) + + assert resp.status_code == 200 + assert resp.json()["value"] == "secret-value" + + def test_reveal_env_var_legacy_authorization_header_still_works(self, tmp_path): + """Keep old dashboard bundles working while the new header rolls out.""" + from hermes_cli.config import save_env_value + from hermes_cli.web_server import _SESSION_TOKEN + + save_env_value("TEST_REVEAL_LEGACY_AUTH", "secret-value") + resp = self.client.post( + "/api/env/reveal", + json={"key": "TEST_REVEAL_LEGACY_AUTH"}, + headers={"Authorization": f"Bearer {_SESSION_TOKEN}"}, + ) + + assert resp.status_code == 200 + 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") @@ -285,7 +318,7 @@ class TestWebServerEndpoints: """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 + # Create a client WITHOUT the dashboard session header unauth_client = TestClient(app) resp = unauth_client.get("/api/env") assert resp.status_code == 401 @@ -388,9 +421,9 @@ class TestConfigRoundTrip: from starlette.testclient import TestClient except ImportError: pytest.skip("fastapi/starlette not installed") - from hermes_cli.web_server import app, _SESSION_TOKEN + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN self.client = TestClient(app) - self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN def test_get_config_no_internal_keys(self): """GET /api/config should not expose _config_version or _model_meta.""" @@ -524,12 +557,12 @@ class TestNewEndpoints: import hermes_state from hermes_constants import get_hermes_home - from hermes_cli.web_server import app, _SESSION_TOKEN + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") self.client = TestClient(app) - self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN def test_get_logs_default(self): resp = self.client.get("/api/logs") @@ -1176,9 +1209,9 @@ class TestStatusRemoteGateway: except ImportError: pytest.skip("fastapi/starlette not installed") - from hermes_cli.web_server import app, _SESSION_TOKEN + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN self.client = TestClient(app) - self.client.headers["Authorization"] = f"Bearer {_SESSION_TOKEN}" + self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN def test_status_falls_back_to_remote_probe(self, monkeypatch): """When local PID check fails and remote probe succeeds, gateway shows running.""" diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 45c0618a5..a44c2f5e2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -10,13 +10,20 @@ declare global { } } let _sessionToken: string | null = null; +const SESSION_HEADER = "X-Hermes-Session-Token"; + +function setSessionHeader(headers: Headers, token: string): void { + if (!headers.has(SESSION_HEADER)) { + headers.set(SESSION_HEADER, token); + } +} export async function fetchJSON(url: string, init?: RequestInit): Promise { // 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}`); + if (token) { + setSessionHeader(headers, token); } const res = await fetch(`${BASE}${url}`, { ...init, headers }); if (!res.ok) { @@ -92,7 +99,7 @@ export const api = { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + [SESSION_HEADER]: token, }, body: JSON.stringify({ key }), }); @@ -138,7 +145,7 @@ export const api = { `/api/providers/oauth/${encodeURIComponent(providerId)}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: { [SESSION_HEADER]: token }, }, ); }, @@ -150,7 +157,7 @@ export const api = { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + [SESSION_HEADER]: token, }, body: "{}", }, @@ -164,7 +171,7 @@ export const api = { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + [SESSION_HEADER]: token, }, body: JSON.stringify({ session_id: sessionId, code }), }, @@ -180,7 +187,7 @@ export const api = { `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE", - headers: { Authorization: `Bearer ${token}` }, + headers: { [SESSION_HEADER]: token }, }, ); },