From 2c238118806c568b11f26e53957662894ed2554f Mon Sep 17 00:00:00 2001 From: shivam Date: Thu, 21 May 2026 16:18:21 -0700 Subject: [PATCH] fix(jwt): close REJECT bypass when JWT omits the configured claim field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JWT presented without the configured `virtual_key_claim_field` previously returned None at the `claim_value is None` guard before the `unregistered_jwt_client_behavior` check ran. A caller who knows the configured claim-field name could bypass REJECT by simply omitting that field and falling through to team-based JWT auth. Apply the no-match policy on a missing claim: - REJECT → 403 - AUTO_REGISTER → 403 (no stable identity to map; refuse rather than create a sentinel-keyed record) - FALLBACK_TEAM_MAPPING → return None (unchanged, backward-compatible) Adds three tests covering each branch of the missing-claim path. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/auth/user_api_key_auth.py | 17 ++++ .../proxy_unit_tests/test_jwt_key_mapping.py | 89 +++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index ce95aafbb51..fa979dcec39 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -714,6 +714,23 @@ async def _resolve_jwt_to_virtual_key( verbose_proxy_logger.debug( f"JWT Key Mapping: Claim field '{virtual_key_claim_field}' not found in JWT claims." ) + # A missing claim is an unmapped client — apply the no-match policy + # rather than returning early. Otherwise a caller can bypass REJECT + # simply by presenting a JWT that omits the configured field. For + # AUTO_REGISTER there is no stable identity to map without a claim + # value, so we deny rather than create a sentinel-keyed record. + behavior = jwt_handler.litellm_jwtauth.unregistered_jwt_client_behavior + if behavior in ( + UnregisteredJWTClientBehavior.REJECT, + UnregisteredJWTClientBehavior.AUTO_REGISTER, + ): + raise HTTPException( + status_code=403, + detail=( + f"JWT Key Mapping: Required claim '{virtual_key_claim_field}' " + "is missing from the JWT. Access denied." + ), + ) return None cache_key = f"jwt_key_mapping:{virtual_key_claim_field}:{claim_value}" diff --git a/tests/proxy_unit_tests/test_jwt_key_mapping.py b/tests/proxy_unit_tests/test_jwt_key_mapping.py index c667ee0e929..01462a76730 100644 --- a/tests/proxy_unit_tests/test_jwt_key_mapping.py +++ b/tests/proxy_unit_tests/test_jwt_key_mapping.py @@ -826,6 +826,95 @@ async def test_reject_behavior_enforced_when_prisma_client_is_none(): assert "unknown@example.com" in exc_info.value.detail +@pytest.mark.asyncio +async def test_reject_raises_403_when_claim_field_missing_from_jwt(): + """ + Security: a JWT that omits the configured virtual_key_claim_field must NOT + bypass the REJECT policy. Previously the early `if claim_value is None: + return None` branch ran before the policy check, letting a caller who knows + the configured claim-field name silently fall through to team-based auth. + """ + from litellm.proxy._types import UnregisteredJWTClientBehavior + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + virtual_key_claim_field="sub", + unregistered_jwt_client_behavior=UnregisteredJWTClientBehavior.REJECT, + ) + # JWT does NOT contain "sub" + jwt_claims = {"email": "user@example.com"} + + with pytest.raises(HTTPException) as exc_info: + await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=MagicMock(), + user_api_key_cache=DualCache(), + parent_otel_span=None, + proxy_logging_obj=None, + ) + assert exc_info.value.status_code == 403 + assert "'sub'" in exc_info.value.detail + assert "missing from the JWT" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_auto_register_raises_403_when_claim_field_missing_from_jwt(): + """ + AUTO_REGISTER cannot create a mapping without a stable identity. When the + configured claim field is missing from the JWT, return 403 rather than + silently falling through (which would bypass the unregistered-client policy) + or creating a sentinel-keyed record. + """ + from litellm.proxy._types import UnregisteredJWTClientBehavior + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + virtual_key_claim_field="sub", + unregistered_jwt_client_behavior=UnregisteredJWTClientBehavior.AUTO_REGISTER, + ) + jwt_claims = {"email": "user@example.com"} + + with pytest.raises(HTTPException) as exc_info: + await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=MagicMock(), + user_api_key_cache=DualCache(), + parent_otel_span=None, + proxy_logging_obj=None, + ) + assert exc_info.value.status_code == 403 + assert "missing from the JWT" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_fallback_team_mapping_returns_none_when_claim_field_missing_from_jwt(): + """ + Under FALLBACK_TEAM_MAPPING (the default, backward-compatible mode), a JWT + without the configured claim field must still fall through to team-based + JWT auth — not raise. This preserves the pre-existing contract. + """ + from litellm.proxy._types import UnregisteredJWTClientBehavior + + jwt_handler = JWTHandler() + jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth( + virtual_key_claim_field="sub", + unregistered_jwt_client_behavior=UnregisteredJWTClientBehavior.FALLBACK_TEAM_MAPPING, + ) + jwt_claims = {"email": "user@example.com"} + + result = await _resolve_jwt_to_virtual_key( + jwt_claims=jwt_claims, + jwt_handler=jwt_handler, + prisma_client=MagicMock(), + user_api_key_cache=DualCache(), + parent_otel_span=None, + proxy_logging_obj=None, + ) + assert result is None + + @pytest.mark.asyncio async def test_fallback_team_mapping_returns_none_when_prisma_client_is_none(): """