Skip to content

Commit 43f85b3

Browse files
committed
Check Host and Origin before answering HEAD, and send nothing after the SSE stream
1 parent 0ebf49d commit 43f85b3

3 files changed

Lines changed: 48 additions & 23 deletions

File tree

‎src/mcp/server/mcpserver/server.py‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
from starlette.middleware.authentication import AuthenticationMiddleware
5252
from starlette.requests import Request
5353
from starlette.responses import Response
54-
from starlette.routing import Mount, Route, request_response
54+
from starlette.routing import Mount, Route
55+
from starlette.types import ASGIApp, Receive, Scope, Send
5556

5657
from mcp.server.auth.middleware.auth_context import AuthContextMiddleware
5758
from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware
@@ -98,7 +99,11 @@
9899
StreamableHTTPSessionManager,
99100
)
100101
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
101-
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
102+
from mcp.server.transport_security import (
103+
DEFAULT_MAX_REQUEST_BODY_SIZE,
104+
TransportSecurityMiddleware,
105+
TransportSecuritySettings,
106+
)
102107
from mcp.shared.exceptions import MCPError
103108
from mcp.shared.uri_template import UriTemplate
104109

@@ -1171,23 +1176,23 @@ def sse_app(
11711176
message_path, security_settings=transport_security, max_request_body_size=max_request_body_size
11721177
)
11731178

1174-
async def sse_endpoint(request: Request) -> Response:
1175-
if request.method == "HEAD":
1176-
# GET's headers without its never-ending stream; a Content-Length would claim an empty one.
1177-
response = Response(
1178-
media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
1179-
)
1180-
del response.headers["content-length"]
1181-
return response
1182-
async with sse.connect_sse( # pragma: no cover
1183-
request.scope,
1184-
request.receive,
1185-
request._send, # type: ignore[reportPrivateUsage]
1186-
) as streams:
1179+
security = TransportSecurityMiddleware(transport_security)
1180+
1181+
async def handle_sse(scope: Scope, receive: Receive, send: Send) -> None:
1182+
if scope["method"] == "HEAD":
1183+
response = await security.validate_request(Request(scope, receive))
1184+
if response is None:
1185+
# GET's headers without its never-ending stream; a Content-Length would claim an empty one.
1186+
response = Response(
1187+
media_type="text/event-stream", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}
1188+
)
1189+
del response.headers["content-length"]
1190+
await response(scope, receive, send)
1191+
return
1192+
async with sse.connect_sse(scope, receive, send) as streams: # pragma: no cover
11871193
await self._lowlevel_server.run(
11881194
streams[0], streams[1], self._lowlevel_server.create_initialization_options()
11891195
)
1190-
return Response() # pragma: no cover
11911196

11921197
# Create routes
11931198
routes: list[Route | Mount] = []
@@ -1245,9 +1250,7 @@ async def sse_endpoint(request: Request) -> Response:
12451250
routes.append(
12461251
Route(
12471252
sse_path,
1248-
endpoint=RequireAuthMiddleware(
1249-
request_response(sse_endpoint), required_scopes, resource_metadata_url
1250-
),
1253+
endpoint=RequireAuthMiddleware(handle_sse, required_scopes, resource_metadata_url),
12511254
methods=["GET"],
12521255
)
12531256
)
@@ -1262,7 +1265,7 @@ async def sse_endpoint(request: Request) -> Response:
12621265
routes.append(
12631266
Route(
12641267
sse_path,
1265-
endpoint=sse_endpoint,
1268+
endpoint=_ASGIEndpoint(handle_sse),
12661269
methods=["GET"],
12671270
)
12681271
)
@@ -1426,3 +1429,14 @@ def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: st
14261429
message=f"Client did not declare required extension {identifier!r}",
14271430
data=data.model_dump(by_alias=True, mode="json", exclude_none=True),
14281431
)
1432+
1433+
1434+
class _ASGIEndpoint:
1435+
"""Hands a `Route` a raw ASGI app; a plain function would be wrapped as a request/response endpoint,
1436+
which sends a second response after the SSE stream has already completed."""
1437+
1438+
def __init__(self, app: ASGIApp) -> None:
1439+
self.app = app
1440+
1441+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
1442+
await self.app(scope, receive, send)

‎tests/interaction/_requirements.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2709,7 +2709,7 @@ def __post_init__(self) -> None:
27092709
source="issue:#3736",
27102710
behavior=(
27112711
"A HEAD request to the SSE endpoint of `MCPServer.sse_app()` is answered at once with the "
2712-
"headers a GET would get and no body or Content-Length, without opening a session or a stream."
2712+
"headers a GET would get, no body and no Content-Length, after the same Host/Origin checks as a GET."
27132713
),
27142714
transports=("sse",),
27152715
note="Only observable over the legacy SSE transport.",

‎tests/interaction/transports/test_sse.py‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,20 +94,31 @@ async def test_post_for_an_unknown_session_is_rejected() -> None:
9494

9595
@requirement("transport:sse:head")
9696
async def test_head_on_the_sse_endpoint_is_answered_with_headers_only() -> None:
97-
"""`MCPServer.sse_app()` answers HEAD at once instead of streaming forever."""
97+
"""`MCPServer.sse_app()` answers HEAD at once with GET's headers and no body instead of streaming forever."""
9898
app = MCPServer("legacy").sse_app(
9999
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
100100
)
101101
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
102102
with anyio.fail_after(5):
103103
response = await http.head("/sse")
104-
assert (response.status_code, dict(response.headers)) == snapshot(
104+
assert (response.status_code, dict(response.headers), response.content) == snapshot(
105105
(
106106
200,
107107
{
108108
"cache-control": "no-store",
109109
"x-accel-buffering": "no",
110110
"content-type": "text/event-stream; charset=utf-8",
111111
},
112+
b"",
112113
)
113114
)
115+
116+
117+
@requirement("transport:sse:head")
118+
async def test_head_on_the_sse_endpoint_is_subject_to_dns_rebinding_protection() -> None:
119+
"""A HEAD from a Host the transport security settings reject gets the same refusal a GET would."""
120+
app = MCPServer("legacy").sse_app(host="127.0.0.1")
121+
async with httpx2.AsyncClient(transport=StreamingASGITransport(app), base_url="http://evil.example") as http:
122+
with anyio.fail_after(5):
123+
response = await http.head("/sse")
124+
assert response.status_code == snapshot(421)

0 commit comments

Comments
 (0)