fix(mcp): forward Authorization in pass-through when x-litellm-api-key is admission

Commit 3753970cc9 widened the Authorization strip to cover all
is_oauth_passthrough servers — protecting against the LiteLLM admission
key leaking upstream when the caller used Authorization for admission,
but also silently stripping legitimate upstream OAuth bearers when the
caller used x-litellm-api-key for admission.

That broke transparent OAuth pass-through (EAI-506 V5/V6): standards-
compliant MCP clients (OpenCode, Claude Code, mcp-inspector) complete
PKCE against the upstream IdP and send the resulting token as plain
Authorization: Bearer per the MCP spec — with the wider strip in place,
that token never reaches the upstream and tools/list returns empty.

Narrow the strip: skip Authorization for pass-through servers only when
the caller did NOT supply x-litellm-api-key. When x-litellm-api-key is
present, admission is unambiguous and Authorization is free to carry
the upstream OAuth bearer.

The original security guarantee is preserved — a client that sends only
Authorization (no x-litellm-api-key) still has it stripped, so the
LiteLLM key cannot leak upstream via that path.

Tests:
- new: forwards Authorization when x-litellm-api-key is present
- new: still strips Authorization when only Authorization is present
- existing pass-through + M2M tests unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Artem Dudarev 2026-05-19 16:42:56 +03:00 • committed by gym-cmd
parent 3990165b26
commit f6359cbf04
2 changed files with 96 additions and 9 deletions

View file

@ -1168,18 +1168,30 @@ if MCP_AVAILABLE:
str(k).lower(): v for k, v in raw_headers.items() if isinstance(k, str)
}
has_explicit_litellm_admission_header = (
normalized_raw_headers.get("x-litellm-api-key") is not None
)
for header in server.extra_headers:
if not isinstance(header, str):
continue
# 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
if header.lower() == "authorization":
# M2M servers fetch their own upstream token via the
# client_credentials flow — never forward the caller's
# Authorization header.
if server.has_client_credentials:
continue
# Transparent OAuth pass-through: forward the caller's
# Authorization header only when LiteLLM admission used
# a different header (`x-litellm-api-key`). Without an
# explicit admission header, `Authorization` may itself
# be the LiteLLM key — strip it to avoid leaking the
# gateway credential upstream.
if (
server.is_oauth_passthrough
and not has_explicit_litellm_admission_header
):
continue
header_value = normalized_raw_headers.get(header.lower())
if header_value is None:
continue

View file

@ -177,6 +177,81 @@ def test_prepare_mcp_server_headers_passthrough_forwards_other_headers():
assert extra_headers == {"x-request-id": "req-123", "x-trace-id": "trace-456"}
def test_prepare_mcp_server_headers_passthrough_forwards_authorization_with_explicit_admission():
"""Transparent OAuth pass-through: when LiteLLM admission used the explicit
`x-litellm-api-key` header, the inbound `Authorization` header is
unambiguously the upstream OAuth bearer and MUST be forwarded.
Regression for EAI-506 V5/V6 — a standards-compliant MCP client (e.g.
OpenCode) completes PKCE against the upstream IdP and sends the resulting
token as plain `Authorization: Bearer <token>` per the MCP spec.
"""
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-explicit-admission",
name="server",
transport=MCPTransport.http,
auth_type=MCPAuth.none,
extra_headers=["Authorization"],
)
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={
"x-litellm-api-key": "Bearer sk-litellm-key",
"authorization": "Bearer upstream-okta-token",
},
)
assert server_auth_header is None
assert extra_headers == {"Authorization": "Bearer upstream-okta-token"}
def test_prepare_mcp_server_headers_passthrough_strips_authorization_without_admission_header():
"""Counterpart to the explicit-admission test: without `x-litellm-api-key`,
the inbound `Authorization` may itself be the LiteLLM admission key, so we
strip it to avoid leaking the gateway credential upstream. This preserves
the security guarantee introduced in commit 3753970cc9.
"""
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-no-admission",
name="server",
transport=MCPTransport.http,
auth_type=MCPAuth.none,
extra_headers=["Authorization", "x-request-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-789",
},
)
assert server_auth_header is None
assert extra_headers == {"x-request-id": "req-789"}
def test_prepare_mcp_server_headers_oauth2_m2m_omits_litellm_caller_authorization():
"""M2M OAuth must not put caller Bearer (LiteLLM API key) into extra_headers (#23652)."""
try: