mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
security: prevent forwarding litellm api keys to upstream mcp servers
- Strip Authorization header from extra_headers for pass-through servers - Pass-through servers (auth_type=None with extra_headers: [Authorization]) must not receive the user's LiteLLM API key - Only OAuth2 M2M and pass-through servers skip Authorization header - Other headers (x-request-id, x-trace-id) are still forwarded normally - Fixes credential leakage / authentication bypass in MCP pass-through mode
This commit is contained in:
parent
409a0058ed
commit
3753970cc9
2 changed files with 63 additions and 12 deletions
|
|
@ -1171,7 +1171,14 @@ if MCP_AVAILABLE:
|
|||
for header in server.extra_headers:
|
||||
if not isinstance(header, str):
|
||||
continue
|
||||
if server.has_client_credentials and header.lower() == "authorization":
|
||||
# Never forward the inbound Authorization header that was used for
|
||||
# LiteLLM API key authentication:
|
||||
# - skip if server has client_credentials (fetch upstream token via M2M flow)
|
||||
# - skip if server is oauth_passthrough (upstream token must come from
|
||||
# a server-specific header, not the LiteLLM API key header)
|
||||
if header.lower() == "authorization" and (
|
||||
server.has_client_credentials or server.is_oauth_passthrough
|
||||
):
|
||||
continue
|
||||
header_value = normalized_raw_headers.get(header.lower())
|
||||
if header_value is None:
|
||||
|
|
|
|||
|
|
@ -109,7 +109,11 @@ async def test_mcp_server_tool_call_body_contains_request_data():
|
|||
assert body["arguments"] == tool_arguments
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_case_insensitive_extra_headers():
|
||||
def test_prepare_mcp_server_headers_passthrough_omits_authorization():
|
||||
"""Pass-through servers must not forward the inbound Authorization header
|
||||
(LiteLLM API key) to the upstream server. The upstream must provide its own
|
||||
bearer token via a server-specific header.
|
||||
"""
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_prepare_mcp_server_headers,
|
||||
|
|
@ -118,9 +122,10 @@ def test_prepare_mcp_server_headers_case_insensitive_extra_headers():
|
|||
pytest.skip("MCP server not available")
|
||||
|
||||
server = MCPServer(
|
||||
server_id="server-case",
|
||||
server_id="server-passthrough",
|
||||
name="server",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none, # Explicitly none for pass-through
|
||||
extra_headers=["Authorization"],
|
||||
)
|
||||
|
||||
|
|
@ -129,11 +134,47 @@ def test_prepare_mcp_server_headers_case_insensitive_extra_headers():
|
|||
mcp_server_auth_headers=None,
|
||||
mcp_auth_header=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers={"authorization": "Bearer token"},
|
||||
raw_headers={"authorization": "Bearer sk-litellm-key"},
|
||||
)
|
||||
|
||||
assert server_auth_header is None
|
||||
assert extra_headers == {"Authorization": "Bearer token"}
|
||||
# Authorization should NOT be forwarded for pass-through servers
|
||||
assert extra_headers is None
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_passthrough_forwards_other_headers():
|
||||
"""Pass-through servers should forward other headers (not Authorization)
|
||||
from the raw request."""
|
||||
try:
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
_prepare_mcp_server_headers,
|
||||
)
|
||||
except ImportError:
|
||||
pytest.skip("MCP server not available")
|
||||
|
||||
server = MCPServer(
|
||||
server_id="server-passthrough-headers",
|
||||
name="server",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.none, # Pass-through mode
|
||||
extra_headers=["Authorization", "x-request-id", "x-trace-id"],
|
||||
)
|
||||
|
||||
server_auth_header, extra_headers = _prepare_mcp_server_headers(
|
||||
server=server,
|
||||
mcp_server_auth_headers=None,
|
||||
mcp_auth_header=None,
|
||||
oauth2_headers=None,
|
||||
raw_headers={
|
||||
"authorization": "Bearer sk-litellm-key",
|
||||
"x-request-id": "req-123",
|
||||
"x-trace-id": "trace-456",
|
||||
},
|
||||
)
|
||||
|
||||
assert server_auth_header is None
|
||||
# Authorization should be omitted, but other headers forwarded
|
||||
assert extra_headers == {"x-request-id": "req-123", "x-trace-id": "trace-456"}
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_oauth2_m2m_omits_litellm_caller_authorization():
|
||||
|
|
@ -2946,7 +2987,7 @@ async def test_list_tools_with_legacy_db_m2m_server_resolves_oauth2_flow():
|
|||
"""
|
||||
P1 Regression: list_tools path must apply _resolve_oauth2_flow to legacy DB
|
||||
rows where oauth2_flow is NULL but M2M credentials are present.
|
||||
|
||||
|
||||
Without this fix, has_client_credentials returns False and the caller's
|
||||
Authorization header is forwarded upstream instead of being blocked.
|
||||
"""
|
||||
|
|
@ -3044,7 +3085,7 @@ async def test_call_tool_empty_extra_headers_returns_none():
|
|||
"""
|
||||
P2 Regression: When all configured extra_headers are filtered out (e.g.
|
||||
Authorization for M2M), the resulting extra_headers should be None, not {}.
|
||||
|
||||
|
||||
Downstream code that checks `if extra_headers is None` will behave
|
||||
differently if an empty dict is passed instead.
|
||||
"""
|
||||
|
|
@ -3071,7 +3112,10 @@ async def test_call_tool_empty_extra_headers_returns_none():
|
|||
extra_headers=["Authorization"], # Will be filtered out for M2M
|
||||
)
|
||||
|
||||
raw_headers = {"Authorization": "Bearer sk-1234", "Content-Type": "application/json"}
|
||||
raw_headers = {
|
||||
"Authorization": "Bearer sk-1234",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
captured_extra_headers = None
|
||||
|
||||
|
|
@ -3108,8 +3152,8 @@ async def test_call_tool_empty_extra_headers_returns_none():
|
|||
pass # We only care about the captured headers
|
||||
|
||||
# With P2 fix: extra_headers should be None (not {}) when all headers filtered
|
||||
assert captured_extra_headers is None, (
|
||||
"P2 API consistency issue: expected None for empty extra_headers, got: "
|
||||
+ str(captured_extra_headers)
|
||||
assert (
|
||||
captured_extra_headers is None
|
||||
), "P2 API consistency issue: expected None for empty extra_headers, got: " + str(
|
||||
captured_extra_headers
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue