fix(spend-logs): deny detail view when row missing to block custom-logger leak

`_assert_user_can_view_request_id` previously returned silently when the
`LiteLLM_SpendLogs` row was absent, after which the handler walked
`get_request_response_payload` on every active custom logger (S3, GCS,
langsmith, …) keyed by request_id — letting any non-admin read another
user's messages/response when the DB row had expired or failed to write.
Raise 403 on the missing-row branch so the lookup cannot fall through to
external log storage without ownership being established first.
This commit is contained in:
oopsoonk 2026-05-19 09:33:15 +08:00
parent 8ba878bb0b
commit 04599034d8
2 changed files with 42 additions and 1 deletions

View file

@ -3571,13 +3571,25 @@ async def _assert_user_can_view_request_id(
Allowed when the log belongs to the user directly, or to one of their
permitted teams (admin or ``/spend/logs`` permission).
Raises HTTP 403 if not.
If the row is missing (expired, never written, or unknown request_id) we
cannot establish ownership, so we must deny — the caller goes on to query
custom loggers (S3, GCS, …) by request_id, and silently allowing here
would let any non-admin read another user's request/response payload.
"""
row = await prisma_client.db.litellm_spendlogs.find_unique(
where={"request_id": request_id},
include=None,
)
if row is None:
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Not authorized to view spend log for request_id={}".format(
request_id
)
},
)
if row.user is not None and row.user == user_api_key_dict.user_id:
return

View file

@ -318,6 +318,35 @@ async def test_assert_user_can_view_request_id_rejects_both_users_none():
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_assert_user_can_view_request_id_denies_when_row_missing():
"""
If the spend-log row is absent (expired, never written, or unknown id) we
cannot establish ownership. Returning silently would let the handler fall
through to custom loggers (S3, GCS, langsmith, …) by request_id, leaking
another user's messages/response. Must deny instead.
"""
class MockSpendLogs:
async def find_unique(self, where, include=None):
return None
class MockDB:
def __init__(self):
self.litellm_spendlogs = MockSpendLogs()
class MockPrisma:
def __init__(self):
self.db = MockDB()
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1")
with pytest.raises(HTTPException) as exc_info:
await spend_management_endpoints._assert_user_can_view_request_id(
MockPrisma(), auth, "req-missing"
)
assert exc_info.value.status_code == 403
def test_ui_view_request_response_forbids_non_admin_without_db(client, monkeypatch):
"""
Without prisma, non-admins cannot be authorized to read request/response