Skip to content

Commit 6bc7314

Browse files
committed
fix(auth): reject non-preflight OPTIONS on body-reading routes
CORSMiddleware only answers *preflight* OPTIONS requests (those carrying Access-Control-Request-Method); any other OPTIONS request is forwarded to the wrapped handler, which then tries to read a body and fails with a 400/500. /token, /register and /revoke only accept POST, so a plain OPTIONS should get a 405 instead of being routed into the body-reading handler. Add _reject_non_preflight_options between the CORS layer and the body reader, and drop OPTIONS from the 413 parametrization (OPTIONS no longer reaches the body reader). Fixes #3652
1 parent 06d1d1e commit 6bc7314

2 files changed

Lines changed: 70 additions & 7 deletions

File tree

‎src/mcp/server/auth/routes.py‎

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from starlette.requests import Request
88
from starlette.responses import Response
99
from starlette.routing import Route, request_response # type: ignore
10-
from starlette.types import ASGIApp
10+
from starlette.types import ASGIApp, Receive, Scope, Send
1111

1212
from mcp.server.auth.handlers.authorize import AuthorizationHandler
1313
from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler
@@ -61,6 +61,40 @@ def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp:
6161
)
6262

6363

64+
def _reject_non_preflight_options(app: ASGIApp) -> ASGIApp:
65+
"""Reject OPTIONS requests that are not CORS preflights.
66+
67+
CORSMiddleware only answers *preflight* OPTIONS requests (those carrying
68+
Access-Control-Request-Method). Any other OPTIONS request is forwarded to
69+
the wrapped handler, which would then try to read a body and fail (400/500).
70+
These routes only accept POST, so a plain OPTIONS should get a 405 instead
71+
of being routed into the body-reading handler.
72+
"""
73+
74+
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
75+
if scope["type"] == "http" and scope["method"] == "OPTIONS":
76+
headers = scope.get("headers") or []
77+
is_preflight = any(
78+
k == b"access-control-request-method" for k, _v in headers
79+
)
80+
if not is_preflight:
81+
await send(
82+
{
83+
"type": "http.response.start",
84+
"status": 405,
85+
"headers": [
86+
(b"content-length", b"0"),
87+
(b"allow", b"POST, OPTIONS"),
88+
],
89+
}
90+
)
91+
await send({"type": "http.response.body", "body": b"", "more_body": False})
92+
return
93+
await app(scope, receive, send)
94+
95+
return wrapped
96+
97+
6498
def _body_limited(app: ASGIApp) -> ASGIApp:
6599
return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE)
66100

@@ -117,7 +151,7 @@ def create_auth_routes(
117151
),
118152
Route(
119153
TOKEN_PATH,
120-
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
154+
endpoint=_cors(_reject_non_preflight_options(_body_limited(request_response(token_handler.handle))), ["POST", "OPTIONS"]),
121155
methods=["POST", "OPTIONS"],
122156
),
123157
]
@@ -130,7 +164,7 @@ def create_auth_routes(
130164
routes.append(
131165
Route(
132166
REGISTRATION_PATH,
133-
endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]),
167+
endpoint=_cors(_reject_non_preflight_options(_body_limited(request_response(registration_handler.handle))), ["POST", "OPTIONS"]),
134168
methods=["POST", "OPTIONS"],
135169
)
136170
)
@@ -140,7 +174,7 @@ def create_auth_routes(
140174
routes.append(
141175
Route(
142176
REVOCATION_PATH,
143-
endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]),
177+
endpoint=_cors(_reject_non_preflight_options(_body_limited(request_response(revocation_handler.handle))), ["POST", "OPTIONS"]),
144178
methods=["POST", "OPTIONS"],
145179
)
146180
)

‎tests/server/auth/test_error_handling.py‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -303,9 +303,6 @@ async def test_token_error_handling_refresh_token(
303303
("POST", "/register", "application/json"),
304304
("POST", "/authorize", _FORM),
305305
# The other methods these routes accept reach the same body-reading handlers.
306-
("OPTIONS", "/token", _FORM),
307-
("OPTIONS", "/revoke", _FORM),
308-
("OPTIONS", "/register", "application/json"),
309306
("HEAD", "/authorize", _FORM),
310307
],
311308
)
@@ -347,3 +344,35 @@ async def test_oversized_cross_origin_request_gets_413_with_cors_headers(client:
347344
)
348345
assert response.status_code == 413
349346
assert response.headers["access-control-allow-origin"] == "*"
347+
348+
349+
@pytest.mark.anyio
350+
async def test_plain_options_on_token_is_405_not_500(client: httpx2.AsyncClient):
351+
"""Non-preflight OPTIONS on /token must not be routed into the body reader."""
352+
resp = await client.request("OPTIONS", "/token")
353+
assert resp.status_code == 405
354+
assert resp.headers.get("allow") == "POST, OPTIONS"
355+
356+
357+
@pytest.mark.anyio
358+
async def test_preflight_options_on_token_still_gets_cors_204(
359+
client: httpx2.AsyncClient,
360+
):
361+
"""CORS preflight on /token keeps working."""
362+
resp = await client.request(
363+
"OPTIONS",
364+
"/token",
365+
headers={
366+
"Origin": "https://inspector.example.com",
367+
"Access-Control-Request-Method": "POST",
368+
},
369+
)
370+
assert resp.status_code in (200, 204)
371+
assert "access-control-allow-origin" in {k.lower() for k in resp.headers.keys()}
372+
373+
374+
@pytest.mark.anyio
375+
async def test_plain_options_on_register_is_405_not_500(client: httpx2.AsyncClient):
376+
"""Non-preflight OPTIONS on /register must not be routed into the body reader."""
377+
resp = await client.request("OPTIONS", "/register")
378+
assert resp.status_code == 405

0 commit comments

Comments
 (0)