Skip to content

fix: do not tear down the part in flight when a parser limit is raised - #643

Open
cmiic wants to merge 2 commits into
fastify:mainfrom
cmiic:fix/parser-limits-destroy-unconsumed-part
Open

fix: do not tear down the part in flight when a parser limit is raised#643
cmiic wants to merge 2 commits into
fastify:mainfrom
cmiic:fix/parser-limits-destroy-unconsumed-part

Conversation

@cmiic

@cmiic cmiic commented Aug 22, 2026

Copy link
Copy Markdown

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 currentFile in onError, so that a request aborted mid-part has its in-flight part destroyed rather than leaving saveRequestFiles hanging on pump() with a leaked temp file. That is correct and this PR keeps it.

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 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() }) 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 sees depends on how it reads:

  • part.toBuffer() iterates the destroyed stream and throws ERR_STREAM_PREMATURE_CLOSE instead of FilesLimitError. 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, 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.

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:

  • Clearing currentFile in the limit handler. Works for filesLimit and partsLimit, which stop busboy emitting parts, but not for 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. Covers 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.

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.

Tests

The two files are split by scope, no overlap:

  • multipart-files-limit-buffered-body.test.js (@noahnu, first commit) — filesLimit + toBuffer, via inject and via http + req.end(buffer).
  • multipart-limits-unconsumed-part.test.js — the onFile + pipeline hang in both transports, partsLimit, and fieldsLimit, including bodies split across two chunks in both orderings.
#641's tests this PR's tests
10.1.1 0/2 2/9
clearing currentFile 2/2 7/9
this PR 2/2 9/9

The rows failing on clearing currentFile are the fieldsLimit cross-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, but keepParsing is exactly the change that could introduce one, so they guard it.

Suite is 103/103 with coverage still at 100%.

A note on fieldsLimit

fieldsLimit behaves differently from the other two limits, and that drove most of the design here: filesLimit and partsLimit stop busboy emitting parts entirely, while fieldsLimit only 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.

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
cmiic force-pushed the fix/parser-limits-destroy-unconsumed-part branch from 89c2948 to f9bd1ee Compare August 22, 2026 21:05
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
cmiic force-pushed the fix/parser-limits-destroy-unconsumed-part branch from f9bd1ee to ae8046a Compare August 22, 2026 23:43
@cmiic cmiic changed the title fix: do not destroy a completed part when a parser limit is raised fix: do not tear down the part in flight when a parser limit is raised Aug 22, 2026
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.

FST_FILES_LIMIT not being raised when files limit is exceeded

2 participants