CVE-2026-113872 ·
One upload request crashes any Express app that uses multer with an async fileFilter
The request gets a normal 400 back. The process dies a few hundred milliseconds later.
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.
| CVE | CVE-2026-113872 |
| GitHub advisory | GHSA-q7qp-rjg7-q26m |
| Package | multer (npm) |
| Affected | >= 2.0.0 < 2.5.0 |
| Fixed in | 2.5.0 |
| Severity | High — 7.5 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) |
| Type | Denial of Service (uncaught exception) — CWE-248, CWE-404 |
| Reporter | Santiago Habib (@santihabib) |
| Fix by | Ulises 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.0or later.
Am I affected?
You are affected if all of the following hold:
- multer version is
2.0.0to2.4.0; - your
fileFiltercalls its callback asynchronously — i.e. after anawait, asetTimeout, 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.0Workaround (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 survivedRun it
cd poc
npm install express multer@2.4.0 # vulnerable
node check.jsExpected 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 runningSame 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 asyncFor 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 whosefileFilteris still pending is not counted.done(err)— ends the request: unpipes busboy, callsnext(err), and onesetImmediatelater callsbusboy.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.
| Time | Event |
|---|---|
| t0 | busboy emits 'file' twice; both filters pending; pendingWrites == 0. |
| t1 | File 2 fills its stream → busboy parks its write callback; the rest of the body stays buffered. |
| t2 | req ends; the pipe calls busboy.end(); finalization waits behind the parked write. |
| 100 ms | Filter 1 rejects → with nothing pending, done(err) runs → client gets 400. |
| next tick | setImmediate removes busboy's listeners — busboy now has no 'error' listener. |
| 300 ms | Filter 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 18 | Reported privately via GitHub Security Advisory (GHSA-q7qp-rjg7-q26m). |
| Oct 18 | Triaged as High by the maintainers. |
| Oct 22 | multer@2.5.0 released with the fix. |
| Oct 22 | CVE-2026-113872 published by the OpenJS Foundation CNA. |
References
- CVE record: https://www.cve.org/CVERecord?id=CVE-2026-113872
- GitHub advisory: https://github.com/expressjs/multer/security/advisories/GHSA-q7qp-rjg7-q26m
- Fix commit: https://github.com/expressjs/multer/commit/80a2d9d
- multer changelog: https://github.com/expressjs/multer/blob/master/CHANGELOG.md