
Reproduction lab for CVE-2026-3304, a Multer async fileFilter race condition causing disk exhaustion via orphaned temp files. Includes vulnerable and patched Docker servers, exploit scripts, and root cause analysis.
For educational and research purposes only. Attacking systems without authorization is illegal.
This lab environment was built by analyzing the Multer 2.1.0 patch and its official test code. The vulnerable server replicates the exact conditions described in the patch test, and the patched server runs Multer 2.1.0 to confirm the fix works.
| Field | Details |
|---|---|
| CVE | CVE-2026-3304 |
| Target | Multer < 2.1.0 (Node.js multipart/form-data middleware) |
| Type | DoS — Orphaned File (incomplete temporary file cleanup) |
| CWE | CWE-459: Incomplete Cleanup |
| CVSS 4.0 | 8.7 HIGH |
| Patched Version | Multer 2.1.0 |
A malformed multipart request with a missing name attribute on a file part causes Multer to create a temporary file on disk but never clean it up. Repeated requests exhaust disk space, resulting in a Denial of Service.
The vulnerability originates in the fileFilter callback handling flow inside multer/lib/make-middleware.js.
When Multer streams and parses a multipart request part by part, the following sequence occurs:
[Parsing event sequence — vulnerable version]
1. Part 1 header received
→ fileFilter(req, file, cb) called
→ setImmediate(cb) → callback deferred to next event loop tick
2. Part 1 body received
→ /tmp/uploads/<uuid> opened, data starts being written
3. Part 2 header received (name attribute missing)
→ Multer detects 'name missing'
→ errorOccured = true ← error flag set
→ abortWithCode('LIMIT_FIELD_KEY') called → HTTP 500 scheduled
4. setImmediate callback fires (next event loop tick)
→ fileFilter result: includeFile = true (normal flow)
→ [BUG] errorOccured flag is NOT checked
→ storage._handleFile() called → temp file committed to disk
5. HTTP 500 response sent
→ temp file remains on disk (orphaned file)
make-middleware.js — Multer < 2.1.0)// fileFilter completion callback (deferred via setImmediate)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
// ❌ errorOccured is never checked here
// even if an error was set while parsing Part 2, execution continues
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// temp file is registered in uploadedFiles and left on disk
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
Why is setImmediate the problem?
Wrapping fileFilter with setImmediate defers its callback to the next event loop tick.
In that window, busboy (the multipart parser) continues parsing the next part's headers,
discovers the missing name, and sets errorOccured = true.
When the callback resumes, the error state is already set — but the code never checks it,
so storage._handleFile is called unconditionally and the temp file is written to disk.
Fix commit: 739919097d
A single if (errorOccured) guard was added immediately after the fileFilter callback entry,
before storage._handleFile is reached.
// fileFilter completion callback (Multer 2.1.0)
fileFilter(req, file, function (err, includeFile) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
// ✅ [PATCH] Check errorOccured before proceeding
if (errorOccured) {
appender.removePlaceholder(placeholder)
return fileStream.resume() // drain stream — no file written to disk
}
if (!includeFile) {
appender.removePlaceholder(placeholder)
return fileStream.resume()
}
storage._handleFile(req, file, function (err, info) {
if (err) {
appender.removePlaceholder(placeholder)
return abortWithError(uploadedFiles, err)
}
appender.replacePlaceholder(placeholder, assign(file, info))
checkFinished()
})
})
| Item | Vulnerable (< 2.1.0) | Patched (2.1.0) |
|---|---|---|
errorOccured check | ❌ Not checked | ✅ Checked immediately on callback entry |
_handleFile called on error | Yes | Blocked |
| Temp file cleanup | ❌ Missing | ✅ Via fileStream.resume() |
| Orphaned files per request | 1 | 0 |
Why fileStream.resume() cleans up:
Calling fileStream.resume() drains and discards the stream without passing it to DiskStorage,
so no file is written and nothing is left on disk.
The Multer team included the following Mocha test in the 2.1.0 patch to verify the fix.
This test became the blueprint for this lab environment — the vulnerable server
replicates the exact setup described here (setImmediate fileFilter + malformed multipart request),
and the expected behavior is verified against both the vulnerable and patched versions.
/* eslint-env mocha */
var assert = require('assert')
var fs = require('fs')
var os = require('os')
var path = require('path')
var http = require('http')
var express = require('express')
var multer = require('../')
describe('async fileFilter cleanup', function () {
it('does not leave orphan files when request aborts with missing field name', function (done) {
var uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'multer-orphan-'))
var app = express()
// Vulnerability trigger: async fileFilter via setImmediate
var upload = multer({
dest: uploadDir,
fileFilter: function (req, file, cb) {
setImmediate(function () { cb(null, true) })
}
})
app.post('/upload', upload.any(), function (req, res) {
res.json({ success: true })
})
// Error handler: respond with 400
app.use(function (err, req, res, next) {
res.status(400).json({ error: err.code })
})
var server = app.listen(0, function () {
var port = server.address().port
var boundary = 'TestBound'
// Malicious body: Part 1 valid, Part 2 missing name attribute
var body =
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; name="f"; filename="a.bin"\r\n' +
'Content-Type: application/octet-stream\r\n\r\nORPHAN FILE DATA\r\n' +
'--' + boundary + '\r\n' +
'Content-Disposition: form-data; filename="b.bin"\r\n' + // ← name= missing
'Content-Type: application/octet-stream\r\n\r\nx\r\n' +
'--' + boundary + '--\r\n'
var req = http.request({
hostname: 'localhost',
port: port,
path: '/upload',
method: 'POST',
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': Buffer.byteLength(body)
}
}, function (res) {
res.resume()
res.on('end', function () {
setTimeout(function () {
var files = fs.readdirSync(uploadDir)
// Assert 1: server must respond with 400 (error)
assert.strictEqual(res.statusCode, 400)
// Assert 2: no orphaned files must remain on disk
assert.strictEqual(files.length, 0)
server.close(done)
}, 500)
})
})
req.write(body)
req.end()
})
})
})
| Assert | Meaning |
|---|---|
res.statusCode === 400 | Multer correctly rejects the malformed request |
files.length === 0 | No orphaned files left on disk (patch verified) |