Add support for DNS rebinding protections (#861)

This commit is contained in:
David Dworken
2025-06-17 12:13:00 -07:00
committed by GitHub
parent a2f8766a8a
commit 86bb54cb28
10 changed files with 799 additions and 13 deletions
+6
View File
@@ -50,6 +50,7 @@ from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
from mcp.types import (
AnyFunction,
@@ -118,6 +119,9 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
auth: AuthSettings | None = None
# Transport security settings (DNS rebinding protection)
transport_security: TransportSecuritySettings | None = None
def lifespan_wrapper(
app: FastMCP,
@@ -674,6 +678,7 @@ class FastMCP:
sse = SseServerTransport(
normalized_message_endpoint,
security_settings=self.settings.transport_security,
)
async def handle_sse(scope: Scope, receive: Receive, send: Send):
@@ -779,6 +784,7 @@ class FastMCP:
event_store=self._event_store,
json_response=self.settings.json_response,
stateless=self.settings.stateless_http, # Use the stateless setting
security_settings=self.settings.transport_security,
)
# Create the ASGI handler
+23 -1
View File
@@ -52,6 +52,10 @@ from starlette.responses import Response
from starlette.types import Receive, Scope, Send
import mcp.types as types
from mcp.server.transport_security import (
TransportSecurityMiddleware,
TransportSecuritySettings,
)
from mcp.shared.message import ServerMessageMetadata, SessionMessage
logger = logging.getLogger(__name__)
@@ -71,16 +75,22 @@ class SseServerTransport:
_endpoint: str
_read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]]
_security: TransportSecurityMiddleware
def __init__(self, endpoint: str) -> None:
def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
"""
Creates a new SSE server transport, which will direct the client to POST
messages to the relative or absolute URL given.
Args:
endpoint: The relative or absolute URL for POST messages.
security_settings: Optional security settings for DNS rebinding protection.
"""
super().__init__()
self._endpoint = endpoint
self._read_stream_writers = {}
self._security = TransportSecurityMiddleware(security_settings)
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
@asynccontextmanager
@@ -89,6 +99,13 @@ class SseServerTransport:
logger.error("connect_sse received non-HTTP request")
raise ValueError("connect_sse can only handle HTTP requests")
# Validate request headers for DNS rebinding protection
request = Request(scope, receive)
error_response = await self._security.validate_request(request, is_post=False)
if error_response:
await error_response(scope, receive, send)
raise ValueError("Request validation failed")
logger.debug("Setting up SSE connection")
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
@@ -160,6 +177,11 @@ class SseServerTransport:
logger.debug("Handling POST message")
request = Request(scope, receive)
# Validate request headers for DNS rebinding protection
error_response = await self._security.validate_request(request, is_post=True)
if error_response:
return await error_response(scope, receive, send)
session_id_param = request.query_params.get("session_id")
if session_id_param is None:
logger.warning("Received request without session_id")
+16
View File
@@ -24,6 +24,10 @@ from starlette.requests import Request
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from mcp.server.transport_security import (
TransportSecurityMiddleware,
TransportSecuritySettings,
)
from mcp.shared.message import ServerMessageMetadata, SessionMessage
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import (
@@ -130,12 +134,14 @@ class StreamableHTTPServerTransport:
_read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None
_write_stream: MemoryObjectSendStream[SessionMessage] | None = None
_write_stream_reader: MemoryObjectReceiveStream[SessionMessage] | None = None
_security: TransportSecurityMiddleware
def __init__(
self,
mcp_session_id: str | None,
is_json_response_enabled: bool = False,
event_store: EventStore | None = None,
security_settings: TransportSecuritySettings | None = None,
) -> None:
"""
Initialize a new StreamableHTTP server transport.
@@ -148,6 +154,7 @@ class StreamableHTTPServerTransport:
event_store: Event store for resumability support. If provided,
resumability will be enabled, allowing clients to
reconnect and resume messages.
security_settings: Optional security settings for DNS rebinding protection.
Raises:
ValueError: If the session ID contains invalid characters.
@@ -158,6 +165,7 @@ class StreamableHTTPServerTransport:
self.mcp_session_id = mcp_session_id
self.is_json_response_enabled = is_json_response_enabled
self._event_store = event_store
self._security = TransportSecurityMiddleware(security_settings)
self._request_streams: dict[
RequestId,
tuple[
@@ -251,6 +259,14 @@ class StreamableHTTPServerTransport:
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
"""Application entry point that handles all HTTP requests"""
request = Request(scope, receive)
# Validate request headers for DNS rebinding protection
is_post = request.method == "POST"
error_response = await self._security.validate_request(request, is_post=is_post)
if error_response:
await error_response(scope, receive, send)
return
if self._terminated:
# If the session has been terminated, return 404 Not Found
response = self._create_error_response(
@@ -22,6 +22,7 @@ from mcp.server.streamable_http import (
EventStore,
StreamableHTTPServerTransport,
)
from mcp.server.transport_security import TransportSecuritySettings
logger = logging.getLogger(__name__)
@@ -60,11 +61,13 @@ class StreamableHTTPSessionManager:
event_store: EventStore | None = None,
json_response: bool = False,
stateless: bool = False,
security_settings: TransportSecuritySettings | None = None,
):
self.app = app
self.event_store = event_store
self.json_response = json_response
self.stateless = stateless
self.security_settings = security_settings
# Session tracking (only used if not stateless)
self._session_creation_lock = anyio.Lock()
@@ -162,6 +165,7 @@ class StreamableHTTPSessionManager:
mcp_session_id=None, # No session tracking in stateless mode
is_json_response_enabled=self.json_response,
event_store=None, # No event store in stateless mode
security_settings=self.security_settings,
)
# Start server in a new task
@@ -217,6 +221,7 @@ class StreamableHTTPSessionManager:
mcp_session_id=new_session_id,
is_json_response_enabled=self.json_response,
event_store=self.event_store, # May be None (no resumability)
security_settings=self.security_settings,
)
assert http_transport.mcp_session_id is not None
+127
View File
@@ -0,0 +1,127 @@
"""DNS rebinding protection for MCP server transports."""
import logging
from pydantic import BaseModel, Field
from starlette.requests import Request
from starlette.responses import Response
logger = logging.getLogger(__name__)
class TransportSecuritySettings(BaseModel):
"""Settings for MCP transport security features.
These settings help protect against DNS rebinding attacks by validating
incoming request headers.
"""
enable_dns_rebinding_protection: bool = Field(
default=True,
description="Enable DNS rebinding protection (recommended for production)",
)
allowed_hosts: list[str] = Field(
default=[],
description="List of allowed Host header values. Only applies when "
+ "enable_dns_rebinding_protection is True.",
)
allowed_origins: list[str] = Field(
default=[],
description="List of allowed Origin header values. Only applies when "
+ "enable_dns_rebinding_protection is True.",
)
class TransportSecurityMiddleware:
"""Middleware to enforce DNS rebinding protection for MCP transport endpoints."""
def __init__(self, settings: TransportSecuritySettings | None = None):
# If not specified, disable DNS rebinding protection by default
# for backwards compatibility
self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False)
def _validate_host(self, host: str | None) -> bool:
"""Validate the Host header against allowed values."""
if not host:
logger.warning("Missing Host header in request")
return False
# Check exact match first
if host in self.settings.allowed_hosts:
return True
# Check wildcard port patterns
for allowed in self.settings.allowed_hosts:
if allowed.endswith(":*"):
# Extract base host from pattern
base_host = allowed[:-2]
# Check if the actual host starts with base host and has a port
if host.startswith(base_host + ":"):
return True
logger.warning(f"Invalid Host header: {host}")
return False
def _validate_origin(self, origin: str | None) -> bool:
"""Validate the Origin header against allowed values."""
# Origin can be absent for same-origin requests
if not origin:
return True
# Check exact match first
if origin in self.settings.allowed_origins:
return True
# Check wildcard port patterns
for allowed in self.settings.allowed_origins:
if allowed.endswith(":*"):
# Extract base origin from pattern
base_origin = allowed[:-2]
# Check if the actual origin starts with base origin and has a port
if origin.startswith(base_origin + ":"):
return True
logger.warning(f"Invalid Origin header: {origin}")
return False
def _validate_content_type(self, content_type: str | None) -> bool:
"""Validate the Content-Type header for POST requests."""
if not content_type:
logger.warning("Missing Content-Type header in POST request")
return False
# Content-Type must start with application/json
if not content_type.lower().startswith("application/json"):
logger.warning(f"Invalid Content-Type header: {content_type}")
return False
return True
async def validate_request(self, request: Request, is_post: bool = False) -> Response | None:
"""Validate request headers for DNS rebinding protection.
Returns None if validation passes, or an error Response if validation fails.
"""
# Always validate Content-Type for POST requests
if is_post:
content_type = request.headers.get("content-type")
if not self._validate_content_type(content_type):
return Response("Invalid Content-Type header", status_code=400)
# Skip remaining validation if DNS rebinding protection is disabled
if not self.settings.enable_dns_rebinding_protection:
return None
# Validate Host header
host = request.headers.get("host")
if not self._validate_host(host):
return Response("Invalid Host header", status_code=421)
# Validate Origin header
origin = request.headers.get("origin")
if not self._validate_origin(origin):
return Response("Invalid Origin header", status_code=400)
return None
+17 -8
View File
@@ -23,6 +23,7 @@ from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.fastmcp.resources import FunctionResource
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import RequestContext
from mcp.types import (
Completion,
@@ -92,7 +93,10 @@ def stateless_http_server_url(stateless_http_server_port: int) -> str:
# Create a function to make the FastMCP server app
def make_fastmcp_app():
"""Create a FastMCP server without auth settings."""
mcp = FastMCP(name="NoAuthServer")
transport_security = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
mcp = FastMCP(name="NoAuthServer", transport_security=transport_security)
# Add a simple tool
@mcp.tool(description="A simple echo tool")
@@ -121,9 +125,10 @@ def make_fastmcp_app():
def make_everything_fastmcp() -> FastMCP:
"""Create a FastMCP server with all features enabled for testing."""
from mcp.server.fastmcp import Context
mcp = FastMCP(name="EverythingServer")
transport_security = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
mcp = FastMCP(name="EverythingServer", transport_security=transport_security)
# Tool with context for logging and progress
@mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool")
@@ -333,8 +338,10 @@ def make_everything_fastmcp_app():
def make_fastmcp_streamable_http_app():
"""Create a FastMCP server with StreamableHTTP transport."""
mcp = FastMCP(name="NoAuthServer")
transport_security = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
mcp = FastMCP(name="NoAuthServer", transport_security=transport_security)
# Add a simple tool
@mcp.tool(description="A simple echo tool")
@@ -359,8 +366,10 @@ def make_everything_fastmcp_streamable_http_app():
def make_fastmcp_stateless_http_app():
"""Create a FastMCP server with stateless StreamableHTTP transport."""
mcp = FastMCP(name="StatelessServer", stateless_http=True)
transport_security = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
mcp = FastMCP(name="StatelessServer", stateless_http=True, transport_security=transport_security)
# Add a simple tool
@mcp.tool(description="A simple echo tool")
+293
View File
@@ -0,0 +1,293 @@
"""Tests for SSE server DNS rebinding protection."""
import logging
import multiprocessing
import socket
import time
import httpx
import pytest
import uvicorn
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Mount, Route
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import Tool
logger = logging.getLogger(__name__)
SERVER_NAME = "test_sse_security_server"
@pytest.fixture
def server_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture
def server_url(server_port: int) -> str:
return f"http://127.0.0.1:{server_port}"
class SecurityTestServer(Server):
def __init__(self):
super().__init__(SERVER_NAME)
async def on_list_tools(self) -> list[Tool]:
return []
def run_server_with_settings(port: int, security_settings: TransportSecuritySettings | None = None):
"""Run the SSE server with specified security settings."""
app = SecurityTestServer()
sse_transport = SseServerTransport("/messages/", security_settings)
async def handle_sse(request: Request):
try:
async with sse_transport.connect_sse(request.scope, request.receive, request._send) as streams:
if streams:
await app.run(streams[0], streams[1], app.create_initialization_options())
except ValueError as e:
# Validation error was already handled inside connect_sse
logger.debug(f"SSE connection failed validation: {e}")
return Response()
routes = [
Route("/sse", endpoint=handle_sse),
Mount("/messages/", app=sse_transport.handle_post_message),
]
starlette_app = Starlette(routes=routes)
uvicorn.run(starlette_app, host="127.0.0.1", port=port, log_level="error")
def start_server_process(port: int, security_settings: TransportSecuritySettings | None = None):
"""Start server in a separate process."""
process = multiprocessing.Process(target=run_server_with_settings, args=(port, security_settings))
process.start()
# Give server time to start
time.sleep(1)
return process
@pytest.mark.anyio
async def test_sse_security_default_settings(server_port: int):
"""Test SSE with default security settings (protection disabled)."""
process = start_server_process(server_port)
try:
headers = {"Host": "evil.com", "Origin": "http://evil.com"}
async with httpx.AsyncClient(timeout=5.0) as client:
async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response:
assert response.status_code == 200
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_invalid_host_header(server_port: int):
"""Test SSE with invalid Host header."""
# Enable security by providing settings with an empty allowed_hosts list
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["example.com"])
process = start_server_process(server_port, security_settings)
try:
# Test with invalid host header
headers = {"Host": "evil.com"}
async with httpx.AsyncClient() as client:
response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers)
assert response.status_code == 421
assert response.text == "Invalid Host header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_invalid_origin_header(server_port: int):
"""Test SSE with invalid Origin header."""
# Configure security to allow the host but restrict origins
security_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://localhost:*"]
)
process = start_server_process(server_port, security_settings)
try:
# Test with invalid origin header
headers = {"Origin": "http://evil.com"}
async with httpx.AsyncClient() as client:
response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers)
assert response.status_code == 400
assert response.text == "Invalid Origin header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_post_invalid_content_type(server_port: int):
"""Test POST endpoint with invalid Content-Type header."""
# Configure security to allow the host
security_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"]
)
process = start_server_process(server_port, security_settings)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Test POST with invalid content type
fake_session_id = "12345678123456781234567812345678"
response = await client.post(
f"http://127.0.0.1:{server_port}/messages/?session_id={fake_session_id}",
headers={"Content-Type": "text/plain"},
content="test",
)
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
# Test POST with missing content type
response = await client.post(
f"http://127.0.0.1:{server_port}/messages/?session_id={fake_session_id}", content="test"
)
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_disabled(server_port: int):
"""Test SSE with security disabled."""
settings = TransportSecuritySettings(enable_dns_rebinding_protection=False)
process = start_server_process(server_port, settings)
try:
# Test with invalid host header - should still work
headers = {"Host": "evil.com"}
async with httpx.AsyncClient(timeout=5.0) as client:
# For SSE endpoints, we need to use stream to avoid timeout
async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response:
# Should connect successfully even with invalid host
assert response.status_code == 200
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_custom_allowed_hosts(server_port: int):
"""Test SSE with custom allowed hosts."""
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"],
)
process = start_server_process(server_port, settings)
try:
# Test with custom allowed host
headers = {"Host": "custom.host"}
async with httpx.AsyncClient(timeout=5.0) as client:
# For SSE endpoints, we need to use stream to avoid timeout
async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response:
# Should connect successfully with custom host
assert response.status_code == 200
# Test with non-allowed host
headers = {"Host": "evil.com"}
async with httpx.AsyncClient() as client:
response = await client.get(f"http://127.0.0.1:{server_port}/sse", headers=headers)
assert response.status_code == 421
assert response.text == "Invalid Host header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_wildcard_ports(server_port: int):
"""Test SSE with wildcard port patterns."""
settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["localhost:*", "127.0.0.1:*"],
allowed_origins=["http://localhost:*", "http://127.0.0.1:*"],
)
process = start_server_process(server_port, settings)
try:
# Test with various port numbers
for test_port in [8080, 3000, 9999]:
headers = {"Host": f"localhost:{test_port}"}
async with httpx.AsyncClient(timeout=5.0) as client:
# For SSE endpoints, we need to use stream to avoid timeout
async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response:
# Should connect successfully with any port
assert response.status_code == 200
headers = {"Origin": f"http://localhost:{test_port}"}
async with httpx.AsyncClient(timeout=5.0) as client:
# For SSE endpoints, we need to use stream to avoid timeout
async with client.stream("GET", f"http://127.0.0.1:{server_port}/sse", headers=headers) as response:
# Should connect successfully with any port
assert response.status_code == 200
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_sse_security_post_valid_content_type(server_port: int):
"""Test POST endpoint with valid Content-Type headers."""
# Configure security to allow the host
security_settings = TransportSecuritySettings(
enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"], allowed_origins=["http://127.0.0.1:*"]
)
process = start_server_process(server_port, security_settings)
try:
async with httpx.AsyncClient() as client:
# Test with various valid content types
valid_content_types = [
"application/json",
"application/json; charset=utf-8",
"application/json;charset=utf-8",
"APPLICATION/JSON", # Case insensitive
]
for content_type in valid_content_types:
# Use a valid UUID format (even though session won't exist)
fake_session_id = "12345678123456781234567812345678"
response = await client.post(
f"http://127.0.0.1:{server_port}/messages/?session_id={fake_session_id}",
headers={"Content-Type": content_type},
json={"test": "data"},
)
# Will get 404 because session doesn't exist, but that's OK
# We're testing that it passes the content-type check
assert response.status_code == 404
assert response.text == "Could not find session"
finally:
process.terminate()
process.join()
@@ -0,0 +1,293 @@
"""Tests for StreamableHTTP server DNS rebinding protection."""
import logging
import multiprocessing
import socket
import time
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import httpx
import pytest
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.types import Receive, Scope, Send
from mcp.server import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.types import Tool
logger = logging.getLogger(__name__)
SERVER_NAME = "test_streamable_http_security_server"
@pytest.fixture
def server_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture
def server_url(server_port: int) -> str:
return f"http://127.0.0.1:{server_port}"
class SecurityTestServer(Server):
def __init__(self):
super().__init__(SERVER_NAME)
async def on_list_tools(self) -> list[Tool]:
return []
def run_server_with_settings(port: int, security_settings: TransportSecuritySettings | None = None):
"""Run the StreamableHTTP server with specified security settings."""
app = SecurityTestServer()
# Create session manager with security settings
session_manager = StreamableHTTPSessionManager(
app=app,
json_response=False,
stateless=False,
security_settings=security_settings,
)
# Create the ASGI handler
async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
await session_manager.handle_request(scope, receive, send)
# Create Starlette app with lifespan
@asynccontextmanager
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
async with session_manager.run():
yield
routes = [
Mount("/", app=handle_streamable_http),
]
starlette_app = Starlette(routes=routes, lifespan=lifespan)
uvicorn.run(starlette_app, host="127.0.0.1", port=port, log_level="error")
def start_server_process(port: int, security_settings: TransportSecuritySettings | None = None):
"""Start server in a separate process."""
process = multiprocessing.Process(target=run_server_with_settings, args=(port, security_settings))
process.start()
# Give server time to start
time.sleep(1)
return process
@pytest.mark.anyio
async def test_streamable_http_security_default_settings(server_port: int):
"""Test StreamableHTTP with default security settings (protection enabled)."""
process = start_server_process(server_port)
try:
# Test with valid localhost headers
async with httpx.AsyncClient(timeout=5.0) as client:
# POST request to initialize session
response = await client.post(
f"http://127.0.0.1:{server_port}/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers={
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
},
)
assert response.status_code == 200
assert "mcp-session-id" in response.headers
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_streamable_http_security_invalid_host_header(server_port: int):
"""Test StreamableHTTP with invalid Host header."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True)
process = start_server_process(server_port, security_settings)
try:
# Test with invalid host header
headers = {
"Host": "evil.com",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"http://127.0.0.1:{server_port}/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers=headers,
)
assert response.status_code == 421
assert response.text == "Invalid Host header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_streamable_http_security_invalid_origin_header(server_port: int):
"""Test StreamableHTTP with invalid Origin header."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1:*"])
process = start_server_process(server_port, security_settings)
try:
# Test with invalid origin header
headers = {
"Origin": "http://evil.com",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"http://127.0.0.1:{server_port}/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers=headers,
)
assert response.status_code == 400
assert response.text == "Invalid Origin header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_streamable_http_security_invalid_content_type(server_port: int):
"""Test StreamableHTTP POST with invalid Content-Type header."""
process = start_server_process(server_port)
try:
async with httpx.AsyncClient(timeout=5.0) as client:
# Test POST with invalid content type
response = await client.post(
f"http://127.0.0.1:{server_port}/",
headers={
"Content-Type": "text/plain",
"Accept": "application/json, text/event-stream",
},
content="test",
)
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
# Test POST with missing content type
response = await client.post(
f"http://127.0.0.1:{server_port}/",
headers={"Accept": "application/json, text/event-stream"},
content="test",
)
assert response.status_code == 400
assert response.text == "Invalid Content-Type header"
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_streamable_http_security_disabled(server_port: int):
"""Test StreamableHTTP with security disabled."""
settings = TransportSecuritySettings(enable_dns_rebinding_protection=False)
process = start_server_process(server_port, settings)
try:
# Test with invalid host header - should still work
headers = {
"Host": "evil.com",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"http://127.0.0.1:{server_port}/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers=headers,
)
# Should connect successfully even with invalid host
assert response.status_code == 200
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_streamable_http_security_custom_allowed_hosts(server_port: int):
"""Test StreamableHTTP with custom allowed hosts."""
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"],
)
process = start_server_process(server_port, settings)
try:
# Test with custom allowed host
headers = {
"Host": "custom.host",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.post(
f"http://127.0.0.1:{server_port}/",
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
headers=headers,
)
# Should connect successfully with custom host
assert response.status_code == 200
finally:
process.terminate()
process.join()
@pytest.mark.anyio
async def test_streamable_http_security_get_request(server_port: int):
"""Test StreamableHTTP GET request with security."""
security_settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["127.0.0.1"])
process = start_server_process(server_port, security_settings)
try:
# Test GET request with invalid host header
headers = {
"Host": "evil.com",
"Accept": "text/event-stream",
}
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers)
assert response.status_code == 421
assert response.text == "Invalid Host header"
# Test GET request with valid host header
headers = {
"Host": "127.0.0.1",
"Accept": "text/event-stream",
}
async with httpx.AsyncClient(timeout=5.0) as client:
# GET requests need a session ID in StreamableHTTP
# So it will fail with "Missing session ID" not security error
response = await client.get(f"http://127.0.0.1:{server_port}/", headers=headers)
# This should pass security but fail on session validation
assert response.status_code == 400
body = response.json()
assert "Missing session ID" in body["error"]["message"]
finally:
process.terminate()
process.join()
+11 -2
View File
@@ -20,6 +20,7 @@ from mcp.client.session import ClientSession
from mcp.client.sse import sse_client
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import McpError
from mcp.types import (
EmptyResult,
@@ -80,7 +81,11 @@ class ServerTest(Server):
# Test fixtures
def make_server_app() -> Starlette:
"""Create test Starlette app with SSE transport"""
sse = SseServerTransport("/messages/")
# Configure security with allowed hosts/origins for testing
security_settings = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
sse = SseServerTransport("/messages/", security_settings=security_settings)
server = ServerTest()
async def handle_sse(request: Request) -> Response:
@@ -339,7 +344,11 @@ class RequestContextServer(Server[object, Request]):
def run_context_server(server_port: int) -> None:
"""Run a server that captures request context"""
sse = SseServerTransport("/messages/")
# Configure security with allowed hosts/origins for testing
security_settings = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
sse = SseServerTransport("/messages/", security_settings=security_settings)
context_server = RequestContextServer()
async def handle_sse(request: Request) -> Response:
+8 -2
View File
@@ -37,6 +37,7 @@ from mcp.server.streamable_http import (
StreamId,
)
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.context import RequestContext
from mcp.shared.exceptions import McpError
from mcp.shared.message import (
@@ -227,10 +228,14 @@ def create_app(is_json_response_enabled=False, event_store: EventStore | None =
server = ServerTest()
# Create the session manager
security_settings = TransportSecuritySettings(
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
)
session_manager = StreamableHTTPSessionManager(
app=server,
event_store=event_store,
json_response=is_json_response_enabled,
security_settings=security_settings,
)
# Create an ASGI application that uses the session manager
@@ -436,8 +441,9 @@ def test_content_type_validation(basic_server, basic_server_url):
},
data="This is not JSON",
)
assert response.status_code == 415
assert "Unsupported Media Type" in response.text
assert response.status_code == 400
assert "Invalid Content-Type" in response.text
def test_json_validation(basic_server, basic_server_url):