fix(proxy): scope file list pagination cursors to the caller

GET /v1/files filters data down to the caller's own managed files but left first_id and last_id as the upstream page's, so a non-owner got back file ids belonging to other users even with an empty data array
This commit is contained in:
devin-ai-integration[bot] 2026-08-06 15:33:40 +00:00 committed by GitHub
parent b66d4e6965
commit 357f90fa39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 115 additions and 0 deletions

View file

@ -1270,10 +1270,25 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
)
## Filter the response to only include the files created by the user
response.data = user_created_file_ids # type: ignore
self._scope_list_page_cursors(response, user_created_file_ids)
return response
return response
return response
@staticmethod
def _scope_list_page_cursors(response: AsyncCursorPage, data: List[OpenAIFileObject]) -> None:
"""Rebuild ``first_id`` / ``last_id`` from the caller-scoped page.
The upstream cursors point at rows that were just filtered out, so
leaving them in place discloses other callers' file ids.
"""
if hasattr(response, "first_id"):
response.first_id = data[0].id if data else None
if hasattr(response, "last_id"):
response.last_id = data[-1].id if data else None
if not data and hasattr(response, "has_more"):
response.has_more = False
async def afile_retrieve(
self, file_id: str, litellm_parent_otel_span: Optional[Span], llm_router=None
) -> OpenAIFileObject:

View file

@ -2861,3 +2861,103 @@ async def test_same_user_different_keys_can_access_batch():
assert "batch_id" in result2
# Both keys should get the same result
assert result1["batch_id"] == result2["batch_id"]
@pytest.mark.asyncio
async def test_file_list_cursors_are_scoped_to_the_caller():
"""A non-owner must not learn other callers' file ids through the page cursors."""
from openai.pagination import AsyncCursorPage
from openai.types import FileObject
from litellm.proxy._types import UserAPIKeyAuth
owner_file = FileObject(
id="file-owner-1",
bytes=100,
created_at=1,
filename="owner.jsonl",
object="file",
purpose="batch",
status="processed",
)
upstream_page = AsyncCursorPage[FileObject].construct(
data=[owner_file],
has_more=True,
first_id=owner_file.id,
last_id=owner_file.id,
object="list",
)
prisma_client = AsyncMock()
prisma_client.db.litellm_managedfiletable.find_many.return_value = []
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
response = await proxy_managed_files.async_post_call_success_hook(
data={},
user_api_key_dict=UserAPIKeyAuth(
user_id="other-user", team_id="other-team", parent_otel_span=MagicMock()
),
response=upstream_page,
)
assert response.data == []
assert response.first_id is None
assert response.last_id is None
assert response.has_more is False
@pytest.mark.asyncio
async def test_file_list_cursors_follow_the_owner_scoped_page():
from openai.pagination import AsyncCursorPage
from openai.types import FileObject
from litellm.proxy._types import UserAPIKeyAuth
def _raw_file(file_id: str) -> FileObject:
return FileObject(
id=file_id,
bytes=100,
created_at=1,
filename=f"{file_id}.jsonl",
object="file",
purpose="batch",
status="processed",
)
upstream_page = AsyncCursorPage[FileObject].construct(
data=[_raw_file("file-someone-else"), _raw_file("file-mine")],
has_more=False,
first_id="file-someone-else",
last_id="file-mine",
object="list",
)
managed_row = MagicMock()
managed_row.file_object = {
"id": "litellm_proxy:mine",
"bytes": 100,
"created_at": 1,
"filename": "mine.jsonl",
"object": "file",
"purpose": "batch",
"status": "processed",
}
prisma_client = AsyncMock()
prisma_client.db.litellm_managedfiletable.find_many.return_value = [managed_row]
proxy_managed_files = _PROXY_LiteLLMManagedFiles(
DualCache(), prisma_client=prisma_client
)
response = await proxy_managed_files.async_post_call_success_hook(
data={},
user_api_key_dict=UserAPIKeyAuth(
user_id="mine-user", parent_otel_span=MagicMock()
),
response=upstream_page,
)
assert [file_object.id for file_object in response.data] == ["litellm_proxy:mine"]
assert response.first_id == "litellm_proxy:mine"
assert response.last_id == "litellm_proxy:mine"