From 0053527ccff26b8b7653fd41e944be5ef13e6e22 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Fri, 10 Jul 2026 15:30:03 -0700 Subject: [PATCH] fix(mcp): keep bridge server-id match total on non-ascii and strip bearer with any whitespace --- .../bridge_credentials.py | 14 ++++++------- .../test_bridge_credentials.py | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py index 2fc1bc99939..5530fbc46fd 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py @@ -10,7 +10,6 @@ token-endpoint and admission wiring live in their respective call sites. """ import hashlib -import hmac from datetime import datetime from functools import lru_cache from typing import Literal, TypeAlias @@ -31,7 +30,6 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import _SIGNING_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-signing:" _ENCRYPTION_KEY_DOMAIN = b"litellm-mcp-bridge:envelope-encryption:" -_BEARER_PREFIX = "bearer " # scrypt work factors (RFC 7914). n=2**15 with r=8/p=1 costs ~50ms and ~32MB per derivation, which # makes offline guessing of a candidate master key memory-hard rather than a bare hash comparison. @@ -123,8 +121,9 @@ BridgeEnvelopeResult: TypeAlias = NotBridgeEnvelope | BridgeEnvelopeAdmitted | B def _strip_bearer(value: str) -> str: - if value[: len(_BEARER_PREFIX)].lower() == _BEARER_PREFIX: - return value[len(_BEARER_PREFIX) :] + parts = value.split(None, 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + return parts[1] return value @@ -153,8 +152,9 @@ def resolve_bridge_envelope( opened envelope whose sealed ``server_id`` does not match is rejected as ``BridgeEnvelopeInvalid``. Binding here (rather than leaving it to the caller) prevents replaying an envelope minted for one server against another, which would forward the - first server's upstream credential across a server boundary. The comparison is constant - time so a mismatch does not leak the expected id through timing. + first server's upstream credential across a server boundary. ``server_id`` is not a + secret (the caller targets that server), so a plain equality check is sufficient and, + unlike ``hmac.compare_digest`` on ``str``, does not raise on a non-ASCII server_id. """ candidate = _strip_bearer(authorization_value) if not is_envelope(candidate): @@ -162,7 +162,7 @@ def resolve_bridge_envelope( opened = open_envelope(candidate, keys, now) if not isinstance(opened, OpenedEnvelope): return BridgeEnvelopeInvalid() - if not hmac.compare_digest(opened.identity.server_id, expected_server_id): + if opened.identity.server_id != expected_server_id: return BridgeEnvelopeInvalid() grant = opened.grant upstream_authorization = f"{grant.token_type} {grant.access_token.get_secret_value()}" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py index 7fdab05b495..dca9dc19314 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_bridge_credentials.py @@ -150,6 +150,26 @@ def test_resolve_matching_server_binding_is_admitted(): assert isinstance(result, BridgeEnvelopeAdmitted) +def test_resolve_non_ascii_server_id_stays_total_and_does_not_raise(): + """The server-binding check must not raise on a non-ASCII server_id (an admin can register a + unicode server_id); it stays total and returns a typed result. A matching non-ASCII id admits, + a mismatching one is BridgeEnvelopeInvalid, and neither raises.""" + keys = envelope_keys_from_master_key(_MASTER_KEY) + unicode_identity = EnvelopeIdentity(user_id=_IDENTITY.user_id, server_id="srv-café") + token = _sealed_token(keys, identity=unicode_identity) + assert isinstance(resolve_bridge_envelope(token, keys, _NOW, "srv-café"), BridgeEnvelopeAdmitted) + assert isinstance(resolve_bridge_envelope(token, keys, _NOW, "srv-cafe"), BridgeEnvelopeInvalid) + + +def test_resolve_strips_bearer_with_extra_whitespace(): + """A Bearer scheme separated by extra spaces or a tab still yields the envelope, so a client + using non-minimal but legal whitespace is not misclassified as a non-envelope.""" + keys = envelope_keys_from_master_key(_MASTER_KEY) + token = _sealed_token(keys) + for header in (f"Bearer {token}", f"Bearer\t{token}", f" Bearer {token}"): + assert isinstance(resolve_bridge_envelope(header, keys, _NOW, _SERVER_ID), BridgeEnvelopeAdmitted) + + def test_admitted_result_repr_never_leaks_upstream_token(): keys = envelope_keys_from_master_key(_MASTER_KEY) result = resolve_bridge_envelope(_sealed_token(keys), keys, _NOW, _SERVER_ID)