Skip to content

Commit dbadb12

Browse files
committed
test: cover the pre-session security check + fix pyright annotation
- Add ``test_bad_host_header_rejected_before_session_allocation`` that drives the DNS-rebinding branch of the manager and asserts the request is rejected with 421 without allocating a session. Restores the strict 100% coverage on ``streamable_http_manager.py``. - Suppress the pyright ``reportUnknownMemberType`` on ``manager._task_group._tasks`` — that attribute is private anyio internals with no exported type, but we deliberately introspect it to prove the leaked task from the original bug is no longer spawned.
1 parent 0f12dae commit dbadb12

1 file changed

Lines changed: 59 additions & 3 deletions

File tree

tests/server/test_streamable_http_manager.py

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mcp.server.lowlevel import Server
1515
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
1616
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
17+
from mcp.server.transport_security import TransportSecuritySettings
1718
from mcp.types import INVALID_REQUEST
1819

1920

@@ -435,9 +436,8 @@ async def mock_receive(): # pragma: no cover
435436
)
436437
assert manager._task_group is not None
437438
# anyio TaskGroup internals: no live tasks belonging to run_server
438-
assert len(manager._task_group._tasks) == 0, (
439-
f"{method} without session-id must not spawn a background task — leaked {len(manager._task_group._tasks)}"
440-
)
439+
live_tasks = len(manager._task_group._tasks) # type: ignore[attr-defined]
440+
assert live_tasks == 0, f"{method} without session-id must not spawn a background task — leaked {live_tasks}"
441441

442442
# Response should be a well-formed JSON-RPC error at status 400
443443
response_start = next(
@@ -453,6 +453,62 @@ async def mock_receive(): # pragma: no cover
453453
assert "Missing session ID" in error_data["error"]["message"]
454454

455455

456+
@pytest.mark.anyio
457+
async def test_bad_host_header_rejected_before_session_allocation():
458+
"""Security check runs before session allocation.
459+
460+
With DNS-rebinding protection enabled, a request that presents a
461+
Host header not in the allow-list must be rejected with 421 without
462+
allocating a session. Previously this check lived only in the
463+
transport, so a bad-Host request would allocate a session first and
464+
then get rejected — the allocated session and its task were leaked.
465+
"""
466+
app = Server("test-bad-host")
467+
manager = StreamableHTTPSessionManager(
468+
app=app,
469+
security_settings=TransportSecuritySettings(
470+
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1"]
471+
),
472+
)
473+
474+
async with manager.run():
475+
sent_messages: list[Message] = []
476+
477+
async def mock_send(message: Message):
478+
sent_messages.append(message)
479+
480+
scope: Scope = {
481+
"type": "http",
482+
"method": "POST",
483+
"path": "/mcp",
484+
"headers": [
485+
(b"host", b"evil.com"),
486+
(b"content-type", b"application/json"),
487+
(b"accept", b"application/json, text/event-stream"),
488+
],
489+
}
490+
491+
async def mock_receive(): # pragma: no cover
492+
return {"type": "http.request", "body": b"{}", "more_body": False}
493+
494+
assert len(manager._server_instances) == 0
495+
496+
await manager.handle_request(scope, mock_receive, mock_send)
497+
498+
# Session must NOT have been allocated
499+
assert len(manager._server_instances) == 0, (
500+
"Bad-Host request must not allocate a session (was rejected by security check)"
501+
)
502+
503+
# And the response must be the 421 the middleware produced
504+
response_start = next(
505+
(msg for msg in sent_messages if msg["type"] == "http.response.start"),
506+
None,
507+
)
508+
assert response_start is not None
509+
assert response_start["status"] == 421
510+
511+
456512
def test_session_idle_timeout_rejects_non_positive():
457513
with pytest.raises(ValueError, match="positive number"):
458514
StreamableHTTPSessionManager(app=Server("test"), session_idle_timeout=-1)

0 commit comments

Comments
 (0)