fix(mcp): reject a refresh envelope explicitly at the tool-call edge

The live proof showed a refresh envelope presented at the MCP tool-call edge was rejected, but through
the generic oauth2 arm ("expected a virtual key starting with sk-") rather than the bridge arm, because
the admission routing gate is_bridge_envelope_shaped matched only the access prefix. The rejection was
already fail-closed and never forwarded anything upstream, but the path was imprecise and the unit test
modelled a route the real router did not take.

Match either envelope kind in is_bridge_envelope_shaped so the bridge arm engages for a refresh envelope
too, and have resolve_bridge_envelope return BridgeEnvelopeInvalid for it: a refresh envelope is a valid
gateway credential but only ever presented back to the token endpoint, never usable to authenticate a
tool call. Admission now fails it closed with the bridge arm's own 401 ("Invalid or expired
credential"), live-verified, with the upstream never touched. is_bridge_envelope_shaped has a single
caller (the admission routing gate), so the change is contained.
This commit is contained in:
Tin Chi Lo 2026-07-11 21:13:39 -07:00
parent c4dd06a0bb
commit 67d54fdbe0
3 changed files with 22 additions and 10 deletions

View file

@ -1160,7 +1160,7 @@ def _refresh_key_failure_to_mint_error(failure: _KeyResolutionFailure) -> _Bridg
async def _prepare_bridge_refresh(
request: Request, mcp_server: MCPServer, refresh_value: Optional[str]
request: Request, mcp_server: MCPServer, refresh_value: str | None
) -> "_BridgeRefreshReady | _BridgeMintError":
"""Phase 1 for the refresh_token grant, BEFORE the upstream exchange: open the client's refresh
envelope, re-validate the sealed litellm identity so a revoked key cannot keep refreshing, and

View file

@ -194,10 +194,12 @@ def _strip_bearer(value: str) -> str:
def is_bridge_envelope_shaped(authorization_value: str) -> bool:
"""Cheap, keyless test that an ``Authorization`` value carries an envelope (optional
``Bearer`` scheme stripped). The admission edge engages the bridge arm only for an
envelope, so a plain upstream bearer falls through to normal oauth2 admission."""
return is_envelope(_strip_bearer(authorization_value))
"""Cheap, keyless test that an ``Authorization`` value carries an envelope of either kind (optional
``Bearer`` scheme stripped). The admission edge engages the bridge arm for an access envelope (to
admit) and for a refresh envelope (to reject it explicitly, since a refresh credential is never
usable at the tool-call edge); a plain upstream bearer falls through to normal oauth2 admission."""
candidate = _strip_bearer(authorization_value)
return is_envelope(candidate) or is_refresh_envelope(candidate)
def resolve_bridge_envelope(
@ -214,6 +216,10 @@ def resolve_bridge_envelope(
envelope, and ``BridgeEnvelopeInvalid`` for an envelope-shaped bearer that will not
open. Never raises: it is total over hostile input via :func:`open_envelope`.
A refresh envelope is ``BridgeEnvelopeInvalid`` here: it is a valid gateway credential but only ever
presented back to the token endpoint, never usable to authenticate a tool call, so admission must
fail it closed rather than let it fall through to another arm.
``expected_server_id`` is the ``server_id`` of the MCP server the request targets; an
opened envelope whose sealed ``server_id`` does not match is rejected as
``BridgeEnvelopeInvalid``. Binding here (rather than leaving it to the caller) prevents
@ -223,6 +229,8 @@ def resolve_bridge_envelope(
unlike ``hmac.compare_digest`` on ``str``, does not raise on a non-ASCII server_id.
"""
candidate = _strip_bearer(authorization_value)
if is_refresh_envelope(candidate):
return BridgeEnvelopeInvalid()
if not is_envelope(candidate):
return NotBridgeEnvelope()
opened = open_envelope(candidate, keys, now)

View file

@ -100,12 +100,16 @@ def test_open_bridge_refresh_envelope_rejects_under_wrong_master_key():
def test_refresh_envelope_is_never_admitted_at_the_tool_call_edge():
"""A refresh envelope must never authenticate a tool call. It is not an access envelope, so the
admission consumer returns NotBridgeEnvelope, which admission fails closed (401): a refresh
credential can only ever be presented back to the token endpoint."""
"""A refresh envelope must never authenticate a tool call. The admission edge engages the bridge arm
for it (is_bridge_envelope_shaped is true for either envelope kind), and the consumer rejects it as
BridgeEnvelopeInvalid, which admission fails closed (401): a refresh credential is only ever
presented back to the token endpoint."""
keys = envelope_keys_from_master_key(_MASTER_KEY)
result = resolve_bridge_envelope(_sealed_refresh(keys), keys, _NOW, _SERVER_ID)
assert isinstance(result, NotBridgeEnvelope)
refresh = _sealed_refresh(keys)
assert is_bridge_envelope_shaped(refresh) is True
assert is_bridge_envelope_shaped(f"Bearer {refresh}") is True
result = resolve_bridge_envelope(refresh, keys, _NOW, _SERVER_ID)
assert isinstance(result, BridgeEnvelopeInvalid)
def test_refresh_jwt_wearing_the_access_prefix_is_rejected_at_the_edge():