From dac88cc6d0b3aa1fd869da8c0fd29d706a1ea594 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Mon, 21 Sep 2026 15:28:25 -0700 Subject: [PATCH] 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 --- litellm/proxy/auth/handle_jwt.py | 6 ++--- litellm/proxy/auth/route_checks.py | 12 ++++++---- .../proxy/auth/test_route_checks.py | 24 +++++++++++++------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 2ae21bd0785..010a1b4536e 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -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: diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 009ea986aad..7b39b68633d 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -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( diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 2a92f3d7fbe..92b52f0e8e5 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -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