Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,22 @@ 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 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")
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(
Expand Down
81 changes: 81 additions & 0 deletions tests/dashboard/test_auth_cors_preflight.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""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


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
Loading