mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
fix(mcp): plug fan-out Authorization bypass in the extra_headers loop
The listing fan-out withholds the request-wide Authorization from a true_passthrough / oauth_delegate server when another server in scope also consumes it, so one bearer is not replayed across upstreams. The later server.extra_headers copy loop did not honor that decision: a server listing Authorization in extra_headers would re-copy the withheld bearer from raw_headers. The withhold decision is now computed once and applied to both the forwarding branch and the extra_headers loop.
This commit is contained in:
parent
edf00bbe23
commit
ddec3b2b8b
2 changed files with 58 additions and 3 deletions
|
|
@ -1565,6 +1565,16 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
extra_headers: Optional[Dict[str, str]] = None
|
||||
is_client_forwarded_mode = server.is_true_passthrough or server.is_oauth_delegate
|
||||
# In a multi-server listing scope the request-wide Authorization can only carry one token,
|
||||
# so it is withheld from a client-forwarded server when another server in scope also consumes
|
||||
# it (RFC 9700 cross-resource replay); such scopes must bind per-server via
|
||||
# x-mcp-{alias}-authorization. The decision is computed once so BOTH the forwarding branch and
|
||||
# the extra_headers copy loop below honor it — otherwise a server that lists Authorization in
|
||||
# extra_headers would re-copy the withheld bearer from raw_headers and replay it anyway.
|
||||
withhold_forwarded_authorization = is_client_forwarded_mode and _caller_authorization_fans_out(
|
||||
server, scope_servers
|
||||
)
|
||||
if server.auth_type == MCPAuth.oauth2:
|
||||
# For OAuth2 M2M servers, upstream Authorization must come from
|
||||
# client_credentials token fetch, never from caller headers.
|
||||
|
|
@ -1583,8 +1593,8 @@ if MCP_AVAILABLE:
|
|||
user_api_key_auth=user_api_key_auth,
|
||||
):
|
||||
extra_headers = _without_authorization(extra_headers)
|
||||
elif server.is_true_passthrough or server.is_oauth_delegate:
|
||||
if not _caller_authorization_fans_out(server, scope_servers):
|
||||
elif is_client_forwarded_mode:
|
||||
if not withhold_forwarded_authorization:
|
||||
extra_headers = _client_forwarded_authorization_headers(
|
||||
mcp_server=server,
|
||||
oauth2_headers=oauth2_headers,
|
||||
|
|
@ -1611,7 +1621,9 @@ if MCP_AVAILABLE:
|
|||
for header in server.extra_headers:
|
||||
if not isinstance(header, str):
|
||||
continue
|
||||
if header.lower() == "authorization" and strip_caller_authorization:
|
||||
if header.lower() == "authorization" and (
|
||||
strip_caller_authorization or withhold_forwarded_authorization
|
||||
):
|
||||
continue
|
||||
header_value = normalized_raw_headers.get(header.lower())
|
||||
if header_value is None:
|
||||
|
|
|
|||
|
|
@ -426,6 +426,49 @@ def test_prepare_mcp_server_headers_scope_counts_legacy_delegate_as_consumer():
|
|||
assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers}
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_fanout_withhold_survives_extra_headers_loop():
|
||||
"""Regression: when fan-out withholds the request-wide Authorization from a client-forwarded
|
||||
server, the later server.extra_headers copy loop must not re-add it from raw_headers even if
|
||||
the server lists Authorization in extra_headers. Otherwise one bearer is replayed across every
|
||||
consuming upstream in the scope (the exact cross-resource replay the withholding prevents)."""
|
||||
delegate = MCPServer(
|
||||
server_id="od-extra-hdr",
|
||||
name="od-extra-hdr",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth_delegate,
|
||||
extra_headers=["Authorization"],
|
||||
)
|
||||
second_consumer = _client_forwarded_mode_server("tp-peer", MCPAuth.true_passthrough)
|
||||
|
||||
_, extra_headers = _prepare_headers_in_scope(delegate, [delegate, second_consumer])
|
||||
|
||||
assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers}
|
||||
|
||||
|
||||
def test_prepare_mcp_server_headers_sole_consumer_still_forwards_via_extra_headers():
|
||||
"""Guard the fix does not over-withhold: with no second consumer in scope, a client-forwarded
|
||||
server that lists Authorization in extra_headers still forwards the caller's bearer."""
|
||||
delegate = MCPServer(
|
||||
server_id="od-extra-sole",
|
||||
name="od-extra-sole",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.oauth_delegate,
|
||||
extra_headers=["Authorization"],
|
||||
)
|
||||
static_server = MCPServer(
|
||||
server_id="static-peer",
|
||||
name="static-peer",
|
||||
transport=MCPTransport.http,
|
||||
auth_type=MCPAuth.api_key,
|
||||
authentication_token="static-key",
|
||||
)
|
||||
|
||||
_, extra_headers = _prepare_headers_in_scope(delegate, [delegate, static_server])
|
||||
|
||||
assert extra_headers is not None
|
||||
assert extra_headers.get("Authorization") == "Bearer upstream-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_tool_m2m_skips_authorization_headers():
|
||||
"""M2M call_tool must not forward caller Authorization in oauth2/raw headers."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue