fix(jwt): close REJECT bypass when JWT omits the configured claim field

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) <noreply@anthropic.com>
This commit is contained in:
shivam 2026-05-21 16:18:21 -07:00
parent bf55895147
commit 2c23811880
No known key found for this signature in database
2 changed files with 106 additions and 0 deletions

View file

@ -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}"

View file

@ -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():
"""