Skip to content

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

Merged
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
devin_ai_fix_file_list_cursor_leak_36087
Aug 9, 2026
Merged

fix(proxy): scope file list pagination cursors to the caller#36093
mateo-berri merged 3 commits into
litellm_internal_stagingfrom
devin_ai_fix_file_list_cursor_leak_36087

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • GET /v1/files cursors leaked other users' file ids
  • data was scoped to the caller, first_id / last_id were not

How it solves it:

  • Rebuild the cursors from the caller-scoped page after filtering
  • Empty page now returns null cursors and has_more: false

User Flow

Before: a key that owns no files still reads other users' raw provider file ids out of the pagination cursors of its empty file list

  1. The admin mints two virtual keys for different users on different teams with POST https://litellm-domain/key/generate, one for owner-user on team A and one for other-user on team B
  2. The owner uploads a file with POST https://litellm-domain/v1/files (multipart form: purpose=batch, file=@batch.jsonl, target_model_names=<their model>) and gets back 200 with a file id
  3. The other user lists files with GET https://litellm-domain/v1/files and gets 200 with "data": [], but first_id and last_id carry raw file-... ids belonging to the owner's upload
  4. The same cursors come back from the alias GET https://litellm-domain/openai/v1/files
  5. The other user now holds a raw provider file id they were never shown a file for, an actionable handle to target another user's file with

After: the same empty list carries null cursors, so a non-owner learns nothing from it

  1. The admin mints two virtual keys for different users on different teams with POST https://litellm-domain/key/generate, one for owner-user on team A and one for other-user on team B
  2. The owner uploads a file with POST https://litellm-domain/v1/files (multipart form: purpose=batch, file=@batch.jsonl, target_model_names=<their model>) and gets back 200 with a file id
  3. The other user lists files with GET https://litellm-domain/v1/files and gets 200 with "data": [], "first_id": null, "last_id": null, and "has_more": false
  4. The alias GET https://litellm-domain/openai/v1/files returns the same null cursors
  5. The owner listing with their own key still gets their files in data, with first_id and last_id matching the first and last ids visible in that same data
  6. The other user can no longer harvest any file id from list responses, so there is no handle left to target another user's file with

Relevant issues

Fixes #36087

Linear ticket

Pre-Submission checklist

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review

Screenshots / Proof of Fix

Two live proxies against real api.openai.com (real uploads, real account, no mocks and no local stand-in server), each in its own worktree with its own venv, its own empty postgres database, and its own port. Both legs drive the exact User Flow above with plain curl, differing only in the commit the proxy was booted from.

Actors in both legs are two non-admin virtual keys with distinct user_id on distinct team_id: OWNER qa36093-owner on team A, OTHER qa36093-other on team B.

Before, at efc4e6f28c (the merge base of this branch, i.e. without the fix)

The owner uploads two batch files, which real OpenAI mints as file-QvsCr8gY6mWS5Ne2GwS6Pz and file-W9z9L1f2e4TdXVqEocSc1E:

$ curl -sS -X POST http://localhost:49382/v1/files \
    -H "Authorization: Bearer $OWNER_KEY" \
    -F "purpose=batch" -F "file=@qa36093_batch_2.jsonl" \
    -F "target_model_names=gpt-5.4-nano"
{"id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCwwMmFkY2Y4MS0zZDQyLTRhMDAtODVkMy1hNzJlM2RmOWVkZjc7...","bytes":162,"created_at":1786234517,"filename":"modified_file.jsonl","object":"file","purpose":"batch","status":"uploaded","expires_at":1788826517,"status_details":null}

The other user then lists, on both routes:

$ curl -sS http://localhost:49382/v1/files -H "Authorization: Bearer $OTHER_KEY"
{"data":[],"has_more":true,"object":"list","first_id":"file-W9z9L1f2e4TdXVqEocSc1E","last_id":"file-VSynJKk2RVneFryB5wR1Fg"}

$ curl -sS http://localhost:49382/openai/v1/files -H "Authorization: Bearer $OTHER_KEY"
{"data":[],"has_more":true,"object":"list","first_id":"file-W9z9L1f2e4TdXVqEocSc1E","last_id":"file-VSynJKk2RVneFryB5wR1Fg"}

Cross-user provenance was proven rather than assumed, in the same shape the issue used:

{"data_len": 0, "first_id": "file-W9z9L1f2e4TdXVqEocSc1E", "last_id": "file-VSynJKk2RVneFryB5wR1Fg", "owner_ids_this_run": ["file-QvsCr8gY6mWS5Ne2GwS6Pz", "file-W9z9L1f2e4TdXVqEocSc1E"], "leaked_ids_belonging_to_owner": ["file-W9z9L1f2e4TdXVqEocSc1E"], "cross_user_leak_proven": true}

A direct GET https://api.openai.com/v1/files taken before the uploads contained neither owner id, so both are genuinely new this run, and retrieving file-W9z9L1f2e4TdXVqEocSc1E straight from OpenAI returns created_at: 1786234517, byte for byte the owner's second upload.

Two things beyond what the issue reported. last_id here is file-VSynJKk2RVneFryB5wR1Fg, a pre-existing fine-tune file belonging to neither key, so the cursors disclose arbitrary ids from the shared upstream account rather than only the other tenant's. And the owner's own list leaks that same foreign id, so even a legitimate lister received ids they have no claim to:

$ curl -sS http://localhost:49382/v1/files -H "Authorization: Bearer $OWNER_KEY"
{"data":[{"id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07...","bytes":159,...},{"id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07...","bytes":162,...}],"has_more":true,"object":"list","first_id":"file-W9z9L1f2e4TdXVqEocSc1E","last_id":"file-VSynJKk2RVneFryB5wR1Fg"}

After, at 2112422c71 (this branch's tip)

Same flow, same two-user setup, owner uploads mint file-LH2Xc1iUcDgZmDoT2VjjW2 and file-HkwW8ByqaNC1WUKsLLnGFy:

$ curl -sS http://localhost:49383/v1/files -H "Authorization: Bearer $OTHER_KEY"
{"data":[],"has_more":false,"object":"list","first_id":null,"last_id":null}

$ curl -sS http://localhost:49383/openai/v1/files -H "Authorization: Bearer $OTHER_KEY"
{"data":[],"has_more":false,"object":"list","first_id":null,"last_id":null}
{"data_len": 0, "first_id": null, "last_id": null, "owner_ids_this_run": ["file-LH2Xc1iUcDgZmDoT2VjjW2", "file-HkwW8ByqaNC1WUKsLLnGFy"], "leaked_ids_belonging_to_owner": [], "cross_user_leak_proven": false}

The null cursors are the fix rather than an empty upstream. A control GET https://api.openai.com/v1/files taken during the run returned 5450 entries with first_id: file-HkwW8ByqaNC1WUKsLLnGFy (the owner's fresh upload) and last_id: file-VSynJKk2RVneFryB5wR1Fg, exactly the two ids the pre-fix code would have handed over. The other user's list also took 2.66s against a 2.13s direct-to-OpenAI control, so the upstream page really was fetched and then scoped.

The owner still gets a usable page, with cursors that match the ids actually visible in their own data (first_id == data[0].id and last_id == data[-1].id, both verified True, data length 2):

$ curl -sS http://localhost:49383/v1/files -H "Authorization: Bearer $OWNER_KEY"
{"data":[{"id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCxkMjA5NDNlNC0wYTg4LTQyODctOWVmMC0wNGYxOTVjZGEyZWY7...","bytes":159,...},{"id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCxhYTBlOGNlYy0xOWFjLTRjNzEtYTFhZi00MjRkODJjYTk3NTI7...","bytes":162,...}],"has_more":true,"object":"list","first_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCxkMjA5NDNlNC0wYTg4LTQyODctOWVmMC0wNGYxOTVjZGEyZWY7...","last_id":"bGl0ZWxsbV9wcm94eTphcHBsaWNhdGlvbi9vY3RldC1zdHJlYW07dW5pZmllZF9pZCxhYTBlOGNlYy0xOWFjLTRjNzEtYTFhZi00MjRkODJjYTk3NTI7..."}

Both runs deleted their uploads from the real account afterwards, verified 404 on a direct retrieve, and touched nothing else.

One thing QA turned up that this PR does not address

On a non-empty scoped page the owner keeps the upstream has_more: true, and following it does not work: GET /v1/files?after=<last_id> returns the identical page with has_more: true again, so an SDK auto-pagination loop spins. after is never forwarded to the upstream list call, so this predates the PR and is orthogonal to the leak. Flagging it because the honest post-fix shape is arguably has_more: false on any scoped page, given the proxy cannot resume from a managed-id cursor at all.

Type

🐛 Bug Fix

Changes

_PROXY_LiteLLMManagedFiles.async_post_call_success_hook replaces the upstream page's data with the rows the caller owns, but it left the page's first_id / last_id alone, so the cursors kept describing the unfiltered upstream page. Both are now derived from the filtered list, and an empty page also drops has_more since there is no cursor left to page from.

Two regression tests cover it: a non-owner gets null cursors on an empty page, and an owner gets cursors matching the ids in their own data. Both fail without the fix.

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

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
@CLAassistant

CLAassistant commented Aug 6, 2026

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ mateo-berri
❌ devin-ai-integration[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds security hardening to caller-scoped managed-file list responses and regression coverage.

  • Rebuilds page cursors from the filtered file list.
  • Clears pagination metadata when the filtered list is empty.
  • Tests empty non-owner and populated owner responses.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
enterprise/litellm_enterprise/proxy/hooks/managed_files.py Updates managed-file list metadata to remain consistent with the caller-scoped response data; no eligible follow-up issue remains.
tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py Adds focused regression tests for empty and populated caller-scoped list pages.

Reviews (2): Last reviewed commit: "test(managed-files): read the scoped pag..." | Re-trigger Greptile

Comment thread enterprise/litellm_enterprise/proxy/hooks/managed_files.py
Comment thread enterprise/litellm_enterprise/proxy/hooks/managed_files.py
Comment thread enterprise/litellm_enterprise/proxy/hooks/managed_files.py
@codecov

codecov Bot commented Aug 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@mateo-berri

Copy link
Copy Markdown
Contributor

@greptileai

@mateo-berri mateo-berri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mateo-berri
mateo-berri merged commit 1f01f19 into litellm_internal_staging Aug 9, 2026
80 checks passed
@mateo-berri
mateo-berri deleted the devin_ai_fix_file_list_cursor_leak_36087 branch August 9, 2026 00:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: File list pagination cursors (first_id/last_id) leak other users' file IDs

2 participants