Skip to content

fix(dashboard): short-circuit OPTIONS preflight in auth middleware for CORS - #59189

Open
wesleysimplicio wants to merge 4 commits into
NousResearch:mainfrom
wesleysimplicio:simplicio/fix-59052-options-cors-shortcircuit
Open

fix(dashboard): short-circuit OPTIONS preflight in auth middleware for CORS#59189
wesleysimplicio wants to merge 4 commits into
NousResearch:mainfrom
wesleysimplicio:simplicio/fix-59052-options-cors-shortcircuit

Conversation

@wesleysimplicio

Copy link
Copy Markdown
Contributor

Closes #59052

CORS preflight (OPTIONS) to /api/* protected routes return 401 because
the auth middleware runs before CORSMiddleware in Starlette's middleware
stack, and OPTIONS requests carry no session token by design.

This short-circuits OPTIONS in auth_middleware() before the token check,
so CORSMiddleware handles the preflight response with proper CORS headers.

Only change: hermes_cli/web_server.py:auth_middleware()

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jul 5, 2026

@AmirF194 AmirF194 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The diagnosis is right and the fix is safe. CORSMiddleware is registered first, so it ends up innermost and runs after the auth middleware, which is why a tokenless OPTIONS preflight gets 401 before CORS can answer. Short-circuiting OPTIONS in the auth middleware lets the inner CORSMiddleware produce the preflight response.

I checked the smuggling angle since bypassing auth on a whole method is the scary part, and it holds up. No route processes OPTIONS as a mutating or data-returning action (handlers are all method-specific, no @app.options, no methods lists including OPTIONS). I confirmed by sending OPTIONS /api/gateway/drain with a real drain body and no token, and the drain handler never ran. A genuine preflight is intercepted by CORSMiddleware and returns "OK" with just CORS headers, so no handler output leaks, and the host-header (DNS-rebinding) check still runs on the OPTIONS path since it is inner to auth. CORS itself stays locked to localhost with no allow-credentials, and an evil Origin gets a 400 with nothing reflected.

Two things before merge, neither a security blocker. First, please add a regression test. This changes an auth-bypass branch and there is currently no coverage for it (a temporary scaffold I wrote in a clean 3.11 container confirmed preflight-to-protected-route returns CORS not 401, non-OPTIONS still 401, and OPTIONS triggers no handler side effect, and it fails-first on main). Something small along those lines is enough. Second, consider gating on a real preflight, e.g. also requiring the access-control-request-method header, so a bare OPTIONS with no Origin does not fall through to the router. Right now it returns 405 vs 404 by route, which is a minor path-existence oracle rather than a real leak, but the tighter guard matches intent and costs nothing.

@wesleysimplicio

Copy link
Copy Markdown
Contributor Author

Reopening — this was closed in error by the automated stale-PR check, which only inspected GitHub's reviewDecision field (APPROVED/CHANGES_REQUESTED) and missed this PR's existing COMMENTED maintainer review confirming the fix is correct. Apologies for the noise.

@wesleysimplicio

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review, @AmirF194 — both points addressed:

  1. Regression test (tests/dashboard/test_auth_cors_preflight.py): asserts a genuine preflight (OPTIONS + Access-Control-Request-Method) to a protected /api/ route is answered by CORSMiddleware (200 + CORS headers, not 401), a tokenless GET still 401s, and a bare OPTIONS no longer bypasses auth. Fails-first on the pre-fix middleware, passes after.
  2. Tighter guard: the short-circuit now requires the Access-Control-Request-Method header, so a bare OPTIONS with no Origin falls through to the normal gate instead of skipping the token check — matching real-preflight intent as you suggested.
python -m pytest tests/dashboard/test_auth_cors_preflight.py -q  ->  3 passed
python -m ruff check hermes_cli/web_server.py tests/dashboard/test_auth_cors_preflight.py  ->  All checks passed!

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding the focused regression coverage; the current-main diagnosis is correct: auth_middleware token-gates protected /api/ paths at hermes_cli/web_server.py:590-595, while CORS is registered earlier at hermes_cli/web_server.py:299-304.

Problems

  • hermes_cli/web_server.py:584 checks OPTIONS plus Access-Control-Request-Method, but does not require Origin. Starlette CORS passes through requests without Origin before considering the preflight header (starlette/middleware/cors.py:85-93), so a no-Origin OPTIONS request with that header bypasses token auth but is not handled as a CORS preflight.
  • tests/dashboard/test_auth_cors_preflight.py covers a headerless bare OPTIONS request, not that no-Origin-plus-preflight-header case.

Suggested changes

  • Require a nonempty Origin as well as Access-Control-Request-Method, then add a test that the no-Origin variant remains 401. Cross-referenced PR #59422 uses this exact predicate and its test covers a genuine cross-port preflight.

Automated hermes-sweeper review.

Comment thread hermes_cli/web_server.py Outdated
@wesleysimplicio

Copy link
Copy Markdown
Contributor Author

Thanks @teknium1 — good catch, addressed in the pushed commit.

The short-circuit now also requires a nonempty Origin, matching Starlette's CORSMiddleware (which passes Origin-less requests straight through without a preflight response). So a no-Origin OPTIONS carrying Access-Control-Request-Method no longer skips the token check — it falls through to the 401 gate, same as #59422's predicate.

Added a regression test (test_options_with_preflight_header_but_no_origin_does_not_bypass_auth) covering exactly that case.

python -m pytest tests/dashboard/test_auth_cors_preflight.py -q  ->  4 passed
python -m ruff check hermes_cli/web_server.py tests/dashboard/test_auth_cors_preflight.py  ->  All checks passed!

Fails-first on the previous guard (the no-Origin variant returned 405 from routing, i.e. it had bypassed auth), passes after requiring Origin.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 15, 2026
wesleysimplicio and others added 4 commits July 22, 2026 16:40
…r 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 NousResearch#59052
…header

Address review on NousResearch#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.
Per maintainer review on NousResearch#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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/cli CLI entry point, hermes_cli/, setup wizard comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OPTIONS preflight returns 401 on /api/* — dashboard auth middleware ordered before CORS

4 participants