CVE-2026-88932: multer's cleanup fix, and the regression test that could not fail
The incomplete cleanup I reported in multer: the commit that fixed aborted uploads also shipped the test that proves it, and that test aborts while the multipart part is still open — the one moment multer was never broken. Close the part first and 80 uploads in 100 stay on disk.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every request in it went to a server running on my own machine, and every file it left behind was written to my own disk. The issue was reported privately through the project’s own security advisory process, and is written up here only after the advisory, the fixed release and the CVE record were public.
There is a file in multer’s test suite called test/orphan-file-cleanup.js. It arrived with the fix for CVE-2026-5038 — the bug where an aborted upload left its temporary file behind — and its job is to prove the cleanup happens. It passes. It passed on the release that shipped the fix, it passed on the release after that, and it passes today.
It also passed on code that left a complete file on disk for eighty of every hundred aborted requests.
This is not a story about a missing test. The test exists, it is specific, it aborts a real upload, and it asserts the right thing. It aborts at the wrong instant — and the instant it picks is the only one at which multer was never broken.
If you use multer with disk storage, upgrade to 2.4.0. Everything from 2.2.0 up to and including 2.3.0 is affected — both releases that carry the CVE-2026-5038 fix. The advisory is GHSA-3pph-fpjx-jg34, published 14 September, and the identifier is CVE-2026-88932, assigned by the OpenJS Foundation. Medium, 5.3, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L. The 3.x line has no disk storage and is not affected.
One commit, one gate, one test
Commit 9c801c7 fixed CVE-2026-5038. It added the thing that removes in-flight uploads when a request dies, and it added the test that proves the removal happens. Both halves of the story come from the same change.
The removal is in lib/make-middleware.js. When the client goes away, multer gathers what it has to clean up:
var filesToRemove = uploadedFiles.concat(
pendingFiles.filter(function (f) { return f.path })
)
pendingFiles = []
Read the filter on the second line. Of the uploads still in flight, it keeps only those that already have a path.
That is a reasonable question to ask. You cannot delete a file whose name you do not know yet.
One asynchronous hop
The trouble is who assigns the name, and when.
Multer starts tracking a file one line before it hands it to the storage engine, so from that moment the file is in pendingFiles. But path is the engine’s to set, and the bundled DiskStorage does not know it yet either. It generates the name with crypto.randomBytes — asynchronous, by default, in the shipped configuration — and assigns file.path only inside that callback:
that.getDestination(req, file, function (err, destination) {
that.getFilename(req, file, function (err, filename) {
var finalPath = path.join(destination, filename)
if (file.stream.destroyed) return cb(new MulterError('STREAM_DESTROYED'))
var outStream = fs.createWriteStream(finalPath)
file.path = finalPath
So between “multer is tracking this file” and “this file has a path” there is a window of one turn of the event loop. It is present in every configuration, and any application that supplies its own async destination or filename — which is the normal way to name uploads after a database row — widens it as much as it likes.
An abort that lands inside that window plays out in six steps:
- the failure handler runs the cleanup immediately;
- the filter does not see the file, so nothing is removed for it;
pendingFiles = []drops the last reference the abort path had to it;- control returns to the application with an error;
- the engine’s callback fires and writes the complete file to disk;
- the finished file is pushed onto
uploadedFiles— whose only consumer is the cleanup that already ran, and which is gated from running twice.
The file is now on disk with no reference to it anywhere in the process that created it.
Why the existing test never sees it
There is a second mechanism, and it is the reason the original fix looked complete.
If the abort arrives while the multipart part is still open, busboy is still holding the file stream. It destroys the stream, and storage/disk.js checks file.stream.destroyed before creating anything — the line immediately above the file.path assignment quoted earlier. No file is created, so there is nothing to orphan.
That path is genuinely safe. It is also safe for a reason that has nothing to do with the cleanup list: busboy is doing it, not multer’s accounting.
test/orphan-file-cleanup.js aborts while the part is still open. So it exercises the branch busboy protects, and it would keep passing if the cleanup filter were deleted outright.
Once the part is closed — which happens as soon as the next boundary arrives on the wire — busboy lets the stream go, and the only thing between an abort and an orphaned file is a filter that cannot name it.
The distance between the covered case and the uncovered one is a few dozen bytes of multipart framing:
req.write(preamble)
req.write(chunk)
req.write('\r\n--' + boundary + '\r\nContent-Disposition: form-data; name="next"\r\n\r\n')
setTimeout(function () { req.destroy() }, 50)
That third line writes the header of a second part, which closes the first one. Add it to the existing test and the test fails on 2.2.0 and on 2.3.0, and passes on the patch. Same file, same assertion, same abort — a fraction of a message later.
Counting, not sampling
I did not want a percentage from a handful of runs, so the harness closed the books on every file.
A hundred aborted requests against the configuration the README puts first — multer({ dest: 'uploads/' }), no custom engine, on 2.3.0:
| files | |
|---|---|
| written to disk | 100 |
| removed by multer’s cleanup | 20 |
| removed by the application’s error handler | 0 |
left in uploads/ permanently |
80 |
Not fragments. Complete files, byte-for-byte what was uploaded, with names nothing recorded.
The controls are what make those eighty a finding rather than a flaky number:
| control | result | what it proves |
|---|---|---|
| abort while the part is still open | 0 of 200 | busboy’s branch is genuinely safe |
memoryStorage instead of disk |
0 of 200 | the defect is the disk engine’s window, not the abort path |
| well-formed request, no abort | 200 of 200 kept | the harness is not deleting files on its own |
And one number that shows the eighty is a floor rather than a ceiling: with an ordinary asynchronous filename — a database lookup, say — the window widens and the rate goes to 100%.
I also checked whose bug it is. busboy behaves correctly throughout at 1.6.0. The accounting gap is entirely inside multer.
The cleanup the application cannot write
The natural objection is that the application should tidy up after itself: catch the error, delete the file, move on.
You cannot, and this is the part I expected to be argued and was not.
The handler that attaches the finished file to req.file runs inside the storage engine’s completion callback — which fires after the error has already been passed to the application. In the error handler, the only place an application gets to react, req.file is undefined for exactly the requests that leaked.
So the canonical cleanup, the one every tutorial writes:
app.use(function (err, req, res, next) {
if (req.file) fs.unlink(req.file.path, function () {})
res.status(400).end()
})
is correct, is widely recommended, and removes none of them. That is the 0 in the table above, and it is measured rather than assumed.
The files then accumulate in whatever dest points at, at whatever rate clients abort, until something outside the application notices. For multer({ dest }) that is the application’s own directory. For multer.diskStorage({}) with no destination it is the operating system’s temporary directory, which is shared with everything else on the host.
That is the whole impact, and it is deliberately narrow: the file contains only what the uploader sent. Nothing is read, disclosed or modified. C:N/I:N was my reading when I filed it and it survived review unchanged.
How it was found, including the part that failed
The honest version is that I was looking somewhere else.
Multer was picked for a reason that has nothing to do with this bug: the OpenJS Foundation is its CNA and credits the reporter on about seventy per cent of its records, and the Express project has no clause forbidding a reporter from taking an identifier elsewhere. That is a selection criterion about who owns the record, applied before a line of code is read.
The question I actually wanted answered was a different one. Four of the project’s five recent advisories are resource exhaustion, so I asked for the one that was not — CVE-2026-77063, a limit bypass through an async fileFilter, which is an ordering defect — and set the brief to enumerate every place where a limit or a policy is decided asynchronously while bytes keep arriving.
That hunt closed negative. Thirteen cells — fileSize, files, parts, fields and per-field maxCount, each crossed with an async fileFilter, an async destination, an async filename and an async custom _handleFile — all behave correctly on 2.3.0. The fix for CVE-2026-77063 is complete as far as that testing reaches, and the report says so.
But the lens survived the target. Something is decided asynchronously while bytes keep arriving is the question that produced the finding — applied not to the limits, where I expected it, but to the cleanup, where nobody had pointed it. The negative result is what moved me one file over.
Two smaller things turned up beside it and did not go in the report, recorded here so they are not rediscovered as new: limits.fileSize given a string silently disables the limit, which is already fixed on main and requires the developer to misconfigure it; and decodeFormDataName came to admit ", CR and LF in originalname.
The fix
The engine is not the right place to fix this, even though the engine is where the window is. Third-party storage engines — multer-s3, GridFS — have prologues far longer than crypto.randomBytes, and closing the window only in the bundled one leaves them all open.
So the fix belongs where the accounting lives: make the engine’s completion callback notice that the request has already failed.
var fileInfo = { ...file, ...info }
if (errorOccured || isDone) {
return storage._removeFile(req, fileInfo, function () {
appender.removePlaceholder(placeholder)
decrementPendingWrites()
})
}
appender.replacePlaceholder(placeholder, fileInfo)
A file that finishes writing after the request is already dead now removes itself, however long its engine took to get there.
Beside that, the f.path filter can simply go — DiskStorage._removeFile no-ops on a file with no path — or the shipped engine can set file.path before it awaits anything, so it has no window of its own. Either is belt to the other’s braces.
Timeline and credits
| 5 Sep, 05:09 UTC | reported privately through GitHub’s advisory flow |
| 8 Sep, 20:10 UTC | accepted by UlisesGascon |
| 8 Sep, 20:11 UTC | private fork opened — one minute later |
| 10 Sep, 14:28 UTC | CVE-2026-88932 reserved by OpenJS |
| 14 Sep, 08:19 UTC | advisory published, 2.4.0 named as the fix |
| 14 Sep, 08:46 UTC | CVE record published |
The advisory carries the title, the vector and both weakness IDs exactly as filed — CWE-459 for the cleanup that does not happen, CWE-400 for what accumulates. The one change is the affected range, from my <= 2.3.0 to < 2.4.0, which is not a correction: it is the fixed version being named, and only the maintainers could do that.
Credits read euriconicacio as reporter, UlisesGascon as remediation developer and bjohansebas as remediation reviewer.
One detail worth knowing for reasons beyond this bug: between the 10th and the 14th the advisory displayed the identifier while the record did not exist. Looking CVE-2026-88932 up in that window returned a reserved ID and nothing else. A CVE ID printed on a page and a CVE record are different objects, and only the second carries the credits and references your tooling ingests. Cite both — the advisory for the range and the detail, the record for the identifier — because neither one links to the other.
The part worth keeping
The lesson is not “write more tests”. The project wrote the test, in the same commit as the fix, for exactly this failure mode.
It is narrower and less comfortable. A regression test proves that one path is fixed, and then quietly stands in for all the others. This one aborted at the single instant when a second, unrelated mechanism was already covering for the code under test. It passed, and its passing read as evidence that the cleanup filter worked — when what it actually demonstrated was that busboy destroys streams.
If you have a fix with a test beside it, the question that finds this class of bug is not does the test pass. It is what else could be making it pass — and the way to answer it is to break the other thing on purpose and see whether the test notices.
Upgrade to 2.4.0. And have a look in uploads/ while you are there.
Eurico Nicacio — @h3llh0und