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
commit 86bb54cb28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 799 additions and 13 deletions

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

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()

View file

@ -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()

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:

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):