From da700843cbd28291dddf77e274c5f03d61049a91 Mon Sep 17 00:00:00 2001 From: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:56:15 +0000 Subject: [PATCH] fix(proxy): close 2 leak vectors flagged by veria-ai on #29748 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit veria-ai's review of a501b0e34e raised two Medium-severity findings: 1. /v2/model/info default branch did not apply the BYOK team-scope filter when caller's key/team/user model lists were all empty. Result: a virtual key with empty restrictions could see other teams' BYOK deployment metadata (api_base, model_info.id, etc.). 2. /v1/model/info?litellm_model_id= (by-id branch) applied only the user.models filter after the option-B refactor, skipping key.models / team_models / BYOK scope. A key restricted by key.models with no personal model restriction could pull metadata for any deployment. Finding #2 is a regression introduced by switching the by-id branch from a 403 gate to filter-chain semantics; the option-B refactor applied only the personal-models filter and missed the listing-path's key/team intersection plus the BYOK scope filter that the v1 default branch already used. Fix mirrors the v1 default branch's filter chain on the by-id path and adds the BYOK filter to v2: - v1 by-id: insert _get_v1_model_info_allowed_model_names + _filter_v1_model_info_deployments (key/team), keep the existing user.models filter, append the BYOK _get_caller_byok_team_scope + _byok_row_outside_caller_teams filter. - v2 default: append the same BYOK filter after user.models. All filters use existing helpers; no new code paths introduced. Filter (drop deployment → empty data) preserved instead of 403, per the route_checks.py:189 spec. Local verification: 87/87 across - tests/test_litellm/proxy/discovery_endpoints/ - tests/test_litellm/proxy/proxy_server/test_routes_model_info.py - tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py --- litellm/proxy/proxy_server.py | 56 +++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0f4bc6b8d50..99f24e41e1d 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -12707,6 +12707,24 @@ async def model_info_v2( user_models_override=user_models_for_filter, ) + # Team BYOK deployments carry an internal routing key and other teams' + # public name / team_id / api_base; drop the ones the caller cannot + # access so a bare /v2/model/info (no user_models_only, no + # include_team_models) does not leak cross-team metadata when the + # caller's key/team/user lists are empty. Mirrors the v1 default + # branch behavior below. + allowed_team_ids = await _get_caller_byok_team_scope( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + all_models = [ + model + for model in all_models + if not _byok_row_outside_caller_teams( + model.get("model_info") or {}, allowed_team_ids + ) + ] + # Apply teamId filter if provided if teamId is not None and teamId.strip(): all_models = await _filter_models_by_team_id( @@ -13474,16 +13492,24 @@ async def model_info_v1( llm_router=llm_router, user_api_key_dict=user_api_key_dict, ) - # Bound by LiteLLM_UserTable.models (Personal Models) using the - # same deployment-level filter as the listing branch. by-id used - # to short-circuit before this filter, letting a restricted user - # pull any deployment's litellm_params via /v1/model/info?litellm_model_id= - # — same leak BerriAI/litellm#26420 fixed for /v1/models. - # - # Filter (drop unauthorized deployments → empty list) instead of - # 403: matches the route_checks.py:189 spec comment "/model/info - # just shows models user has access to" and avoids leaking model + # Bound by the same filter chain as the listing branch below + # (key.models / team_models, then LiteLLM_UserTable.models, then + # BYOK team scope). by-id used to short-circuit before any of + # these, letting a restricted virtual key pull any deployment's + # litellm_params via /v1/model/info?litellm_model_id=. Filter + # (drop unauthorized deployments → empty list) instead of 403: + # matches the route_checks.py:189 spec comment "/model/info just + # shows models user has access to" and avoids leaking model # existence via 403-vs-404 enumeration. + allowed_model_names = _get_v1_model_info_allowed_model_names( + user_api_key_dict=user_api_key_dict, + llm_router=llm_router, + ) + single_model_list = _filter_v1_model_info_deployments( + all_models=single_model_list, + allowed_model_names=allowed_model_names, + ) + from litellm.proxy.utils import apply_user_models_filter_to_deployments single_model_list = await apply_user_models_filter_to_deployments( @@ -13494,6 +13520,18 @@ async def model_info_v1( proxy_logging_obj=proxy_logging_obj, user_api_key_cache=user_api_key_cache, ) + + allowed_team_ids = await _get_caller_byok_team_scope( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + single_model_list = [ + model + for model in single_model_list + if not _byok_row_outside_caller_teams( + model.get("model_info") or {}, allowed_team_ids + ) + ] return {"data": single_model_list} # Return router deployments (same source as /v2/model/info), not wildcard-