Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

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"]