Fix info disclosure via status codes and remove redundant user_id field

Move access control check before DB query for non-admin, non-self requests
so unauthorized callers always get 403 (cannot distinguish "not found" from
"not authorized"). Remove top-level user_id from UserInfoV2Response since
it already exists inside user_info.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
yuneng-jiang 2026-03-05 15:45:17 -08:00
parent f4d29cc268
commit 4ff02a4c65
3 changed files with 21 additions and 20 deletions

View file

@ -770,22 +770,18 @@ async def user_info_v2(
detail="user_id is required",
)
user_info = await prisma_client.get_data(user_id=user_id)
# Access control check before DB query to avoid information disclosure
# (different error codes for "not found" vs "not authorized")
is_proxy_admin = user_api_key_dict.user_role in (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
)
is_self = user_id == user_api_key_dict.user_id
if user_info is None:
raise HTTPException(
status_code=404,
detail=f"User {user_id} not found",
)
# Access control: non-admin users can only query their own info
# or info of users in teams they admin
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
and user_id != user_api_key_dict.user_id
):
if not await _is_team_admin_for_user(
if not is_proxy_admin and not is_self:
# Need to check team admin status — fetch user first
user_info = await prisma_client.get_data(user_id=user_id)
if user_info is None or not await _is_team_admin_for_user(
user_api_key_dict=user_api_key_dict,
target_user_teams=user_info.teams,
prisma_client=prisma_client,
@ -796,9 +792,15 @@ async def user_info_v2(
user_api_key_dict.user_id, user_id
),
)
else:
user_info = await prisma_client.get_data(user_id=user_id)
if user_info is None:
raise HTTPException(
status_code=404,
detail=f"User {user_id} not found",
)
return UserInfoV2Response(
user_id=user_id,
user_info=user_info.model_dump(),
)
except Exception as e:

View file

@ -31,7 +31,6 @@ class UserInfoV2Response(BaseModel):
via /key/list and /v2/team/list respectively.
"""
user_id: str
user_info: LiteLLM_UserTable

View file

@ -1336,7 +1336,7 @@ async def test_user_info_v2_returns_user_profile(mocker):
request=mock_request,
)
assert response.user_id == "test-user-123"
assert response.user_info.user_id == "test-user-123"
assert response.user_info.user_email == "test@example.com"
assert response.user_info.teams == ["team-1", "team-2"]
# Verify no keys or teams fields on the response
@ -1383,7 +1383,7 @@ async def test_user_info_v2_falls_back_to_caller_user_id(mocker):
request=mock_request,
)
assert response.user_id == "caller-user-id"
assert response.user_info.user_id == "caller-user-id"
assert response.user_info.user_email == "caller@example.com"
@ -1606,7 +1606,7 @@ async def test_user_info_v2_team_admin_can_query_team_member(mocker):
request=mock_request,
)
assert result.user_id == "user-B"
assert result.user_info.user_id == "user-B"
@pytest.mark.asyncio