Skip to content

fix: enforce maxFileSize/maxTotalFileSize on octet-stream uploads - #1113

Open
spokodev wants to merge 8 commits into
node-formidable:masterfrom
spokodev:fix/octetstream-size-limits
Open

spokodev wants to merge 8 commits into
node-formidable:masterfrom
spokodev:fix/octetstream-size-limits

Conversation

@spokodev

@spokodev spokodev commented Jul 1, 2026 •

Copy link
Copy Markdown

The octet-stream upload path does not enforce the documented maxFileSize / maxTotalFileSize limits, unlike the multipart path.

Steps to reproduce

POST a 256KB body with Content-Type: application/octet-stream to a form configured with maxFileSize: 1024:

const form = formidable({ maxFileSize: 1024, maxTotalFileSize: 2048 });
form.parse(req, (err, fields, files) => {
  console.log(err, Object.keys(files).length);
});
// request.end(Buffer.alloc(256 * 1024, 0x42));

ACTUAL: err is null, one file is returned with size 262144, and the over-sized file is committed to disk via file.end().

EXPECTED: an error, no file returned, and nothing left on disk. With this fix the error is biggerThanMaxFileSize (1016). The multipart path also rejects this request and leaves nothing on disk, but reports biggerThanTotalMaxFileSize (1009), because it checks the total while the file streams and maxFileSize only when the part ends.

Root cause

The octet-stream plugin's _parser.on("data", ...) handler in src/plugins/octetstream.js writes each chunk straight to the file with no size check, and it bypasses _handlePart() in src/Formidable.js where the multipart size caps are enforced. As a result a raw octet-stream body of any size is accepted regardless of the configured limits.

The README documents maxFileSize and maxTotalFileSize as limiting each file and the batch respectively, with defaults, and does not exempt octet-stream.

Fix

Accumulate the per-file and running total sizes before each write and abort via this._error(...) with the existing FormidableError codes biggerThanMaxFileSize / biggerThanTotalMaxFileSize that _handlePart uses. Both caps are checked before each chunk is written, so the body is rejected at the first chunk that crosses a cap. In-limit uploads are unaffected. Because the octet-stream file is tracked in openedFiles, the shared _error cleanup calls file.destroy(), which unlinks the partial file, so no bytes remain on disk (the same cleanup the multipart path relies on).

Tests

Two integration cases in test/integration/octet-stream.test.js: a 256KB octet-stream body with maxFileSize: 1024 must be rejected with code 1016, and a 3000-byte body with maxFileSize: 4096 and maxTotalFileSize: 2048 with code 1009. Both must return no files. Both fail on the current source, where the upload is accepted with err null, and pass with the fix.

Suite status: 96 passed / 3 skipped across 15 jest suites, and 11/11 node tests.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no outstanding issues were found.

Summary

Adds maxFileSize and maxTotalFileSize checks before octet-stream chunks are written.

  • Octet-stream uploads stop when they exceed either file-size limit.

Reviews (11) · Last reviewed commit: "test: give the octet-stream limit tests ..." · Reviewed by Greptile

The octet-stream upload path wrote every chunk to disk without checking
the documented maxFileSize/maxTotalFileSize limits, unlike the multipart
path in _handlePart. Accumulate per-file and total sizes and abort via
_error with the existing FormidableError codes when a cap is exceeded,
mirroring the multipart implementation. The over-limit file is removed
through the shared _error cleanup, so no partial bytes remain on disk.
Comment thread src/plugins/octetstream.js Resolved
Comment thread test/integration/octet-stream.test.js Resolved
spokodev and others added 3 commits August 27, 2026 09:33
The maxFileSize test left a TCPWRAP handle open: formidable aborts the parse
mid-body, so the connection never completes on its own and server.close() only
stops new ones. Jest reported "did not exit one second after the test run"
locally and "A worker process has failed to exit gracefully" on CI, where the
leaked socket's late callback surfaced as a failure inside the next test file,
test/integration/store-files-option.test.js.

Destroying the client request ends the connection. Measured: with the old
teardown --detectOpenHandles reports 1 open TCPWRAP at the server.listen line;
with this change it reports none, and the full suite is 15/15 suites,
95 passed / 3 skipped of 98, with no worker warning.
Comment thread test/integration/octet-stream.test.js Resolved
@spokodev

Copy link
Copy Markdown
Author

The ci run was failing here, and it was my test's fault rather than the fix's.

The maxFileSize test left a TCP handle open: formidable aborts the parse mid-body, so the connection never completes on its own and server.close() only stops new ones. On CI that showed up as "A worker process has failed to exit gracefully", and the late callback surfaced as a failure inside the next test file, test/integration/store-files-option.test.js — which this PR does not touch.

It reproduces locally when the suite runs in a single process. With the test file in its previous form, jest --runInBand gives the CI result exactly — 1 failed / 14 passed of 15 suites, and 1 failed / 3 skipped / 94 passed of 98 — with the failing assertion's stack originating in octet-stream.test.js. With 1a43a61, which destroys the client request in the parse callback, the same in-band run is 15/15 and 95 passed.

--detectOpenHandles on that file goes from one TCPWRAP at its server.listen to none. Two pre-existing TCPWRAPs remain elsewhere in the suite, in keep-alive-error.test.js and connection-aborted.test.js; this PR touches neither. node --test is 11/11.

Shrinking the body from 256 KB to 4 KB did not fix it, so the leak is the unfinished connection rather than the unsent bytes.

Both workflow runs on this push concluded action_required with no jobs dispatched, so CI needs a maintainer to approve the run before it can confirm any of this here.

@superbot-helper

Copy link
Copy Markdown

I reproduced this on the published tarball and can confirm the report, plus one thing this fix does not yet cover.

Confirmed on formidable@3.5.4 over real HTTP. src/plugins/octetstream.js (sha256 8b30bce93d…, matching tag v3.5.4) writes every chunk at :51-63 with no size comparison, and master at a78b8f48d547 is the same.

  • 1000-byte octet body with maxFileSize: 10 came back 200 with 1000 bytes on disk
  • 5 MiB with maxFileSize: 10 came back 200 with 5,242,880 bytes, 524,288 times the limit
  • 220 MiB with default options came back 200 with 230,686,720 bytes, past the documented 200 MiB default
  • The multipart controls returned 413 (codes 1016 and 1009) with zero bytes, so the contrast is clean

The gap. All five checks live in Formidable._handlePart (src/Formidable.js:310): filter at :358, maxTotalFileSize at :385-394, allowEmptyFiles at :405-414, minFileSize at :415-424, maxFileSize at :425-434. _handlePart is reached only through onPart, and the only caller is src/plugins/multipart.js:163, so the octet path skips all five.

This PR adds the two size checks. Still uncovered after it: filter, minFileSize and allowEmptyFiles. Measured with filter: () => false, minFileSize: 1000 and allowEmptyFiles: false against a 1000-byte body: accepted, and filter was called zero times. So a type allowlist or an empty-file rejection does not apply on this path either.

Routing the octet path through _handlePart, or extracting the five checks into something both paths call, would close it in one place rather than five.

I have not opened a competing PR, and I am happy to test a revision if useful.

@spokodev

Copy link
Copy Markdown
Author

Thanks for checking this on 3.5.4. I measured the three options on master and on this branch, sending the same body as multipart as a control:

Option octet-stream multipart
filter: () => false, 1000 bytes filter not called, file kept filter called once, file skipped
minFileSize: 1000, 10 bytes file kept error 1008
allowEmptyFiles: false, empty body no file, no error error 1010

So filter and minFileSize don't apply on the octet path, as you said. With an empty body it returns no file, but doesn't report an error either. A 1000-byte body passes minFileSize: 1000 and allowEmptyFiles: false on both paths, so those two need a smaller or empty body to show.

I'd keep this PR to the two size limits in its title and send the other checks as a separate PR, unless the maintainers would rather have one change that routes the octet path through _handlePart.

@spokodev

Copy link
Copy Markdown
Author

Pushed 6d9e2a0. The two size-limit tests listened on 13537 and 13538, and store-files-option.test.js also listens on 13537, so when Jest ran both files at the same time the maxFileSize test failed with EADDRINUSE (2 of 12 full runs here on 2d00c6d). They now use 13550 and 13551, which no other test uses. With another server holding 13537, the file now passes, where before it failed.

@greptile-apps

greptile-apps Bot commented Nov 12, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Start a greploop in Codex and it will work through the open comments and keep going until this PR reviews clean.

This branch has not been deployed

No deployments
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.

3 participants