49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
131 lines
5.9 KiB
Python
131 lines
5.9 KiB
Python
"""Tests for StreamableHTTP server DNS rebinding protection."""
|
|
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
|
|
import httpx
|
|
import pytest
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Mount
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
from tests.interaction.transports import StreamingASGITransport
|
|
|
|
SERVER_NAME = "test_streamable_http_security_server"
|
|
|
|
# The in-process app is mounted at this origin purely so URLs are well-formed and the default
|
|
# Host header is a localhost form; nothing listens here.
|
|
BASE_URL = "http://127.0.0.1:8000"
|
|
|
|
|
|
@asynccontextmanager
|
|
async def streamable_http_security_client(
|
|
security_settings: TransportSecuritySettings | None = None,
|
|
) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""Yield an httpx client served in process by a StreamableHTTP app with the given settings."""
|
|
session_manager = StreamableHTTPSessionManager(app=Server(SERVER_NAME), security_settings=security_settings)
|
|
app = Starlette(routes=[Mount("/", app=session_manager.handle_request)])
|
|
|
|
async with session_manager.run():
|
|
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as client:
|
|
yield client
|
|
|
|
|
|
def _base_headers() -> dict[str, str]:
|
|
"""Headers every well-formed request carries, so each test varies only the header under test."""
|
|
return {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
|
|
|
|
|
def _initialize_body() -> dict[str, object]:
|
|
"""A minimal initialize POST body; these tests assert header validation, not the handshake."""
|
|
return {"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}}
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_default_settings() -> None:
|
|
"""With default security settings, a request with localhost headers is served."""
|
|
async with streamable_http_security_client() as client:
|
|
response = await client.post("/", json=_initialize_body(), headers=_base_headers())
|
|
assert response.status_code == 200
|
|
assert "mcp-session-id" in response.headers
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_invalid_host_header() -> None:
|
|
"""A Host header outside allowed_hosts is rejected with 421."""
|
|
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True)
|
|
|
|
async with streamable_http_security_client(security_settings) as client:
|
|
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"})
|
|
assert response.status_code == 421
|
|
assert response.text == "Invalid Host header"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_invalid_origin_header() -> None:
|
|
"""An Origin header outside allowed_origins is rejected with 403."""
|
|
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"])
|
|
|
|
async with streamable_http_security_client(security_settings) as client:
|
|
response = await client.post(
|
|
"/", json=_initialize_body(), headers=_base_headers() | {"Origin": "http://evil.com"}
|
|
)
|
|
assert response.status_code == 403
|
|
assert response.text == "Invalid Origin header"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_invalid_content_type() -> None:
|
|
"""A POST whose Content-Type is not application/json (or is missing) is rejected with 400."""
|
|
async with streamable_http_security_client() as client:
|
|
response = await client.post("/", headers=_base_headers() | {"Content-Type": "text/plain"}, content="test")
|
|
assert response.status_code == 400
|
|
assert response.text == "Invalid Content-Type header"
|
|
|
|
response = await client.post("/", headers={"Accept": "application/json, text/event-stream"}, content="test")
|
|
assert response.status_code == 400
|
|
assert response.text == "Invalid Content-Type header"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_disabled() -> None:
|
|
"""With protection explicitly disabled, a disallowed Host is still served."""
|
|
settings = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
|
|
|
async with streamable_http_security_client(settings) as client:
|
|
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "evil.com"})
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_custom_allowed_hosts() -> None:
|
|
"""A custom entry in allowed_hosts is served."""
|
|
settings = TransportSecuritySettings(
|
|
enable_dns_rebinding_protection=True,
|
|
allowed_hosts=["localhost", "127.0.0.1", "custom.host"],
|
|
allowed_origins=["http://localhost", "http://127.0.0.1", "http://custom.host"],
|
|
)
|
|
|
|
async with streamable_http_security_client(settings) as client:
|
|
response = await client.post("/", json=_initialize_body(), headers=_base_headers() | {"Host": "custom.host"})
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_streamable_http_security_get_request() -> None:
|
|
"""GET requests pass the same Host validation before any session handling."""
|
|
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1"])
|
|
|
|
async with streamable_http_security_client(security_settings) as client:
|
|
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "evil.com"})
|
|
assert response.status_code == 421
|
|
assert response.text == "Invalid Host header"
|
|
|
|
response = await client.get("/", headers={"Accept": "text/event-stream", "Host": "127.0.0.1"})
|
|
# An allowed host passes security and fails on session validation instead.
|
|
assert response.status_code == 400
|
|
body = response.json()
|
|
assert "Missing session ID" in body["error"]["message"]
|