Research

CVE-2026-113872 ·

Un solo upload tira abajo cualquier app Express que use multer con un fileFilter asíncrono

El request recibe un 400 normal. El proceso muere unos cientos de milisegundos después.

Este análisis está disponible solo en inglés.

A single unauthenticated upload request can crash the whole Node.js process of any Express app that uses multer with an asynchronous fileFilter. The request even gets a normal 400 response first; the process dies a few hundred milliseconds later.

CVECVE-2026-113872
GitHub advisoryGHSA-q7qp-rjg7-q26m
Packagemulter (npm)
Affected>= 2.0.0 < 2.5.0
Fixed in2.5.0
SeverityHigh — 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H)
TypeDenial of Service (uncaught exception) — CWE-248, CWE-404
ReporterSantiago Habib (@santihabib)
Fix byUlises Gascón, Sebastián Beltrán (expressjs/multer maintainers)

This repository is a write-up of a vulnerability I reported and which is now publicly disclosed and fixed. The proof of concept targets a patched release so defenders can confirm their exposure and verify the fix. Upgrade to multer@2.5.0 or later.


Am I affected?

You are affected if all of the following hold:

  • multer version is 2.0.0 to 2.4.0;
  • your fileFilter calls its callback asynchronously — i.e. after an await, a setTimeout, a database lookup, an HTTP call, a virus scan, etc.;
  • the route accepts more than one file (.array(), .fields(), .any()).

The storage engine (disk, memory, S3, …) and the configured limits make no difference. A fully synchronous fileFilter is not affected.

Fix

npm install multer@^2.5.0

Workaround (if you cannot upgrade)

Keep the fileFilter synchronous, and move any async validation into a route handler that runs after multer has finished parsing.


Proof of concept

poc/
├── server.js   # tiny Express app: memory storage + async fileFilter
├── client.js   # sends one crafted multipart request
└── check.js    # runs the server in a child process and reports if it survived

Run it

cd poc
npm install express multer@2.4.0   # vulnerable
node check.js

Expected output against a vulnerable version:

[server] listening on 31046
[client] HTTP 400 rejected
[check] VULNERABLE: server process exited with code 1 (Error: Unexpected end of form)

Now against the patched version:

npm install multer@2.5.0
node check.js
[server] listening on 32000
[client] HTTP 400 rejected
[check] NOT VULNERABLE: server is still running

Same payload, same 400 response — but on 2.5.0 the process stays alive.


How it works

multer does not parse multipart bodies itself. It pipes the request into busboy and reacts to its events:

req ──pipe──▶ busboy ──'file'──▶ fileStream ──▶ storage engine
                                     │
                               fileFilter(req, file, cb)   ← your code, may be async

For every file part, busboy creates a Readable (fileStream) and emits 'file'. multer asks your fileFilter whether to accept it. Until cb is called, nobody reads that file stream.

Three bits of multer state matter:

  • errorOccured — a latch set by the first error; later aborts become no-ops.
  • pendingWrites — counts files that have been accepted and are being stored. A file whose fileFilter is still pending is not counted.
  • done(err) — ends the request: unpipes busboy, calls next(err), and one setImmediate later calls busboy.removeAllListeners().

1. Backpressure parks the parser

When busboy pushes file data into a stream that nobody is reading, push() returns false. Instead of failing, busboy parks its own write callback inside the file stream and waits for someone to read it. Meanwhile, the rest of the body keeps arriving and piles up unparsed inside busboy's internal buffer.

The parked callback is only released when the file stream is read again — at which point busboy drains its buffer and keeps parsing.

2. Teardown leaves a door open

When an earlier error ends the request, done() unpipes busboy and schedules removeAllListeners(). But unpipe does not flush what busboy already buffered, and when a still-pending fileFilter finally calls back, multer runs:

if (errorOccured) {
  appender.removePlaceholder(placeholder)
  return fileStream.resume()   // ← reads the parked stream → busboy resumes
}

So after teardown, that resume() is what wakes the parser back up.

3. The race

Two file parts, both with async filters. The first rejects after 100 ms; the second accepts after 300 ms. The second file is larger than fileHwm (16 KiB), and the body ends without a closing boundary.

TimeEvent
t0busboy emits 'file' twice; both filters pending; pendingWrites == 0.
t1File 2 fills its stream → busboy parks its write callback; the rest of the body stays buffered.
t2req ends; the pipe calls busboy.end(); finalization waits behind the parked write.
100 msFilter 1 rejects → with nothing pending, done(err) runs → client gets 400.
next ticksetImmediate removes busboy's listeners — busboy now has no 'error' listener.
300 msFilter 2 calls back; errorOccured is set, so multer calls fileStream.resume().
300 ms+busboy drains its buffer and finalizes a truncated body → Error('Unexpected end of form').
300 ms++Node emits that error on busboy; nobody is listening → uncaught exception → process exits.

In production this looks like: "the request returned a clean 400, and then the server restarted for no reason." That delay between the response and the crash is what makes it confusing to diagnose.

Why a malformed header does not crash

An earlier idea was to make busboy throw after teardown with a malformed part header. That path is safe: the error is thrown synchronously from inside FileStream._read, which Node wraps in a try/catch and re-emits on the file stream — and multer keeps a listener on each file stream. The _final failure is different: it is delivered through busboy's Writable callback on a later tick, emitted on busboy itself, where the listener has already been removed.


The fix

multer 2.5.0 keeps a no-op error listener on busboy after teardown, so a late error can no longer become an uncaught exception (commit 80a2d9d):

setImmediate(() => {
  busboy.removeAllListeners()
  // Keep an error listener so a late busboy error after teardown cannot
  // crash the process; the request has already finished.
  busboy.on('error', function () {})
})

The request still ends with 400; the difference is that the process survives.


Timeline

Date (2026)Event
Oct 18Reported privately via GitHub Security Advisory (GHSA-q7qp-rjg7-q26m).
Oct 18Triaged as High by the maintainers.
Oct 22multer@2.5.0 released with the fix.
Oct 22CVE-2026-113872 published by the OpenJS Foundation CNA.

References