mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
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:
parent
bf55895147
commit
2c23811880
2 changed files with 106 additions and 0 deletions
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue