Skip to content

Security: file-request visitor places unbounded files when no file limit is set (inode/row #401

Closed

Description

@BeSovereign

Where

  • server/src/storage/fileRequests.ts:134-151 (reserveUpload): the count and byte checks run only when max_files / max_bytes are non-null. The only unconditional caps, MAX_OPEN_PER_VISITOR = 10 and MAX_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: maxFiles defaults to null (no limit). web/src/views/FileRequestsView.vue:40-64 sends null when the field is left empty, the dialog's default.
  • server/src/routes/fileRequests.ts:173-210 (createUpload) and server/src/storage/local.ts:846-913 (appendImpl): an upload with Upload-Length: 0 is placed by its first empty PATCH, so it is never open. Each placement costs an inode, a file_request_uploads row kept forever (name up to 255 bytes plus uploader), a name_index row, a recent row 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

  1. Add constants in storage/fileRequests.ts: MAX_FILES_PER_REQUEST = 1000 (the ceiling when max_files is null, and the new maximum of RequestFields.maxFiles in shared/src/requests.ts, today 100000) and MAX_FILES_PER_VISITOR = 100. Enforce both in reserveUpload inside the same transaction against COUNTS (placed plus open), per request and per visitor, answering REQUEST_FILE_LIMIT.
  2. Rate-limit visitor upload creation per (request, visitor) and per address, e.g. 60 per minute, with the existing AttemptLimiter in http/attempts.ts, answering 429 TOO_MANY_ATTEMPTS (already in VISITOR_CODES).
  3. UI: the request dialog's max-files field shows the ceiling as placeholder; info keeps answering filesLeft from the effective limit so the visitor page shows it. Strings in all three locales.
  4. 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 no maxFiles; one visitor loops MAX_FILES_PER_VISITOR x (create Upload-Length: 0, empty PATCH), all 201/204; the next create answers 409 REQUEST_FILE_LIMIT; readdirSync(folder) holds exactly MAX_FILES_PER_VISITOR files; info shows filesLeft: 0. A second visitor still places files until MAX_FILES_PER_REQUEST. Third case: 61 creations within one minute from one visitor, the 61st is 429 with retry-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

  1. added
    rebuildPart of the ground-up rebuild (spec 2026-10-14)
    securitySecurity finding
    on Oct 20, 2026
  2. added 2 commits that reference this issue on Oct 20, 2026
  3. BeSovereign commented on Oct 20, 2026

    @BeSovereign
    ContributorAuthor

    Fixed: PR #410 squash-merged into main after green CI (one commit; git revert it to undo).

    • Suites: typecheck: 1 passed, 0 failed | lint: 1 passed, 0 failed | unit: 1186 passed, 0 failed | e2e: 603 passed, 0 failed
    • Declared tests fail without the production change and pass with it (output in the PR).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    rebuildPart of the ground-up rebuild (spec 2026-10-14)securitySecurity finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions