mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
fix(mcp): reject origins outside the configured allowlist (#42649)
Co-authored-by: Joshua Valluru <326636767+joshua-berri@users.noreply.github.com>
This commit is contained in:
parent
793627ce83
commit
57eb3ff6b6
2 changed files with 67 additions and 0 deletions
|
|
@ -111,6 +111,13 @@ _MCP_DESTINATIONS_SCOPE_KEY: Final = "litellm_otel_request_destinations"
|
|||
_MCP_PROTOCOL_VERSION_HEADER: Final = b"mcp-protocol-version"
|
||||
|
||||
|
||||
def reject_disallowed_mcp_origin(request: StarletteRequest) -> None:
|
||||
from litellm.proxy.proxy_server import origins # noqa: PLC0415 # proxy imports this module during startup
|
||||
|
||||
if "*" not in origins and any(origin not in origins for origin in request.headers.getlist("origin")):
|
||||
raise HTTPException(status_code=403, detail="Invalid Origin header")
|
||||
|
||||
|
||||
def unsupported_protocol_version(scope: Scope) -> str | None:
|
||||
"""Return the unsupported ``MCP-Protocol-Version`` header value, if any.
|
||||
|
||||
|
|
@ -1931,6 +1938,7 @@ if MCP_AVAILABLE:
|
|||
async def handle_streamable_http_mcp(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""Handle MCP requests through StreamableHTTP."""
|
||||
try:
|
||||
reject_disallowed_mcp_origin(StarletteRequest(scope))
|
||||
bad_version: Final = unsupported_protocol_version(scope)
|
||||
if bad_version is not None:
|
||||
supported: Final = ", ".join(sorted(HANDSHAKE_PROTOCOL_VERSIONS))
|
||||
|
|
@ -2275,6 +2283,7 @@ if MCP_AVAILABLE:
|
|||
async def handle_sse_mcp(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
"""Handle MCP requests through SSE."""
|
||||
try:
|
||||
reject_disallowed_mcp_origin(StarletteRequest(scope))
|
||||
bad_version: Final = unsupported_protocol_version(scope)
|
||||
if bad_version is not None:
|
||||
supported: Final = ", ".join(sorted(HANDSHAKE_PROTOCOL_VERSIONS))
|
||||
|
|
|
|||
|
|
@ -10201,6 +10201,64 @@ async def test_active_request_ctx_var_feeds_get_current_session(_mcp_request_ctx
|
|||
assert _get_current_session() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "session_headers"),
|
||||
(
|
||||
("POST", "/mcp", ()),
|
||||
("GET", "/mcp", (("mcp-session-id", "existing-session"),)),
|
||||
("DELETE", "/mcp", (("mcp-session-id", "existing-session"),)),
|
||||
("POST", "/server/mcp", ()),
|
||||
("GET", "/sse", ()),
|
||||
("POST", "/sse/messages", ()),
|
||||
),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_origins", "origin_headers", "expected_status"),
|
||||
(
|
||||
(("https://allowed.example",), (("origin", "https://evil.example"),), 403),
|
||||
(("https://allowed.example",), (("origin", "https://allowed.example.evil.example"),), 403),
|
||||
(("https://allowed.example",), (("origin", "null"),), 403),
|
||||
(("https://allowed.example",), (("origin", ""),), 403),
|
||||
(
|
||||
("https://allowed.example",),
|
||||
(("origin", "https://allowed.example"), ("origin", "https://evil.example")),
|
||||
403,
|
||||
),
|
||||
(("https://allowed.example",), (("origin", "https://allowed.example"),), 401),
|
||||
(("https://allowed.example",), (), 401),
|
||||
(("*",), (("origin", "https://another.example"),), 401),
|
||||
),
|
||||
)
|
||||
async def test_mcp_origin_admission_precedes_authentication(
|
||||
method: str,
|
||||
path: str,
|
||||
session_headers: tuple[tuple[str, str], ...],
|
||||
allowed_origins: tuple[str, ...],
|
||||
origin_headers: tuple[tuple[str, str], ...],
|
||||
expected_status: int,
|
||||
) -> None:
|
||||
import httpx
|
||||
|
||||
from litellm.proxy._experimental.mcp_server import server
|
||||
|
||||
authenticate: Final = AsyncMock(side_effect=HTTPException(status_code=401, detail="authentication required"))
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.origins", allowed_origins),
|
||||
patch.object(server, "extract_mcp_auth_context", authenticate),
|
||||
):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=server.app), base_url="http://gateway") as client:
|
||||
response: Final = await client.request(method, path, headers=(*session_headers, *origin_headers))
|
||||
|
||||
assert response.status_code == expected_status
|
||||
if expected_status == 403:
|
||||
assert response.json() == {"detail": "Invalid Origin header"}
|
||||
authenticate.assert_not_awaited()
|
||||
else:
|
||||
assert response.json() == {"detail": "authentication required"}
|
||||
authenticate.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_request_ctx_var_feeds_auth_resolution_recording(_mcp_request_ctx) -> None:
|
||||
from starlette.requests import Request
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue