516 lines
21 KiB
Python
516 lines
21 KiB
Python
"""Tests for SSE server request validation."""
|
|
|
|
import logging
|
|
import multiprocessing
|
|
import re
|
|
import socket
|
|
from collections.abc import Iterator
|
|
from typing import Any
|
|
|
|
import anyio
|
|
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 starlette.types import Message
|
|
|
|
from mcp.server import Server
|
|
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
|
from mcp.server.auth.provider import AccessToken
|
|
from mcp.server.sse import SseServerTransport
|
|
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
|
|
from mcp.types import Tool
|
|
from tests.test_helpers import wait_for_server
|
|
|
|
logger = logging.getLogger(__name__)
|
|
SERVER_NAME = "test_sse_security_server"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_sse_starlette_exit_event() -> Iterator[None]:
|
|
"""sse-starlette<2 caches a module-level anyio.Event on AppStatus; clear it
|
|
around each test so it is never bound to a closed event loop. Clearing it
|
|
afterwards matters too: later test modules fork uvicorn subprocesses on
|
|
Linux and would otherwise inherit a stale event."""
|
|
from sse_starlette.sse import AppStatus
|
|
|
|
def clear() -> None:
|
|
if hasattr(AppStatus, "should_exit_event"): # pragma: no cover
|
|
setattr(AppStatus, "should_exit_event", None)
|
|
|
|
clear()
|
|
yield
|
|
clear()
|
|
|
|
|
|
@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: # pragma: no cover
|
|
return f"http://127.0.0.1:{server_port}"
|
|
|
|
|
|
class SecurityTestServer(Server): # pragma: no cover
|
|
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): # pragma: no cover
|
|
"""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()
|
|
# Wait for server to be ready to accept connections
|
|
wait_for_server(port)
|
|
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 == 403
|
|
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()
|
|
|
|
|
|
def _authenticated_user(client_id: str, subject: str | None = None, issuer: str | None = None) -> AuthenticatedUser:
|
|
"""Build the scope["user"] value that AuthenticationMiddleware would set for this principal."""
|
|
claims = {"iss": issuer} if issuer is not None else None
|
|
return AuthenticatedUser(AccessToken(token="token", client_id=client_id, scopes=[], subject=subject, claims=claims))
|
|
|
|
|
|
def _sse_scope(method: str, path: str, user: AuthenticatedUser | None, *, query_string: bytes = b"") -> dict[str, Any]:
|
|
"""Build an ASGI scope for a request to the SSE transport."""
|
|
scope: dict[str, Any] = {
|
|
"type": "http",
|
|
"method": method,
|
|
"path": path,
|
|
"root_path": "",
|
|
"query_string": query_string,
|
|
"headers": [(b"content-type", b"application/json")],
|
|
}
|
|
if user is not None:
|
|
scope["user"] = user
|
|
return scope
|
|
|
|
|
|
async def _call_message_endpoint(
|
|
transport: SseServerTransport, scope: dict[str, Any], body: bytes | list[bytes]
|
|
) -> list[Message]:
|
|
"""Send a request to the transport's message endpoint and return the ASGI messages it sent.
|
|
|
|
`body` may be a list of chunks to deliver the request body over several `http.request` messages;
|
|
no Content-Length header is set either way.
|
|
"""
|
|
sent: list[Message] = []
|
|
chunks = list(body) if isinstance(body, list) else [body]
|
|
|
|
async def receive() -> Message:
|
|
chunk = chunks.pop(0)
|
|
return {"type": "http.request", "body": chunk, "more_body": bool(chunks)}
|
|
|
|
async def send(message: Message) -> None:
|
|
sent.append(message)
|
|
|
|
await transport.handle_post_message(scope, receive, send)
|
|
return sent
|
|
|
|
|
|
def _response_status(sent: list[Message]) -> int:
|
|
response_start = next(msg for msg in sent if msg["type"] == "http.response.start")
|
|
return response_start["status"]
|
|
|
|
|
|
def _response_body(sent: list[Message]) -> bytes:
|
|
return b"".join(msg.get("body", b"") for msg in sent if msg["type"] == "http.response.body")
|
|
|
|
|
|
async def _post_message(transport: SseServerTransport, session_id: str, user: AuthenticatedUser | None) -> int:
|
|
"""POST a message to an SSE session as `user` and return the response status."""
|
|
body = b'{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null}'
|
|
scope = _sse_scope("POST", "/messages/", user, query_string=f"session_id={session_id}".encode())
|
|
return _response_status(await _call_message_endpoint(transport, scope, body))
|
|
|
|
|
|
_Principal = tuple[str] | tuple[str, str] | tuple[str, str, str]
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize(
|
|
("creator", "sender", "expected"),
|
|
[
|
|
pytest.param(("client-a",), ("client-b",), 404, id="different-client"),
|
|
pytest.param(("client-a",), None, 404, id="unauthenticated-sender"),
|
|
pytest.param(("client-a", "alice"), ("client-a", "bob"), 404, id="same-client-different-subject"),
|
|
pytest.param(("client-a", "alice"), ("client-a",), 404, id="same-client-no-subject"),
|
|
pytest.param(
|
|
("client-a", "alice", "https://i1"), ("client-a", "alice", "https://i2"), 404, id="different-issuer"
|
|
),
|
|
pytest.param(None, ("client-a",), 404, id="unauthenticated-creator"),
|
|
pytest.param(("client-a",), ("client-a",), 202, id="same-client"),
|
|
pytest.param(("client-a", "alice"), ("client-a", "alice"), 202, id="same-client-and-subject"),
|
|
pytest.param(None, None, 202, id="both-unauthenticated"),
|
|
],
|
|
)
|
|
async def test_sse_post_requires_the_credential_that_created_the_session(
|
|
creator: _Principal | None,
|
|
sender: _Principal | None,
|
|
expected: int,
|
|
):
|
|
"""The session endpoint URL issued to one authenticated principal must not
|
|
accept messages from a request authenticated as a different one."""
|
|
transport = SseServerTransport("/messages/")
|
|
session_id_received = anyio.Event()
|
|
session_ids: list[str] = []
|
|
client_disconnected = anyio.Event()
|
|
|
|
async def get_send(message: Message) -> None:
|
|
# The first body chunk is the SSE event announcing the session URI to POST messages to.
|
|
if message["type"] == "http.response.body" and not session_ids:
|
|
match = re.search(rb"session_id=([0-9a-f]{32})", message.get("body", b""))
|
|
assert match is not None, f"expected the endpoint event first, got {message!r}"
|
|
session_ids.append(match.group(1).decode())
|
|
session_id_received.set()
|
|
|
|
async def get_receive() -> Message:
|
|
# The SSE client stays connected until the test signals otherwise.
|
|
await client_disconnected.wait()
|
|
return {"type": "http.disconnect"}
|
|
|
|
creator_user = _authenticated_user(*creator) if creator is not None else None
|
|
sender_user = _authenticated_user(*sender) if sender is not None else None
|
|
|
|
async def hold_sse_connection() -> None:
|
|
"""Establish the SSE session as `creator` and keep it open, as a server would."""
|
|
scope = _sse_scope("GET", "/sse", creator_user)
|
|
with anyio.fail_after(5):
|
|
async with transport.connect_sse(scope, get_receive, get_send) as (read_stream, write_stream):
|
|
async with read_stream, write_stream: # pragma: no branch
|
|
# ^ coverage.py misses the ->exit arc on 3.11+ when the body
|
|
# is nested inside multiple async with blocks
|
|
async for _ in read_stream:
|
|
pass
|
|
|
|
async with anyio.create_task_group() as tg:
|
|
tg.start_soon(hold_sse_connection)
|
|
with anyio.fail_after(5):
|
|
await session_id_received.wait()
|
|
|
|
assert await _post_message(transport, session_ids[0], sender_user) == expected
|
|
|
|
client_disconnected.set()
|
|
|
|
# Once the connection is gone the session is no longer routable.
|
|
assert await _post_message(transport, session_ids[0], creator_user) == 404
|
|
|
|
|
|
# A well-formed session ID that no live session owns.
|
|
_UNKNOWN_SESSION = b"session_id=12345678123456781234567812345678"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_post_body_over_the_limit_returns_413():
|
|
"""A POST body larger than max_request_body_size is answered with 413 before any session handling."""
|
|
transport = SseServerTransport("/messages/", max_request_body_size=8)
|
|
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
|
|
|
|
sent = await _call_message_endpoint(transport, scope, b"123456789")
|
|
assert _response_status(sent) == 413
|
|
assert _response_body(sent) == b"Request body too large"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_post_body_limit_defaults_to_four_mib():
|
|
"""Without an explicit limit, a body one byte over 4 MiB (and no Content-Length) is answered with 413."""
|
|
transport = SseServerTransport("/messages/")
|
|
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
|
|
|
|
sent = await _call_message_endpoint(transport, scope, b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1))
|
|
assert _response_status(sent) == 413
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_post_streamed_body_over_the_limit_returns_413():
|
|
"""The limit counts bytes across body chunks, not just a declared Content-Length."""
|
|
transport = SseServerTransport("/messages/", max_request_body_size=8)
|
|
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
|
|
|
|
sent = await _call_message_endpoint(transport, scope, [b"1234", b"56789"])
|
|
assert _response_status(sent) == 413
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sse_post_within_the_limit_reaches_session_lookup():
|
|
"""A body within the limit is passed on intact: an unknown session still gets its 404."""
|
|
transport = SseServerTransport("/messages/", max_request_body_size=64)
|
|
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
|
|
|
|
sent = await _call_message_endpoint(transport, scope, [b'{"jsonrpc": ', b'"2.0"}'])
|
|
assert _response_status(sent) == 404
|
|
assert _response_body(sent) == b"Could not find session"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
@pytest.mark.parametrize("method", ["GET", "PUT"])
|
|
async def test_sse_message_endpoint_answers_405_to_non_post(method: str):
|
|
"""The message endpoint only accepts POST; other methods get 405 with an Allow header."""
|
|
transport = SseServerTransport("/messages/")
|
|
scope = _sse_scope(method, "/messages/", None, query_string=_UNKNOWN_SESSION)
|
|
|
|
sent = await _call_message_endpoint(transport, scope, b"{}")
|
|
assert _response_status(sent) == 405
|
|
response_start = next(msg for msg in sent if msg["type"] == "http.response.start")
|
|
assert (b"allow", b"POST") in response_start["headers"]
|
|
|
|
|
|
@pytest.mark.parametrize("max_request_body_size", [0, -1])
|
|
def test_sse_transport_rejects_a_non_positive_body_limit(max_request_body_size: int):
|
|
"""The body limit must be a positive number of bytes, matching StreamableHTTPSessionManager."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
SseServerTransport("/messages/", max_request_body_size=max_request_body_size)
|
|
assert str(exc_info.value) == "max_request_body_size must be a positive number of bytes"
|