From dd720b6385bcc4d5bce24cb066fa106b4e04ad30 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 19 Aug 2026 17:07:40 -0700 Subject: [PATCH] fix(auth): match templated info routes by pattern is_info_route compared the request path against info_routes with a bare `in`, so an entry carrying a path parameter could never match: the incoming route holds a resolved id, not the `{key_id}` template. /key/{key_id}/budgets was therefore refused for the view-only, team and customer roles that /key/info serves, and its info_routes entry matched nothing at all. Route it through check_route_access, the way is_management_route already does. It is the only templated entry in the list today, so no other route changes reachability. --- litellm/proxy/auth/route_checks.py | 5 ++- .../proxy/auth/test_info_routes.py | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 04eb7ab326b..9758162e753 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -440,8 +440,11 @@ class RouteChecks: def is_info_route(route: str) -> bool: """ Check if route is an info route + + Pattern-aware, like ``is_management_route``, so an info route carrying a path parameter is as + reachable as one without: the incoming route holds a resolved id, never the ``{...}`` template. """ - return route in LiteLLMRoutes.info_routes.value + return RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.info_routes.value) @staticmethod def _is_azure_openai_route(route: str) -> bool: diff --git a/tests/test_litellm/proxy/auth/test_info_routes.py b/tests/test_litellm/proxy/auth/test_info_routes.py index 416924b5f76..79a73f663e5 100644 --- a/tests/test_litellm/proxy/auth/test_info_routes.py +++ b/tests/test_litellm/proxy/auth/test_info_routes.py @@ -149,3 +149,48 @@ def test_v2_user_info_route_access(): valid_token=valid_token, request_data={}, ) + + +@pytest.mark.parametrize( + "role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + LitellmUserRoles.CUSTOMER, + None, + ], +) +def test_templated_info_route_is_as_reachable_as_a_plain_one(role): + """ + A resolved path parameter must not make an info route less reachable than one without. + + `is_info_route` compared the request path against the route list by exact string, so + `/key/{key_id}/budgets` never matched a real request and callers who could read /key/info + were refused on the equivalent budgets route. + """ + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=role.value if role is not None else None, + max_budget=None, + spend=0.0, + models=[], + ) + + for route in ("/key/info", "/key/some-resolved-key-hash/budgets", "/key/budgets"): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=role, + route=route, + request=MagicMock(spec=Request), + valid_token=UserAPIKeyAuth(api_key="sk-test"), + request_data={}, + ) + + +def test_templated_route_matching_does_not_widen_unrelated_key_routes(): + """Pattern matching must stay scoped to the entries in the list, not every /key// path.""" + assert RouteChecks.is_info_route("/key/some-hash/regenerate") is False + assert RouteChecks.is_info_route("/key/some-hash/delete") is False + assert RouteChecks.is_info_route("/key/some-hash/budgets") is True