fix(auth): keep blanket wildcards and team-less jwts out of the passthrough grant

A team_allowed_routes entry that names no path segment, such as * or /*, is a blanket grant like a named route group, so it no longer opens auth=true passthroughs. The grant in the shared route check now also requires a team on the JWT token, because team_allowed_routes should not apply to a JWT that resolved no team
This commit is contained in:
ryan-crabbe-berri 2026-09-21 15:28:25 -07:00
parent a8003102b2
commit dac88cc6d0
3 changed files with 27 additions and 15 deletions

View file

@ -1693,7 +1693,7 @@ class JWTAuthManager:
team_object=team_object,
route=route,
request_method=request_method,
team_allowed_routes=jwt_handler.litellm_jwtauth.team_allowed_routes or (),
team_allowed_routes=jwt_handler.litellm_jwtauth.team_allowed_routes,
):
is_allowed = False
denied_auth_enforced_pass_through_route = True
@ -2589,7 +2589,7 @@ class JWTAuthManager:
team_object=team_object,
route=route,
request_method=request_method,
team_allowed_routes=handler.litellm_jwtauth.team_allowed_routes or (),
team_allowed_routes=handler.litellm_jwtauth.team_allowed_routes,
):
JWTAuthManager._raise_team_passthrough_route_denial(route=route)
@ -2659,7 +2659,7 @@ class JWTAuthManager:
team_object=team_object,
route=route,
request_method=request_method,
team_allowed_routes=handler.litellm_jwtauth.team_allowed_routes or (),
team_allowed_routes=handler.litellm_jwtauth.team_allowed_routes,
):
JWTAuthManager._raise_team_passthrough_route_denial(route=route)
elif team_id is None:

View file

@ -686,23 +686,25 @@ class RouteChecks:
@staticmethod
def jwt_team_routes_grant_pass_through(route: str, team_allowed_routes: Collection[str]) -> bool:
"""
Explicit paths and trailing-wildcard prefixes grant auth=true pass-through. Entries are only ever compared
as paths, so a named route group like ``openai_routes`` never grants.
Explicit paths and trailing-wildcard prefixes grant auth=true pass-through. Blanket grants never do:
a named route group like ``openai_routes`` is only ever compared as a path, and an entry that names
no path segment (``*``, ``/*``) is skipped.
"""
return any(
RouteChecks.route_matches_wildcard_pattern(route=route, pattern=allowed_route)
for allowed_route in team_allowed_routes
if allowed_route.rstrip("*").strip("/")
)
@staticmethod
def _jwt_team_allowed_routes(valid_token: UserAPIKeyAuth) -> Collection[str]:
"""``team_allowed_routes`` for tokens built by JWT auth; JWT-mapped virtual keys stay key-scoped."""
if valid_token.jwt_claims is None or valid_token.token is not None:
"""``team_allowed_routes`` for team tokens built by JWT auth; JWT-mapped virtual keys stay key-scoped."""
if valid_token.jwt_claims is None or valid_token.token is not None or valid_token.team_id is None:
return ()
from litellm.proxy.proxy_server import jwt_handler
return jwt_handler.litellm_jwtauth.team_allowed_routes or ()
return jwt_handler.litellm_jwtauth.team_allowed_routes
@staticmethod
def _require_auth_pass_through_access(

View file

@ -1272,6 +1272,8 @@ def test_non_proxy_admin_allows_auth_pass_through_with_team_allowlist():
("/model-host/v1/extractor/predict", ["/model-host/v1/extractor"], False),
("/other/v1/extractor", ["/model-host/*"], False),
("/model-host/v1/extractor", ["openai_routes", "llm_api_routes", "mapped_pass_through_routes"], False),
("/model-host/v1/extractor", ["*"], False),
("/model-host/v1/extractor", ["/*"], False),
("/model-host/v1/extractor", [], False),
],
)
@ -1350,20 +1352,28 @@ def test_non_proxy_admin_denies_auth_pass_through_for_jwt_when_only_route_groups
@pytest.mark.parametrize(
"jwt_claims",
[None, {"sub": "test_user"}],
ids=["plain_virtual_key", "jwt_mapped_virtual_key"],
"api_key, team_id, jwt_claims",
[
("sk-test-key", "team-a", None),
("sk-test-key", "team-a", {"sub": "test_user"}),
(None, "team-a", None),
(None, None, {"sub": "test_user"}),
],
ids=["plain_virtual_key", "jwt_mapped_virtual_key", "keyless_non_jwt_caller", "jwt_without_team"],
)
def test_non_proxy_admin_jwt_team_allowed_routes_never_grant_pass_through_to_virtual_keys(jwt_claims):
virtual_key: Final = UserAPIKeyAuth(
api_key="sk-test-key",
def test_non_proxy_admin_jwt_team_allowed_routes_grant_pass_through_only_to_jwt_team_callers(
api_key, team_id, jwt_claims
):
caller: Final = UserAPIKeyAuth(
api_key=api_key,
user_id="test_user",
user_role=LitellmUserRoles.INTERNAL_USER.value,
team_id=team_id,
jwt_claims=jwt_claims,
)
with pytest.raises(HTTPException) as exc_info:
_check_model_host_route_as(virtual_key, team_allowed_routes=["openai_routes", "/model-host/*"])
_check_model_host_route_as(caller, team_allowed_routes=["openai_routes", "/model-host/*"])
assert exc_info.value.status_code == 403, exc_info.value.detail
assert "allowed_passthrough_routes" in exc_info.value.detail