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:
gym-cmd 2026-05-16 19:23:03 +01:00
parent 409a0058ed
commit 3753970cc9
2 changed files with 63 additions and 12 deletions

View file

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

View file

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