fix: do not tear down the part in flight when a parser limit is raised - #643
Open
cmiic wants to merge 2 commits into
Open
fix: do not tear down the part in flight when a parser limit is raised#643cmiic wants to merge 2 commits into
cmiic wants to merge 2 commits into
Conversation
Adding failing coverage that shows FilesLimitError is replaced by ERR_STREAM_PREMATURE_CLOSE when the multipart body is fully buffered (fastify.inject or http end(buffer)) before parts are consumed. Existing filesLimit tests still pass because they stream via form.pipe. Co-authored-by: Cursor <cursoragent@cursor.com>
cmiic
force-pushed
the
fix/parser-limits-destroy-unconsumed-part
branch
from
August 22, 2026 21:05
89c2948 to
f9bd1ee
Compare
The GHSA-vmph-573x-85f6 fix (556cdb1) stopped clearing `currentFile` in `onError` so that a request aborted mid-part would have its in-flight part destroyed, instead of leaving `saveRequestFiles` hanging on `pump()` with a leaked temp file. That part of the fix is correct and stays. The side effect is that `cleanup()` now destroys `currentFile` for *any* error, and the `partsLimit` / `filesLimit` / `fieldsLimit` handlers call `cleanup(err)` too. A parser limit is raised between parts and does not truncate the one in flight: busboy keeps parsing, that part completes on its own, and the consumer still has to read it before it can observe the limit error on the next iteration. The window is wide open whenever the body reaches busboy in one piece -- `fastify.inject({ payload: form.getBuffer() })` or `req.end(buffer)`. busboy then parses the whole body synchronously, so the limit fires and the part is destroyed before the consumer has resumed at all. What the consumer gets depends on how it reads: - `part.toBuffer()` iterates the destroyed stream and throws ERR_STREAM_PREMATURE_CLOSE instead of FilesLimitError (fastify#642), which is what the preceding commit's tests cover. - `onFile` + `stream.pipeline()` receives a stream that is already destroyed and closed, with `close` emitted and no error. `pipeline()` attaches its end-of-stream listener too late to observe any of it and never settles: the queued limit error is never delivered, no response is sent, and the request hangs until the socket is torn down. Deliver the limit error without tearing the pipe down. busboy finishes the part it already opened, the consumer reads it and then observes the limit error, and the normal end-of-request path unpipes as usual. Two narrower fixes do not hold up. Clearing `currentFile` in the limit handler covers `filesLimit` and `partsLimit`, which stop busboy emitting parts, but not `fieldsLimit`, which only suppresses further *fields*: a file emitted after it becomes the part in flight by the time the deferred cleanup runs. Skipping the destroy for limit errors while still unpiping does cover that, but only for a part busboy has already finished -- one still arriving is then starved of the rest of its body and the consumer waits on it forever. Both are covered by the tests added here. Requests that die mid-part are unaffected: they reach `cleanup` through `onRequestClose` with `keepParsing` false, so the in-flight part is still destroyed and the advisory's regression test still passes. The existing limit tests do not cover any of this because they use `form.pipe(req)`, which feeds busboy slowly enough that the consumer has usually drained the previous part before the limit fires. Fixes: fastify#642 Closes: fastify#641
cmiic
force-pushed
the
fix/parser-limits-destroy-unconsumed-part
branch
from
August 22, 2026 23:43
f9bd1ee to
ae8046a
Compare
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.
Fixes #642. Includes the regression tests from #641 (cherry-picked, authorship preserved), which go green with the fix — so that PR can close with this one.
The bug
The GHSA-vmph-573x-85f6 fix in 556cdb1 stopped clearing
currentFileinonError, so that a request aborted mid-part has its in-flight part destroyed rather than leavingsaveRequestFileshanging onpump()with a leaked temp file. That is correct and this PR keeps it.The side effect is that
cleanup()now destroyscurrentFilefor any error — and thepartsLimit/filesLimit/fieldsLimithandlers callcleanup(err)too. A parser limit does not truncate the part in flight: busboy keeps parsing, the part completes on its own, and the consumer still has to read it before it can observe the limit error on the next iteration.The window is wide open whenever the body reaches busboy in one piece —
fastify.inject({ payload: form.getBuffer() })orreq.end(buffer). busboy then parses the whole body synchronously, so the limit fires and the part is destroyed before the consumer has resumed at all. What the consumer sees depends on how it reads:part.toBuffer()iterates the destroyed stream and throwsERR_STREAM_PREMATURE_CLOSEinstead ofFilesLimitError. This is FST_FILES_LIMIT not being raised when files limit is exceeded #642, covered by the first commit's tests.onFile+stream.pipeline()receives a stream that is already destroyed and closed, withcloseemitted and no error.pipeline()attaches its end-of-stream listener too late to observe any of it and never settles: the queued limit error is never delivered, no response is sent, and the request hangs until the socket is torn down.The second shape is worth calling out because it is a hang rather than a throw. A client can wedge a request indefinitely by posting two file parts to an endpoint with
limits.files: 1— a well-formed, complete request, no abort needed.The existing limit tests don't catch any of this because they use
form.pipe(req), which feeds busboy slowly enough that the consumer has usually drained the previous part before the limit fires.The fix
Deliver the limit error without tearing the pipe down. busboy finishes the part it already opened, the consumer reads it and then observes the limit error, and the normal end-of-request path unpipes as usual.
Two narrower fixes don't hold up, and both are covered by the tests here:
currentFilein the limit handler. Works forfilesLimitandpartsLimit, which stop busboy emitting parts, but not forfieldsLimit, which only suppresses further fields — a file emitted after it becomes the part in flight by the time the deferred cleanup runs.Requests that die mid-part are unaffected: they reach
cleanupthroughonRequestClosewithkeepParsingfalse, so the in-flight part is still destroyed and the advisory's regression test still passes.Tests
The two files are split by scope, no overlap:
multipart-files-limit-buffered-body.test.js(@noahnu, first commit) —filesLimit+toBuffer, viainjectand viahttp+req.end(buffer).multipart-limits-unconsumed-part.test.js— theonFile+pipelinehang in both transports,partsLimit, andfieldsLimit, including bodies split across two chunks in both orderings.currentFileThe rows failing on
clearing currentFileare thefieldsLimitcross-chunk cases — they are what distinguishes this fix from the narrower one. The two that already pass on 10.1.1 are characterisation tests for the ordering where the file arrives entirely after the error: no bug there, butkeepParsingis exactly the change that could introduce one, so they guard it.Suite is 103/103 with coverage still at 100%.
A note on
fieldsLimitfieldsLimitbehaves differently from the other two limits, and that drove most of the design here:filesLimitandpartsLimitstop busboy emitting parts entirely, whilefieldsLimitonly suppresses further fields, so file parts after it keep coming. Both cross-chunk orderings around it are covered above.Note
The first commit is intentionally red on its own (tests before fix), which isn't bisect-friendly. Say the word and I'll squash the two, keeping a
Co-authored-by:trailer so attribution survives.