fix(proxy): close by-id authorization bypass on /v1/model/info

The `litellm_model_id` query branch in `model_info_v1` returned
deployment metadata before the listing-path user-filter ever ran,
so a user restricted by `LiteLLM_UserTable.models` could pull info
for any deployment via `/v1/model/info?litellm_model_id=<id>`.

Reported by @veria-ai on this PR. Fix in-place rather than as a
follow-up since closing the discovery-vs-inference gap is the PR's
whole point.

- After `llm_router.get_deployment(...)` resolves, call
  `get_available_models_for_user` (same signature as the listing
  path) and intersect against `deployment_info.model_name`.
- If not in the authorized list, raise `HTTPException(403, ...)`
  with a "not authorized" message — 403, not 404, since the user
  IS authenticated, matching inference-time `can_user_call_model`.
- Admin / master-key unaffected: `_apply_user_models_filter`
  returns `all_models` unchanged when `user_api_key_dict.user_id`
  is None.

Tests (`test_v1_model_info_user_filter.py`):
- `test_v1_model_info_by_id_denied_when_model_not_in_user_models`
- `test_v1_model_info_by_id_allowed_when_model_in_user_models`
- `test_v1_model_info_by_id_master_key_bypass`

11/11 pass locally.
This commit is contained in:
songkuan-zheng 2026-06-15 13:45:42 +00:00
parent 6137e2f467
commit c4ee909407
2 changed files with 90 additions and 0 deletions

View file

@ -13445,6 +13445,30 @@ 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.
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,
)
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)
)

View file

@ -236,3 +236,69 @@ def test_v1_model_info_alias_route_filters_identically(
resp = client.get("/model/info", headers={"Authorization": "Bearer sk-test"})
assert resp.status_code == 200
assert _model_names(resp.json()) == ["claude-3-opus"]
# ---------------------------------------------------------------------------
# /v1/model/info Path A (?litellm_model_id=...) — by-id authorization
# ---------------------------------------------------------------------------
def _deployment_id(router, model_name):
for d in router.model_list:
if d["model_name"] == model_name:
return d["model_info"]["id"]
raise AssertionError(f"model {model_name!r} not in router")
def test_v1_model_info_by_id_denied_when_model_not_in_user_models(
client, configure_router, monkeypatch
):
"""Regression: by-id lookup must respect user.models (veria-ai #29748)."""
_override_auth(user_id="u-test")
_patch_user(monkeypatch, models=["gpt-4"])
disallowed_id = _deployment_id(configure_router, "claude-3-opus")
resp = client.get(
f"/v1/model/info?litellm_model_id={disallowed_id}",
headers={"Authorization": "Bearer sk-test"},
)
assert resp.status_code == 403
assert "not authorized" in resp.text.lower()
def test_v1_model_info_by_id_allowed_when_model_in_user_models(
client, configure_router, monkeypatch
):
"""By-id lookup for a deployment whose model_name is in user.models -> 200."""
_override_auth(user_id="u-test")
_patch_user(monkeypatch, models=["claude-3-opus"])
allowed_id = _deployment_id(configure_router, "claude-3-opus")
resp = client.get(
f"/v1/model/info?litellm_model_id={allowed_id}",
headers={"Authorization": "Bearer sk-test"},
)
assert resp.status_code == 200
assert _model_names(resp.json()) == ["claude-3-opus"]
def test_v1_model_info_by_id_master_key_bypass(client, configure_router, monkeypatch):
"""Master key / service account (user_id=None) -> by-id lookup unrestricted."""
async def _should_not_be_called(*args, **kwargs):
raise AssertionError("get_user_object must not be called when user_id is None")
monkeypatch.setattr(
"litellm.proxy.auth.auth_checks.get_user_object",
_should_not_be_called,
)
_override_auth(user_id=None)
any_id = _deployment_id(configure_router, "claude-3-opus")
resp = client.get(
f"/v1/model/info?litellm_model_id={any_id}",
headers={"Authorization": "Bearer sk-test"},
)
assert resp.status_code == 200
assert _model_names(resp.json()) == ["claude-3-opus"]