Add support for DNS rebinding protections (#861)
This commit is contained in:
parent
a2f8766a8a
commit
86bb54cb28
10 changed files with 799 additions and 13 deletions
|
|
@ -50,6 +50,7 @@ from mcp.server.sse import SseServerTransport
|
||||||
from mcp.server.stdio import stdio_server
|
from mcp.server.stdio import stdio_server
|
||||||
from mcp.server.streamable_http import EventStore
|
from mcp.server.streamable_http import EventStore
|
||||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
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.shared.context import LifespanContextT, RequestContext, RequestT
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
AnyFunction,
|
AnyFunction,
|
||||||
|
|
@ -118,6 +119,9 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
|
||||||
|
|
||||||
auth: AuthSettings | None = None
|
auth: AuthSettings | None = None
|
||||||
|
|
||||||
|
# Transport security settings (DNS rebinding protection)
|
||||||
|
transport_security: TransportSecuritySettings | None = None
|
||||||
|
|
||||||
|
|
||||||
def lifespan_wrapper(
|
def lifespan_wrapper(
|
||||||
app: FastMCP,
|
app: FastMCP,
|
||||||
|
|
@ -674,6 +678,7 @@ class FastMCP:
|
||||||
|
|
||||||
sse = SseServerTransport(
|
sse = SseServerTransport(
|
||||||
normalized_message_endpoint,
|
normalized_message_endpoint,
|
||||||
|
security_settings=self.settings.transport_security,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_sse(scope: Scope, receive: Receive, send: Send):
|
async def handle_sse(scope: Scope, receive: Receive, send: Send):
|
||||||
|
|
@ -779,6 +784,7 @@ class FastMCP:
|
||||||
event_store=self._event_store,
|
event_store=self._event_store,
|
||||||
json_response=self.settings.json_response,
|
json_response=self.settings.json_response,
|
||||||
stateless=self.settings.stateless_http, # Use the stateless setting
|
stateless=self.settings.stateless_http, # Use the stateless setting
|
||||||
|
security_settings=self.settings.transport_security,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the ASGI handler
|
# Create the ASGI handler
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,10 @@ from starlette.responses import Response
|
||||||
from starlette.types import Receive, Scope, Send
|
from starlette.types import Receive, Scope, Send
|
||||||
|
|
||||||
import mcp.types as types
|
import mcp.types as types
|
||||||
|
from mcp.server.transport_security import (
|
||||||
|
TransportSecurityMiddleware,
|
||||||
|
TransportSecuritySettings,
|
||||||
|
)
|
||||||
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
from mcp.shared.message import ServerMessageMetadata, SessionMessage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -71,16 +75,22 @@ class SseServerTransport:
|
||||||
|
|
||||||
_endpoint: str
|
_endpoint: str
|
||||||
_read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]]
|
_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
|
Creates a new SSE server transport, which will direct the client to POST
|
||||||
messages to the relative or absolute URL given.
|
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__()
|
super().__init__()
|
||||||
self._endpoint = endpoint
|
self._endpoint = endpoint
|
||||||
self._read_stream_writers = {}
|
self._read_stream_writers = {}
|
||||||
|
self._security = TransportSecurityMiddleware(security_settings)
|
||||||
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
|
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
|
|
@ -89,6 +99,13 @@ class SseServerTransport:
|
||||||
logger.error("connect_sse received non-HTTP request")
|
logger.error("connect_sse received non-HTTP request")
|
||||||
raise ValueError("connect_sse can only handle HTTP requests")
|
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")
|
logger.debug("Setting up SSE connection")
|
||||||
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
|
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception]
|
||||||
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
|
read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception]
|
||||||
|
|
@ -160,6 +177,11 @@ class SseServerTransport:
|
||||||
logger.debug("Handling POST message")
|
logger.debug("Handling POST message")
|
||||||
request = Request(scope, receive)
|
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")
|
session_id_param = request.query_params.get("session_id")
|
||||||
if session_id_param is None:
|
if session_id_param is None:
|
||||||
logger.warning("Received request without session_id")
|
logger.warning("Received request without session_id")
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ from starlette.requests import Request
|
||||||
from starlette.responses import Response
|
from starlette.responses import Response
|
||||||
from starlette.types import Receive, Scope, Send
|
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.message import ServerMessageMetadata, SessionMessage
|
||||||
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
|
@ -130,12 +134,14 @@ class StreamableHTTPServerTransport:
|
||||||
_read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None
|
_read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] | None = None
|
||||||
_write_stream: MemoryObjectSendStream[SessionMessage] | None = None
|
_write_stream: MemoryObjectSendStream[SessionMessage] | None = None
|
||||||
_write_stream_reader: MemoryObjectReceiveStream[SessionMessage] | None = None
|
_write_stream_reader: MemoryObjectReceiveStream[SessionMessage] | None = None
|
||||||
|
_security: TransportSecurityMiddleware
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
mcp_session_id: str | None,
|
mcp_session_id: str | None,
|
||||||
is_json_response_enabled: bool = False,
|
is_json_response_enabled: bool = False,
|
||||||
event_store: EventStore | None = None,
|
event_store: EventStore | None = None,
|
||||||
|
security_settings: TransportSecuritySettings | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize a new StreamableHTTP server transport.
|
Initialize a new StreamableHTTP server transport.
|
||||||
|
|
@ -148,6 +154,7 @@ class StreamableHTTPServerTransport:
|
||||||
event_store: Event store for resumability support. If provided,
|
event_store: Event store for resumability support. If provided,
|
||||||
resumability will be enabled, allowing clients to
|
resumability will be enabled, allowing clients to
|
||||||
reconnect and resume messages.
|
reconnect and resume messages.
|
||||||
|
security_settings: Optional security settings for DNS rebinding protection.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If the session ID contains invalid characters.
|
ValueError: If the session ID contains invalid characters.
|
||||||
|
|
@ -158,6 +165,7 @@ class StreamableHTTPServerTransport:
|
||||||
self.mcp_session_id = mcp_session_id
|
self.mcp_session_id = mcp_session_id
|
||||||
self.is_json_response_enabled = is_json_response_enabled
|
self.is_json_response_enabled = is_json_response_enabled
|
||||||
self._event_store = event_store
|
self._event_store = event_store
|
||||||
|
self._security = TransportSecurityMiddleware(security_settings)
|
||||||
self._request_streams: dict[
|
self._request_streams: dict[
|
||||||
RequestId,
|
RequestId,
|
||||||
tuple[
|
tuple[
|
||||||
|
|
@ -251,6 +259,14 @@ class StreamableHTTPServerTransport:
|
||||||
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
|
async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
"""Application entry point that handles all HTTP requests"""
|
"""Application entry point that handles all HTTP requests"""
|
||||||
request = Request(scope, receive)
|
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 self._terminated:
|
||||||
# If the session has been terminated, return 404 Not Found
|
# If the session has been terminated, return 404 Not Found
|
||||||
response = self._create_error_response(
|
response = self._create_error_response(
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from mcp.server.streamable_http import (
|
||||||
EventStore,
|
EventStore,
|
||||||
StreamableHTTPServerTransport,
|
StreamableHTTPServerTransport,
|
||||||
)
|
)
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -60,11 +61,13 @@ class StreamableHTTPSessionManager:
|
||||||
event_store: EventStore | None = None,
|
event_store: EventStore | None = None,
|
||||||
json_response: bool = False,
|
json_response: bool = False,
|
||||||
stateless: bool = False,
|
stateless: bool = False,
|
||||||
|
security_settings: TransportSecuritySettings | None = None,
|
||||||
):
|
):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.event_store = event_store
|
self.event_store = event_store
|
||||||
self.json_response = json_response
|
self.json_response = json_response
|
||||||
self.stateless = stateless
|
self.stateless = stateless
|
||||||
|
self.security_settings = security_settings
|
||||||
|
|
||||||
# Session tracking (only used if not stateless)
|
# Session tracking (only used if not stateless)
|
||||||
self._session_creation_lock = anyio.Lock()
|
self._session_creation_lock = anyio.Lock()
|
||||||
|
|
@ -162,6 +165,7 @@ class StreamableHTTPSessionManager:
|
||||||
mcp_session_id=None, # No session tracking in stateless mode
|
mcp_session_id=None, # No session tracking in stateless mode
|
||||||
is_json_response_enabled=self.json_response,
|
is_json_response_enabled=self.json_response,
|
||||||
event_store=None, # No event store in stateless mode
|
event_store=None, # No event store in stateless mode
|
||||||
|
security_settings=self.security_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start server in a new task
|
# Start server in a new task
|
||||||
|
|
@ -217,6 +221,7 @@ class StreamableHTTPSessionManager:
|
||||||
mcp_session_id=new_session_id,
|
mcp_session_id=new_session_id,
|
||||||
is_json_response_enabled=self.json_response,
|
is_json_response_enabled=self.json_response,
|
||||||
event_store=self.event_store, # May be None (no resumability)
|
event_store=self.event_store, # May be None (no resumability)
|
||||||
|
security_settings=self.security_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert http_transport.mcp_session_id is not None
|
assert http_transport.mcp_session_id is not None
|
||||||
|
|
|
||||||
127
src/mcp/server/transport_security.py
Normal file
127
src/mcp/server/transport_security.py
Normal 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
|
||||||
|
|
@ -23,6 +23,7 @@ from mcp.client.sse import sse_client
|
||||||
from mcp.client.streamable_http import streamablehttp_client
|
from mcp.client.streamable_http import streamablehttp_client
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.server.fastmcp.resources import FunctionResource
|
from mcp.server.fastmcp.resources import FunctionResource
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from mcp.shared.context import RequestContext
|
from mcp.shared.context import RequestContext
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
Completion,
|
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
|
# Create a function to make the FastMCP server app
|
||||||
def make_fastmcp_app():
|
def make_fastmcp_app():
|
||||||
"""Create a FastMCP server without auth settings."""
|
"""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
|
# Add a simple tool
|
||||||
@mcp.tool(description="A simple echo tool")
|
@mcp.tool(description="A simple echo tool")
|
||||||
|
|
@ -121,9 +125,10 @@ def make_fastmcp_app():
|
||||||
|
|
||||||
def make_everything_fastmcp() -> FastMCP:
|
def make_everything_fastmcp() -> FastMCP:
|
||||||
"""Create a FastMCP server with all features enabled for testing."""
|
"""Create a FastMCP server with all features enabled for testing."""
|
||||||
from mcp.server.fastmcp import Context
|
transport_security = TransportSecuritySettings(
|
||||||
|
allowed_hosts=["127.0.0.1:*", "localhost:*"], allowed_origins=["http://127.0.0.1:*", "http://localhost:*"]
|
||||||
mcp = FastMCP(name="EverythingServer")
|
)
|
||||||
|
mcp = FastMCP(name="EverythingServer", transport_security=transport_security)
|
||||||
|
|
||||||
# Tool with context for logging and progress
|
# Tool with context for logging and progress
|
||||||
@mcp.tool(description="A tool that demonstrates logging and progress", title="Progress Tool")
|
@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():
|
def make_fastmcp_streamable_http_app():
|
||||||
"""Create a FastMCP server with StreamableHTTP transport."""
|
"""Create a FastMCP server with StreamableHTTP transport."""
|
||||||
|
transport_security = TransportSecuritySettings(
|
||||||
mcp = FastMCP(name="NoAuthServer")
|
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
|
# Add a simple tool
|
||||||
@mcp.tool(description="A simple echo 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():
|
def make_fastmcp_stateless_http_app():
|
||||||
"""Create a FastMCP server with stateless StreamableHTTP transport."""
|
"""Create a FastMCP server with stateless StreamableHTTP transport."""
|
||||||
|
transport_security = TransportSecuritySettings(
|
||||||
mcp = FastMCP(name="StatelessServer", stateless_http=True)
|
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
|
# Add a simple tool
|
||||||
@mcp.tool(description="A simple echo tool")
|
@mcp.tool(description="A simple echo tool")
|
||||||
|
|
|
||||||
293
tests/server/test_sse_security.py
Normal file
293
tests/server/test_sse_security.py
Normal 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()
|
||||||
293
tests/server/test_streamable_http_security.py
Normal file
293
tests/server/test_streamable_http_security.py
Normal 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()
|
||||||
|
|
@ -20,6 +20,7 @@ from mcp.client.session import ClientSession
|
||||||
from mcp.client.sse import sse_client
|
from mcp.client.sse import sse_client
|
||||||
from mcp.server import Server
|
from mcp.server import Server
|
||||||
from mcp.server.sse import SseServerTransport
|
from mcp.server.sse import SseServerTransport
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
EmptyResult,
|
EmptyResult,
|
||||||
|
|
@ -80,7 +81,11 @@ class ServerTest(Server):
|
||||||
# Test fixtures
|
# Test fixtures
|
||||||
def make_server_app() -> Starlette:
|
def make_server_app() -> Starlette:
|
||||||
"""Create test Starlette app with SSE transport"""
|
"""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()
|
server = ServerTest()
|
||||||
|
|
||||||
async def handle_sse(request: Request) -> Response:
|
async def handle_sse(request: Request) -> Response:
|
||||||
|
|
@ -339,7 +344,11 @@ class RequestContextServer(Server[object, Request]):
|
||||||
|
|
||||||
def run_context_server(server_port: int) -> None:
|
def run_context_server(server_port: int) -> None:
|
||||||
"""Run a server that captures request context"""
|
"""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()
|
context_server = RequestContextServer()
|
||||||
|
|
||||||
async def handle_sse(request: Request) -> Response:
|
async def handle_sse(request: Request) -> Response:
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ from mcp.server.streamable_http import (
|
||||||
StreamId,
|
StreamId,
|
||||||
)
|
)
|
||||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from mcp.shared.context import RequestContext
|
from mcp.shared.context import RequestContext
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.shared.message import (
|
from mcp.shared.message import (
|
||||||
|
|
@ -227,10 +228,14 @@ def create_app(is_json_response_enabled=False, event_store: EventStore | None =
|
||||||
server = ServerTest()
|
server = ServerTest()
|
||||||
|
|
||||||
# Create the session manager
|
# 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(
|
session_manager = StreamableHTTPSessionManager(
|
||||||
app=server,
|
app=server,
|
||||||
event_store=event_store,
|
event_store=event_store,
|
||||||
json_response=is_json_response_enabled,
|
json_response=is_json_response_enabled,
|
||||||
|
security_settings=security_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create an ASGI application that uses the session manager
|
# 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",
|
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):
|
def test_json_validation(basic_server, basic_server_url):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue