205 lines
8.3 KiB
Python
205 lines
8.3 KiB
Python
"""Tests for the request checks shared by the HTTP server transports."""
|
|
|
|
from collections.abc import Iterator
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from starlette.requests import Request
|
|
from starlette.types import Message, Receive, Scope, Send
|
|
|
|
from mcp.server.transport_security import (
|
|
RequestBodyLimitMiddleware,
|
|
TransportSecurityMiddleware,
|
|
TransportSecuritySettings,
|
|
)
|
|
|
|
|
|
def _request(host: str | None, origin: str | None, content_type: str | None = "application/json") -> Request:
|
|
headers: list[tuple[bytes, bytes]] = []
|
|
if content_type is not None:
|
|
headers.append((b"content-type", content_type.encode()))
|
|
if host is not None:
|
|
headers.append((b"host", host.encode()))
|
|
if origin is not None:
|
|
headers.append((b"origin", origin.encode()))
|
|
return Request({"type": "http", "method": "GET", "headers": headers})
|
|
|
|
|
|
SETTINGS = TransportSecuritySettings(
|
|
enable_dns_rebinding_protection=True,
|
|
allowed_hosts=["good.example", "wild.example:*"],
|
|
allowed_origins=["http://good.example", "http://wild.example:*"],
|
|
)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
("host", "origin", "expected"),
|
|
[
|
|
pytest.param(None, None, 421, id="missing-host"),
|
|
pytest.param("evil.example", None, 421, id="host-no-match"),
|
|
pytest.param("evil.example:9000", None, 421, id="host-wildcard-base-mismatch"),
|
|
pytest.param("good.example", None, None, id="host-exact-no-origin"),
|
|
pytest.param("wild.example:9000", None, None, id="host-wildcard-match"),
|
|
pytest.param("good.example", "http://evil.example", 403, id="origin-no-match"),
|
|
pytest.param("good.example", "http://evil.example:9000", 403, id="origin-wildcard-base-mismatch"),
|
|
pytest.param("good.example", "http://good.example", None, id="origin-exact"),
|
|
pytest.param("good.example", "http://wild.example:9000", None, id="origin-wildcard-match"),
|
|
],
|
|
)
|
|
async def test_validate_request_checks_host_then_origin(
|
|
host: str | None, origin: str | None, expected: int | None
|
|
) -> None:
|
|
"""Host is checked first, then Origin; exact and wildcard-port allowlist entries are honoured."""
|
|
middleware = TransportSecurityMiddleware(SETTINGS)
|
|
response = await middleware.validate_request(_request(host, origin))
|
|
assert (None if response is None else response.status_code) == expected
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_validate_request_skips_host_and_origin_when_protection_is_disabled() -> None:
|
|
"""With DNS-rebinding protection off, any Host/Origin is accepted."""
|
|
middleware = TransportSecurityMiddleware(TransportSecuritySettings(enable_dns_rebinding_protection=False))
|
|
assert await middleware.validate_request(_request("evil.example", "http://evil.example")) is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_validate_request_defaults_to_protection_disabled() -> None:
|
|
"""Constructing the middleware without settings leaves DNS-rebinding protection off."""
|
|
middleware = TransportSecurityMiddleware()
|
|
assert await middleware.validate_request(_request("evil.example", "http://evil.example")) is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
("content_type", "expected"),
|
|
[
|
|
pytest.param("application/json", None, id="json"),
|
|
pytest.param("application/json; charset=utf-8", None, id="json-with-charset"),
|
|
pytest.param("APPLICATION/JSON", None, id="case-insensitive"),
|
|
pytest.param("text/plain", 400, id="wrong-type"),
|
|
pytest.param(None, 400, id="missing"),
|
|
],
|
|
)
|
|
async def test_validate_request_checks_content_type_on_post(content_type: str | None, expected: int | None) -> None:
|
|
"""POST requests must carry an application/json Content-Type, regardless of DNS-rebinding settings."""
|
|
middleware = TransportSecurityMiddleware()
|
|
response = await middleware.validate_request(_request("any", None, content_type=content_type), is_post=True)
|
|
assert (None if response is None else response.status_code) == expected
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_validate_request_ignores_content_type_on_get() -> None:
|
|
"""Content-Type is only enforced for POST requests."""
|
|
middleware = TransportSecurityMiddleware(SETTINGS)
|
|
response = await middleware.validate_request(_request("good.example", None, content_type="text/plain"))
|
|
assert response is None
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_client_disconnect_while_streaming_request_body_is_replayed() -> None:
|
|
"""SDK-defined: raw ASGI is required to prove a disconnect before body completion reaches the transport."""
|
|
disconnect: Message = {"type": "http.disconnect"}
|
|
request_messages: Iterator[Message] = iter(
|
|
[{"type": "http.request", "body": b"1234", "more_body": True}, disconnect]
|
|
)
|
|
received_messages: list[Message] = []
|
|
|
|
async def receive() -> Message:
|
|
return next(request_messages)
|
|
|
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
received_messages.append(await receive())
|
|
received_messages.append(await receive())
|
|
|
|
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
|
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
|
|
|
|
await middleware(scope, receive, AsyncMock())
|
|
|
|
assert received_messages == [
|
|
{"type": "http.request", "body": b"1234", "more_body": True},
|
|
disconnect,
|
|
]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_client_disconnect_before_request_body_is_replayed() -> None:
|
|
"""SDK-defined: raw ASGI proves a disconnect before the first body message reaches the transport."""
|
|
disconnect: Message = {"type": "http.disconnect"}
|
|
received_messages: list[Message] = []
|
|
|
|
async def receive() -> Message:
|
|
return disconnect
|
|
|
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
received_messages.append(await receive())
|
|
|
|
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
|
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
|
|
|
|
await middleware(scope, receive, AsyncMock())
|
|
|
|
assert received_messages == [disconnect]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_request_body_chunks_are_replayed_as_one_message() -> None:
|
|
"""SDK-defined: raw ASGI proves chunk overhead is discarded before the body reaches the transport."""
|
|
request_messages: Iterator[Message] = iter(
|
|
[
|
|
{"type": "http.request", "body": b"12", "more_body": True},
|
|
{"type": "http.request", "body": b"34", "more_body": True},
|
|
{"type": "http.request", "body": b"56", "more_body": False},
|
|
]
|
|
)
|
|
received_messages: list[Message] = []
|
|
|
|
async def receive() -> Message:
|
|
return next(request_messages)
|
|
|
|
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
received_messages.append(await receive())
|
|
|
|
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
|
|
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
|
|
|
|
await middleware(scope, receive, AsyncMock())
|
|
|
|
assert received_messages == [{"type": "http.request", "body": b"123456", "more_body": False}]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("method", ["GET", "PUT", "OPTIONS", "HEAD", "DELETE"])
|
|
async def test_request_body_limit_applies_to_every_method(method: str) -> None:
|
|
"""SDK-defined: the limit is a property of the request body, not of the method that carries it."""
|
|
app = AsyncMock()
|
|
sent_messages: list[Message] = []
|
|
receive = AsyncMock(return_value={"type": "http.request", "body": b"123456789", "more_body": False})
|
|
|
|
async def send(message: Message) -> None:
|
|
sent_messages.append(message)
|
|
|
|
scope: Scope = {"type": "http", "method": method, "path": "/mcp", "headers": []}
|
|
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
|
|
|
|
await middleware(scope, receive, send)
|
|
|
|
assert [message["status"] for message in sent_messages if message["type"] == "http.response.start"] == [413]
|
|
app.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_request_body_limit_leaves_non_http_scopes_alone() -> None:
|
|
"""SDK-defined: only HTTP requests carry a body to limit; other ASGI scopes go straight to the app."""
|
|
app = AsyncMock()
|
|
receive = AsyncMock()
|
|
send = AsyncMock()
|
|
scope: Scope = {"type": "lifespan"}
|
|
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
|
|
|
|
await middleware(scope, receive, send)
|
|
|
|
app.assert_awaited_once_with(scope, receive, send)
|
|
receive.assert_not_awaited()
|