Description
Where
server/src/storage/fileRequests.ts:134-151(reserveUpload): the count and byte checks run only whenmax_files/max_bytesare non-null. The only unconditional caps,MAX_OPEN_PER_VISITOR = 10andMAX_OPEN_PER_REQUEST = 100(lines 20-22), count open uploads (OPEN, line 19:placed_at IS NULL), never placed ones.shared/src/requests.ts:16,25:maxFilesdefaults tonull(no limit).web/src/views/FileRequestsView.vue:40-64sendsnullwhen the field is left empty, the dialog's default.server/src/routes/fileRequests.ts:173-210(createUpload) andserver/src/storage/local.ts:846-913(appendImpl): an upload withUpload-Length: 0is placed by its first emptyPATCH, so it is never open. Each placement costs an inode, afile_request_uploadsrow kept forever (name up to 255 bytes plus uploader), aname_indexrow, arecentrow and two fsyncs. There is no rate limit on/r/:token/uploads.
What happens
Anyone holding a request link whose owner left "max files" empty can create files without end from one browser session: POST /r/<token>/uploads with Upload-Length: 0 (201) followed by an empty PATCH (204), two requests per file, with distinct names (up to 4096 chars, sanitised to 255 bytes). The owner's quota and the shard's disk reserve (#320) count bytes only, so zero-byte files pass both. Result: inode exhaustion of the data volume every app of the shard uses, an unbounded SQLite table, and a folder no listing can render.
Probe (vitest, since deleted): limitless request on /inbox, one visitor, 150 x (create with Upload-Length: 0, PATCH empty body). All 150 answered 201/204, 150 files on disk, 150 placed rows, nothing refused.
Expected behaviour
A visitor can never place more than a fixed number of files through one request, whatever the owner typed, and cannot hammer creation. "No limit" on the owner's side means the server's ceiling, not unbounded.
Fix
- Add constants in
storage/fileRequests.ts:MAX_FILES_PER_REQUEST = 1000(the ceiling whenmax_filesis null, and the new maximum ofRequestFields.maxFilesinshared/src/requests.ts, today 100000) andMAX_FILES_PER_VISITOR = 100. Enforce both inreserveUploadinside the same transaction againstCOUNTS(placed plus open), per request and per visitor, answeringREQUEST_FILE_LIMIT. - Rate-limit visitor upload creation per (request, visitor) and per address, e.g. 60 per minute, with the existing
AttemptLimiterinhttp/attempts.ts, answering 429TOO_MANY_ATTEMPTS(already inVISITOR_CODES). - UI: the request dialog's max-files field shows the ceiling as placeholder;
infokeeps answeringfilesLeftfrom the effective limit so the visitor page shows it. Strings in all three locales. - Record in DECISIONS.md (amends Security: file request visitors hold the owner's quota with reservation-only uploads; revo #304/Security: a file-request visitor holds the shard's free disk with unsent uploads for 24 h #364: files, not only bytes, are bounded).
Test that must fail without the fix
server/test/fileRequests.test.ts: create a request with nomaxFiles; one visitor loopsMAX_FILES_PER_VISITORx (createUpload-Length: 0, empty PATCH), all 201/204; the next create answers 409REQUEST_FILE_LIMIT;readdirSync(folder)holds exactlyMAX_FILES_PER_VISITORfiles;infoshowsfilesLeft: 0. A second visitor still places files untilMAX_FILES_PER_REQUEST. Third case: 61 creations within one minute from one visitor, the 61st is 429 withretry-after. Removing the new checks turns all of this red (today every request succeeds).- Playwright (the file-request spec): the owner's new-request dialog shows the ceiling in the files field; the visitor page of a limitless request shows the files left out of the ceiling.
Found by the automatic review round (claude-fable-5-1, b739414); per CLAUDE.md the fix starts with a failing behaviour test.
Activity
fix(#401): Security: file-request visitor places unbounded files when n
fix(#401): Security: file-request visitor places unbounded files when…
BeSovereign commentedon Oct 20, 2026
Fixed: PR #410 squash-merged into
mainafter green CI (one commit;git revertit to undo).