fix(mcp): run proxy-wide pre-DB gates on bridge envelope admission

The envelope arm bypasses user_api_key_auth, so it never ran
pre_db_read_auth_checks (request-size and body-safety limits, the IP allowlist,
and the general_settings route allowlist) that the normal MCP admission path
runs before any key lookup. A caller blocked by IP or a disallowed proxy route
could be admitted through an envelope where the same principal on the normal
path is rejected. Run those gates before the envelope crypto, mirroring the
pipeline's pre-DB ordering; a blocked IP or route surfaces its own 403.
This commit is contained in:
Tin Chi Lo 2026-07-11 14:39:57 -07:00
parent 688f535bbf
commit 1c3c1af529
2 changed files with 46 additions and 3 deletions

View file

@ -534,6 +534,8 @@ class MCPRequestHandler:
if not master_key:
raise HTTPException(status_code=500, detail="Server misconfigured: master_key is not set")
await MCPRequestHandler._run_pre_db_read_auth_checks(request=request, route=route)
keys = envelope_keys_from_master_key(master_key)
result = resolve_bridge_envelope(authorization_value, keys, datetime.now(timezone.utc), server.server_id)
match result:
@ -551,6 +553,25 @@ class MCPRequestHandler:
case _:
assert_never(result)
@staticmethod
async def _run_pre_db_read_auth_checks(request: Request, route: str) -> None:
"""Run the proxy-wide gates ``user_api_key_auth`` applies before any key lookup: the
request-size and body-safety limits, the IP allowlist, and the ``general_settings``
route allowlist. The envelope arm bypasses ``user_api_key_auth`` (it opens the envelope
and reloads the identity itself), so without this a caller blocked by IP or hitting a
proxy route the allowlist forbids would be admitted through an envelope where the same
principal presented on the normal MCP admission path would be rejected. Runs before the
envelope crypto so a disallowed caller is turned away before any work, mirroring the
standard pipeline's pre-DB ordering. Violations raise the gate's own status (an IP or
route block is a 403, an oversized body its own limit error)."""
from litellm.proxy.auth.auth_utils import pre_db_read_auth_checks
await pre_db_read_auth_checks(
request=request,
request_data=await _read_request_body(request=request),
route=route,
)
@staticmethod
async def _reload_admitted_key(key_hash: str) -> UserAPIKeyAuth:
"""Reload the live key record an admitted envelope references and re-check live policy.

View file

@ -5243,9 +5243,7 @@ class TestMCPDcrBridgeDelegateAdmission:
assert exc_info.value.status_code == 401
_POLICY_GATE = (
"litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp._run_centralized_common_checks"
)
_POLICY_GATE = "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp._run_centralized_common_checks"
async def _enforce_with_gate_error(self, error):
"""Drive _enforce_admitted_live_policy with the centralized gate raising ``error`` and return
@ -5322,6 +5320,30 @@ class TestMCPDcrBridgeDelegateAdmission:
assert exc_info.value.status_code == 403
async def test_envelope_rejected_by_proxy_wide_pre_db_gates_403(self):
"""The envelope arm runs the same proxy-wide pre-DB gates user_api_key_auth applies before any
key lookup (request size, body safety, IP allowlist, general_settings route allowlist). Here
the proxy route allowlist forbids MCP, so the envelope is turned away with a 403 before the
identity is even reloaded, closing the gap where an envelope bypassed the IP/route allowlists
the normal MCP admission path enforces."""
envelope = self._mint_bridge_envelope(key_hash=self._KEY_HASH)
scope = {
"type": "http",
"method": "POST",
"path": "/mcp/bridge_delegate_server",
"headers": [(b"authorization", f"Bearer {envelope}".encode("latin-1"))],
}
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr,
patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY),
patch("litellm.proxy.proxy_server.general_settings", {"allowed_routes": ["/chat/completions"]}),
):
mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server()
with pytest.raises(HTTPException) as exc_info:
await MCPRequestHandler.process_mcp_request(scope)
assert exc_info.value.status_code == 403
async def test_blocked_state_bare_exception_stays_401(self):
"""A blocked team/project raises a bare Exception (no status) in common_checks, which the
standard pipeline renders as 401; the arm keeps failing those closed as 401, never a 500."""