Skip to content

test: reproduce filesLimit Premature close on buffered bodies (10.1.1 regression) - #641

Open
noahnu wants to merge 1 commit into
fastify:mainfrom
noahnu:repro/files-limit-premature-close-on-buffered-body
Open

test: reproduce filesLimit Premature close on buffered bodies (10.1.1 regression)#641
noahnu wants to merge 1 commit into
fastify:mainfrom
noahnu:repro/files-limit-premature-close-on-buffered-body

Conversation

@noahnu

@noahnu noahnu commented Aug 18, 2026

Copy link
Copy Markdown

Summary

This PR adds failing regression tests only (no fix). CI is expected to be red until the issue is fixed.

On @fastify/multipart@10.1.1, exceeding limits.files while consuming parts with part.toBuffer() often throws ERR_STREAM_PREMATURE_CLOSE (Premature close) instead of FilesLimitError (FST_FILES_LIMIT) when the request body is fully buffered up front.

This appears to be an unintended side effect of the GHSA-vmph-573x-85f6 fix that stopped clearing currentFile in onError: filesLimit cleanup now destroys the in-flight part stream, racing ahead of the FilesLimitError delivered on the async iterator.

Passes on 10.1.0 / fails on 10.1.1

npm test -- test/multipart-files-limit-buffered-body.test.js

Why existing filesLimit tests still pass

They use form.pipe(req), which streams the body slowly enough that the previous part is usually fully consumed before filesLimit fires. The regression shows up with:

  • fastify.inject({ payload: form.getBuffer() })
  • http.request(...); req.end(form.getBuffer())

Both are common in apps and Fastify tests.

Related

Will also open an issue with a write-up; this PR is the executable repro.

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 added a commit to cmiic/fastify-multipart that referenced this pull request Aug 22, 2026
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 does not truncate the part in flight:
busboy keeps parsing, the part completes on its own, and the consumer 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.

Pass the limit cleanup through `cleanup(err, false)` so it leaves the part
alone. Two alternatives 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. It passes the
    preceding commit's tests while still being wrong, which is why the
    `fieldsLimit` cases in this commit are worth having.
  - Destroying only parts busboy has not finished does not work either.
    At cleanup time `_readableState.ended` is still false even for a part
    that goes on to complete, because busboy has buffered input left to
    parse.

Keeping `currentFile` assigned also means a request that dies mid-part
after a limit is still torn down by `onRequestClose`, so the advisory's
behaviour is unchanged -- its 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.

This leaves one adjacent hang in the same area untouched. It predates the
advisory fix and reproduces identically on 10.1.0, so it is not part of
the regression fixed here; it is being raised with the maintainers
separately.

Fixes: fastify#642
Closes: fastify#641
cmiic added a commit to cmiic/fastify-multipart that referenced this pull request Aug 22, 2026
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
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.

1 participant