fix(proxy): scope file list pagination cursors to the caller - #36093
Merged
mateo-berri merged 3 commits intoAug 9, 2026
Merged
Conversation
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
|
|
Contributor
Greptile SummaryThis PR adds security hardening to caller-scoped managed-file list responses and regression coverage.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| 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
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…evin_ai_fix_file_list_cursor_leak_36087
Contributor
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TLDR
Problem this solves:
GET /v1/filescursors leaked other users' file idsdatawas scoped to the caller,first_id/last_idwere notHow it solves it:
has_more: falseUser 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
POST https://litellm-domain/key/generate, one forowner-useron team A and one forother-useron team BPOST https://litellm-domain/v1/files(multipart form:purpose=batch,file=@batch.jsonl,target_model_names=<their model>) and gets back 200 with a file idGET https://litellm-domain/v1/filesand gets 200 with"data": [], butfirst_idandlast_idcarry rawfile-...ids belonging to the owner's uploadGET https://litellm-domain/openai/v1/filesAfter: the same empty list carries null cursors, so a non-owner learns nothing from it
POST https://litellm-domain/key/generate, one forowner-useron team A and one forother-useron team BPOST https://litellm-domain/v1/files(multipart form:purpose=batch,file=@batch.jsonl,target_model_names=<their model>) and gets back 200 with a file idGET https://litellm-domain/v1/filesand gets 200 with"data": [],"first_id": null,"last_id": null, and"has_more": falseGET https://litellm-domain/openai/v1/filesreturns the same null cursorsdata, withfirst_idandlast_idmatching the first and last ids visible in that samedataRelevant issues
Fixes #36087
Linear ticket
Pre-Submission checklist
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_idon distinctteam_id: OWNERqa36093-owneron team A, OTHERqa36093-otheron 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-QvsCr8gY6mWS5Ne2GwS6Pzandfile-W9z9L1f2e4TdXVqEocSc1E:The other user then lists, on both routes:
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/filestaken before the uploads contained neither owner id, so both are genuinely new this run, and retrievingfile-W9z9L1f2e4TdXVqEocSc1Estraight from OpenAI returnscreated_at: 1786234517, byte for byte the owner's second upload.Two things beyond what the issue reported.
last_idhere isfile-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:After, at
2112422c71(this branch's tip)Same flow, same two-user setup, owner uploads mint
file-LH2Xc1iUcDgZmDoT2VjjW2andfile-HkwW8ByqaNC1WUKsLLnGFy:{"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/filestaken during the run returned 5450 entries withfirst_id: file-HkwW8ByqaNC1WUKsLLnGFy(the owner's fresh upload) andlast_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].idandlast_id == data[-1].id, both verified True,datalength 2):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 withhas_more: trueagain, so an SDK auto-pagination loop spins.afteris 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 arguablyhas_more: falseon 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_hookreplaces the upstream page'sdatawith the rows the caller owns, but it left the page'sfirst_id/last_idalone, so the cursors kept describing the unfiltered upstream page. Both are now derived from the filtered list, and an empty page also dropshas_moresince 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