From c9406948294cfed6f6126d5fc2f610318397da41 Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sun, 5 Jul 2026 18:37:51 -0300 Subject: [PATCH 1/4] fix(dashboard): short-circuit OPTIONS preflight in auth middleware for CORS CORS preflight (OPTIONS) requests to /api/* protected routes return 401 because the auth middleware (registered before CORSMiddleware in Starlette stack) checks the session token, which preflights never carry by design. Add a guard in auth_middleware to pass OPTIONS through without token validation, letting CORSMiddleware respond with the proper CORS headers. Closes #59052 --- hermes_cli/web_server.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f6c43a550b8b..bafcd1571fa8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -609,6 +609,18 @@ async def auth_middleware(request: Request, call_next): return await call_next(request) path = request.url.path is_mcp_oauth_callback = path.startswith("/api/mcp/oauth/callback/") + # A genuine CORS preflight (OPTIONS + Origin + Access-Control-Request-Method) + # carries no credentials by design — the session-token check would always + # fail and return a 401 before CORSMiddleware can answer with the proper + # CORS headers. Short-circuit only real preflights so CORSMiddleware handles + # the reply. Requiring Origin matches Starlette, which passes Origin-less + # requests through without emitting a preflight response. + if ( + request.method == "OPTIONS" + and request.headers.get("origin") + and "access-control-request-method" in request.headers + ): + return await call_next(request) if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse( From abd5b7552dd9684f12ce8c86d6db08557b5e352c Mon Sep 17 00:00:00 2001 From: "Simplicio, Wesley (ext)" Date: Wed, 15 Jul 2026 11:49:05 -0300 Subject: [PATCH 2/4] test(dashboard): cover CORS preflight auth bypass; require preflight header Address review on #59052: add a regression test that a genuine OPTIONS preflight to a protected /api/ route is answered by CORSMiddleware (not 401), a tokenless GET still 401s, and a bare OPTIONS with no Access-Control-Request-Method no longer bypasses auth. Tighten the guard to require the preflight header so a bare OPTIONS cannot probe protected routes. --- tests/dashboard/test_auth_cors_preflight.py | 67 +++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 tests/dashboard/test_auth_cors_preflight.py diff --git a/tests/dashboard/test_auth_cors_preflight.py b/tests/dashboard/test_auth_cors_preflight.py new file mode 100644 index 000000000000..3cf440931224 --- /dev/null +++ b/tests/dashboard/test_auth_cors_preflight.py @@ -0,0 +1,67 @@ +"""Regression tests for CORS-preflight handling in the dashboard auth gate. + +Issue #59052: ``CORSMiddleware`` is registered first, so it ends up +*innermost* and runs after ``auth_middleware``. A browser CORS preflight +(``OPTIONS``) carries no session token, so the token check returned 401 +before ``CORSMiddleware`` could answer with the preflight headers, and the +SPA's cross-origin requests to protected ``/api/`` routes failed. + +The contract these tests pin down: + + * A genuine preflight (``OPTIONS`` + ``Access-Control-Request-Method``) + to a protected route short-circuits the token check and is answered by + ``CORSMiddleware`` with the CORS headers (not 401). + * A non-``OPTIONS`` request to a protected route without a token still + returns 401 (the auth gate is otherwise unchanged). + * A bare ``OPTIONS`` with no preflight header does NOT bypass auth — it + falls through to the normal gate, so it cannot be used to probe + protected routes without credentials. +""" + +from __future__ import annotations + +import pytest +from starlette.testclient import TestClient + +from hermes_cli import web_server + +# A route under ``/api/`` that is NOT in ``PUBLIC_API_PATHS`` — i.e. the auth +# gate enforces the session token on it. +_PROTECTED_PATH = "/api/config" +_ORIGIN = "http://localhost:3000" + + +@pytest.fixture +def client(): + """A TestClient with the legacy (loopback) auth gate active. + + ``auth_required`` False keeps the OAuth gate a no-op so ``auth_middleware`` + is the authority — the path exercised by the fix. Restored afterwards so + the flag does not leak into other tests. + """ + saved = getattr(web_server.app.state, "auth_required", None) + web_server.app.state.auth_required = False + yield TestClient(web_server.app) + web_server.app.state.auth_required = saved + + +def test_genuine_preflight_is_answered_by_cors_not_401(client): + resp = client.request( + "OPTIONS", + _PROTECTED_PATH, + headers={"Origin": _ORIGIN, "Access-Control-Request-Method": "GET"}, + ) + assert resp.status_code == 200 + assert resp.headers.get("access-control-allow-origin") == _ORIGIN + + +def test_protected_route_without_token_still_401(client): + resp = client.get(_PROTECTED_PATH) + assert resp.status_code == 401 + + +def test_bare_options_without_preflight_header_does_not_bypass_auth(client): + # No Access-Control-Request-Method → not a real preflight, so it must not + # short-circuit the token check and hand back a 200. + resp = client.request("OPTIONS", _PROTECTED_PATH) + assert resp.status_code == 401 From d9d39e9f3060140493c2b15215fb21d4806a465c Mon Sep 17 00:00:00 2001 From: "Simplicio, Wesley (ext)" Date: Wed, 15 Jul 2026 15:55:59 -0300 Subject: [PATCH 3/4] fix(dashboard): require Origin on CORS-preflight auth short-circuit Per maintainer review on #59189: the OPTIONS short-circuit checked only Access-Control-Request-Method, but Starlette CORSMiddleware passes Origin-less requests through without emitting a preflight response. A no-Origin OPTIONS carrying that header therefore skipped the session-token check while never being handled as a real preflight. Require a nonempty Origin as well; add a regression test that the no-Origin variant stays 401. --- hermes_cli/web_server.py | 14 +++++++++----- tests/dashboard/test_auth_cors_preflight.py | 14 ++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index bafcd1571fa8..96ecd84eea07 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -610,11 +610,15 @@ async def auth_middleware(request: Request, call_next): path = request.url.path is_mcp_oauth_callback = path.startswith("/api/mcp/oauth/callback/") # A genuine CORS preflight (OPTIONS + Origin + Access-Control-Request-Method) - # carries no credentials by design — the session-token check would always - # fail and return a 401 before CORSMiddleware can answer with the proper - # CORS headers. Short-circuit only real preflights so CORSMiddleware handles - # the reply. Requiring Origin matches Starlette, which passes Origin-less - # requests through without emitting a preflight response. + # carries no credentials by design — the session-token check would + # always fail and return a 401 before CORSMiddleware can answer with the + # proper CORS headers. Short-circuit only real preflights so + # CORSMiddleware handles the reply. Requiring a nonempty Origin matches + # Starlette CORSMiddleware, which passes an Origin-less request straight + # through without emitting a preflight response; without this an + # OPTIONS + Access-Control-Request-Method with no Origin would skip the + # token check yet never be treated as a preflight. A bare OPTIONS with + # no preflight header still falls through to normal routing. if ( request.method == "OPTIONS" and request.headers.get("origin") diff --git a/tests/dashboard/test_auth_cors_preflight.py b/tests/dashboard/test_auth_cors_preflight.py index 3cf440931224..667cf878bd57 100644 --- a/tests/dashboard/test_auth_cors_preflight.py +++ b/tests/dashboard/test_auth_cors_preflight.py @@ -65,3 +65,17 @@ def test_bare_options_without_preflight_header_does_not_bypass_auth(client): # short-circuit the token check and hand back a 200. resp = client.request("OPTIONS", _PROTECTED_PATH) assert resp.status_code == 401 + + +def test_options_with_preflight_header_but_no_origin_does_not_bypass_auth(client): + # Starlette's ``CORSMiddleware`` passes requests with no ``Origin`` straight + # through without emitting a preflight response, so an ``OPTIONS`` carrying + # ``Access-Control-Request-Method`` but no ``Origin`` is not a real + # preflight — it must not short-circuit the token check (issue #59052 + # review). Requiring a nonempty ``Origin`` keeps it on the 401 path. + resp = client.request( + "OPTIONS", + _PROTECTED_PATH, + headers={"Access-Control-Request-Method": "GET"}, + ) + assert resp.status_code == 401 From b5bdb969dc1286e48e3deaebe1baf9e3d63efa42 Mon Sep 17 00:00:00 2001 From: "Simplicio, Wesley (ext)" Date: Thu, 16 Jul 2026 00:20:10 -0300 Subject: [PATCH 4/4] ci: retrigger checks