From 968835b5e42fc11cd43f4b40320cdd28f78d316e Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Tue, 16 Jun 2026 00:43:00 +0000 Subject: [PATCH] fix(proxy): apply full key/team/user filter on by-id /v1/model/info MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses veria-ai Finding 2 on #29748. The 5638d58e bypass keyed off `user_api_key_dict.user_id is None` to short-circuit by-id authorization, but service-account virtual keys can carry `user_id=None` AND still have `models` / `team_models` restrictions — so a restricted service account could pull metadata for any deployment via `/v1/model/info?litellm_model_id=`. Drop the explicit master-key short-circuit and call `get_available_models_for_user` unconditionally — same call the listing path uses. `_apply_user_models_filter` already bypasses the user.models step when `user_id is None` (master key / service account), but `get_key_models` + `get_team_models` still narrow to the key/team allowlist for service accounts. By-id authorization now tracks the listing path exactly. Test fixes: - `test_model_info_endpoint_returns_defaults_for_specific_model_id`: add the missing patches for `get_key_models` / `get_team_models` / `get_complete_model_list` + stub `mock_router.get_model_names` / `.get_model_access_groups` so by-id authorization sees a valid allowlist. 18/18 pass locally. 403 message broadened to "not authorized for this key/user" since the rejection can fire on key/team restrictions, not just user.models. --- litellm/proxy/proxy_server.py | 54 ++++++++++--------- .../proxy/test_model_info_default_limits.py | 13 +++++ 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 682d3e47711..b2b38b7bd4b 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13445,33 +13445,37 @@ async def model_info_v1( "error": f"Model id = {litellm_model_id} not found on litellm proxy" }, ) - # Authorize against user.models — by-id was previously - # short-circuiting before the listing-path filter. Master - # key / service accounts (no user_id) bypass, matching - # `_apply_user_models_filter` semantics. - if user_api_key_dict.user_id is not None: - from litellm.proxy.utils import get_available_models_for_user + # Authorize against key/team/user models — by-id was previously + # short-circuiting before the listing-path filter, letting a + # restricted virtual key (including service accounts with no + # user_id but with key.models or team_models constraints) pull + # metadata for any deployment via /v1/model/info?litellm_model_id=. + # Reuse `get_available_models_for_user` so by-id authorization + # tracks the listing-path filter exactly: key.models, team_models, + # and user.models all narrow the allowlist; `_apply_user_models_filter` + # bypasses the user.models step when user_id is absent. + from litellm.proxy.utils import get_available_models_for_user - authorized_models = await get_available_models_for_user( - user_api_key_dict=user_api_key_dict, - llm_router=llm_router, - general_settings=general_settings, - user_model=user_model, - prisma_client=prisma_client, - proxy_logging_obj=proxy_logging_obj, - team_id=None, - include_model_access_groups=False, - only_model_access_groups=False, - return_wildcard_routes=False, - user_api_key_cache=user_api_key_cache, + authorized_models = await get_available_models_for_user( + user_api_key_dict=user_api_key_dict, + llm_router=llm_router, + general_settings=general_settings, + user_model=user_model, + prisma_client=prisma_client, + proxy_logging_obj=proxy_logging_obj, + team_id=None, + include_model_access_groups=False, + only_model_access_groups=False, + return_wildcard_routes=False, + user_api_key_cache=user_api_key_cache, + ) + if deployment_info.model_name not in authorized_models: + raise HTTPException( + status_code=403, + detail={ + "error": f"Model id = {litellm_model_id} not authorized for this key/user" + }, ) - if deployment_info.model_name not in authorized_models: - raise HTTPException( - status_code=403, - detail={ - "error": f"Model id = {litellm_model_id} not authorized for this user" - }, - ) _deployment_info_dict = _get_proxy_model_info( model=deployment_info.model_dump(exclude_none=True) ) diff --git a/tests/test_litellm/proxy/test_model_info_default_limits.py b/tests/test_litellm/proxy/test_model_info_default_limits.py index 8111a7af006..470c34b680f 100644 --- a/tests/test_litellm/proxy/test_model_info_default_limits.py +++ b/tests/test_litellm/proxy/test_model_info_default_limits.py @@ -115,6 +115,11 @@ class TestModelInfoEndpointWithRouter: mock_router = MagicMock() mock_router.get_deployment.return_value = deployment + # By-id now authorizes against the listing-path filter; stub + # what `get_available_models_for_user` reads from the router so + # "model1" is in the allowlist. + mock_router.get_model_names.return_value = ["model1"] + mock_router.get_model_access_groups.return_value = {} user_api_key_dict = UserAPIKeyAuth(api_key="sk-test") @@ -122,6 +127,14 @@ class TestModelInfoEndpointWithRouter: patch("litellm.proxy.proxy_server.llm_router", mock_router), patch("litellm.proxy.proxy_server.llm_model_list", []), patch("litellm.proxy.proxy_server.user_model", None), + patch("litellm.proxy.proxy_server.get_key_models", return_value=["model1"]), + patch( + "litellm.proxy.proxy_server.get_team_models", return_value=["model1"] + ), + patch( + "litellm.proxy.proxy_server.get_complete_model_list", + return_value=["model1"], + ), ): response = await model_info_v1( user_api_key_dict=user_api_key_dict,