Skip to content

Commit 2ad8b68

Browse files
UlisesGascondkoazw
andcommitted
fix: destroy disk write stream on aborted uploads to prevent fd leak
Ref: GHSA-qfvm-cv95-jqjf Co-authored-by: dkoazw <dkoazw@users.noreply.github.com>
1 parent 25ec9bb commit 2ad8b68

7 files changed

Lines changed: 424 additions & 12 deletions

File tree

lib/make-middleware.js

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -234,10 +234,14 @@ function makeMiddleware (setup) {
234234
busboy.on('file', function (fieldname, fileStream, { filename, encoding, mimeType }) {
235235
var pendingWritesIncremented = false
236236

237+
function decrementPendingWrites () {
238+
if (!pendingWritesIncremented) return
239+
pendingWritesIncremented = false
240+
pendingWrites.decrement()
241+
}
242+
237243
fileStream.on('error', function (err) {
238-
if (pendingWritesIncremented) {
239-
pendingWrites.decrement()
240-
}
244+
decrementPendingWrites()
241245
abortWithError(err)
242246
})
243247

@@ -300,20 +304,20 @@ function makeMiddleware (setup) {
300304
if (aborting) {
301305
appender.removePlaceholder(placeholder)
302306
uploadedFiles.push({ ...file, ...info })
303-
return pendingWrites.decrement()
307+
return decrementPendingWrites()
304308
}
305309

306310
if (err) {
307311
appender.removePlaceholder(placeholder)
308-
pendingWrites.decrement()
312+
decrementPendingWrites()
309313
return abortWithError(err)
310314
}
311315

312316
var fileInfo = { ...file, ...info }
313317

314318
appender.replacePlaceholder(placeholder, fileInfo)
315319
uploadedFiles.push(fileInfo)
316-
pendingWrites.decrement()
320+
decrementPendingWrites()
317321
indicateDone()
318322
})
319323
})

lib/multer-error.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ var errorMessages = {
1010
LIMIT_UNEXPECTED_FILE: 'Unexpected field',
1111
MISSING_FIELD_NAME: 'Field name missing',
1212
LIMIT_FIELD_NESTING: 'Field name nesting too deep',
13-
LIMIT_FIELD_ARRAY_INDEX: 'Field name array index too large'
13+
LIMIT_FIELD_ARRAY_INDEX: 'Field name array index too large',
14+
STREAM_DESTROYED: 'File stream was destroyed'
1415
}
1516

1617
function MulterError (code, field) {

storage/disk.js

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ var fs = require('fs')
22
var os = require('os')
33
var path = require('path')
44
var crypto = require('crypto')
5+
var pipeline = require('stream').pipeline
6+
var MulterError = require('../lib/multer-error')
7+
8+
// Write streams still open for a file, so _removeFile can wait for the
9+
// descriptor to be closed before unlinking (Windows refuses to unlink open files).
10+
var openStreams = new WeakMap()
511

612
function getFilename (req, file, cb) {
713
crypto.randomBytes(16, function (err, raw) {
@@ -35,15 +41,17 @@ DiskStorage.prototype._handleFile = function _handleFile (req, file, cb) {
3541

3642
var finalPath = path.join(destination, filename)
3743

38-
if (file.stream.destroyed) return
44+
if (file.stream.destroyed) return cb(new MulterError('STREAM_DESTROYED'))
3945

4046
var outStream = fs.createWriteStream(finalPath)
4147

4248
file.path = finalPath
49+
openStreams.set(file, outStream)
50+
outStream.once('close', function () { openStreams.delete(file) })
51+
52+
pipeline(file.stream, outStream, function (err) {
53+
if (err) return cb(err)
4354

44-
file.stream.pipe(outStream)
45-
outStream.on('error', cb)
46-
outStream.on('finish', function () {
4755
cb(null, {
4856
destination: destination,
4957
filename: filename,
@@ -62,7 +70,13 @@ DiskStorage.prototype._removeFile = function _removeFile (req, file, cb) {
6270
delete file.filename
6371
delete file.path
6472

65-
fs.unlink(path, cb)
73+
var outStream = openStreams.get(file)
74+
if (!outStream) return fs.unlink(path, cb)
75+
76+
// Wait for the descriptor to be released before unlinking; destroy() is a
77+
// no-op if the stream is already being torn down.
78+
outStream.once('close', function () { fs.unlink(path, cb) })
79+
outStream.destroy()
6680
}
6781

6882
module.exports = function (opts) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/* eslint-env mocha */
2+
3+
var stream = require('stream')
4+
5+
var multer = require('../')
6+
var rimraf = require('rimraf')
7+
var temp = require('fs-temp')
8+
9+
// @see https://github.com/expressjs/multer/security/advisories/GHSA-qfvm-cv95-jqjf
10+
11+
describe('disk storage _handleFile with an already-destroyed source stream', function () {
12+
var uploadDir
13+
14+
beforeEach(function (done) {
15+
temp.mkdir(function (err, dir) {
16+
if (err) return done(err)
17+
uploadDir = dir
18+
done()
19+
})
20+
})
21+
22+
afterEach(function (done) {
23+
rimraf(uploadDir, done)
24+
})
25+
26+
it('should always invoke the callback', function (done) {
27+
this.timeout(2000)
28+
29+
var storage = multer.diskStorage({ destination: uploadDir })
30+
31+
var source = new stream.PassThrough()
32+
source.destroy()
33+
34+
var file = {
35+
fieldname: 'file',
36+
originalname: 'destroyed.bin',
37+
stream: source
38+
}
39+
40+
// If the callback is never invoked (the pre-write guard returning without
41+
// settling), mocha fails this test with a timeout. A double invocation is
42+
// caught by mocha's "done() called multiple times".
43+
storage._handleFile({}, file, function () {
44+
done()
45+
})
46+
})
47+
})

test/abort-fd-leak.js

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* eslint-env mocha */
2+
3+
var assert = require('assert')
4+
var fs = require('fs')
5+
var path = require('path')
6+
var http = require('http')
7+
8+
var express = require('express')
9+
var multer = require('../')
10+
var rimraf = require('rimraf')
11+
var temp = require('fs-temp')
12+
13+
// @see https://github.com/expressjs/multer/security/advisories/GHSA-qfvm-cv95-jqjf
14+
15+
// Count open file descriptors that point at a now-deleted file inside `dir`.
16+
// Linux exposes these via /proc/self/fd as symlinks whose target ends with
17+
// " (deleted)". Returns -1 when /proc/self/fd is unavailable (non-Linux).
18+
function countLeakedFds (dir) {
19+
var fdDir = '/proc/self/fd'
20+
var entries
21+
22+
try {
23+
entries = fs.readdirSync(fdDir)
24+
} catch (e) {
25+
return -1
26+
}
27+
28+
var leaked = 0
29+
30+
for (var i = 0; i < entries.length; i++) {
31+
var target
32+
33+
try {
34+
target = fs.readlinkSync(path.join(fdDir, entries[i]))
35+
} catch (e) {
36+
continue
37+
}
38+
39+
if (target.indexOf(dir) === 0 && / \(deleted\)$/.test(target)) {
40+
leaked++
41+
}
42+
}
43+
44+
return leaked
45+
}
46+
47+
describe('file descriptor leak on aborted uploads', function () {
48+
var uploadDir, server, port
49+
50+
beforeEach(function (done) {
51+
temp.mkdir(function (err, dir) {
52+
if (err) return done(err)
53+
54+
uploadDir = dir
55+
var upload = multer({ dest: dir })
56+
var app = express()
57+
58+
app.post('/upload', upload.single('file'), function (req, res) {
59+
res.json({ success: true })
60+
})
61+
62+
app.use(function (err, req, res, next) {
63+
res.status(400).json({ error: err.message || err.code })
64+
})
65+
66+
server = app.listen(0, function () {
67+
port = server.address().port
68+
done()
69+
})
70+
})
71+
})
72+
73+
afterEach(function (done) {
74+
server.close(function () {
75+
rimraf(uploadDir, done)
76+
})
77+
})
78+
79+
it('should not leak file descriptors when uploads abort mid-write', function (done) {
80+
this.timeout(20000)
81+
82+
if (countLeakedFds(uploadDir) === -1) return this.skip()
83+
84+
var attempts = 15
85+
86+
function abortOnce (next) {
87+
var boundary = 'FdLeakBound' + Date.now() + Math.random().toString(16).slice(2)
88+
var preamble =
89+
'--' + boundary + '\r\n' +
90+
'Content-Disposition: form-data; name="file"; filename="leak.bin"\r\n' +
91+
'Content-Type: application/octet-stream\r\n\r\n'
92+
var chunk = Buffer.alloc(64 * 1024, 0x5a)
93+
94+
var req = http.request({
95+
hostname: 'localhost',
96+
port: port,
97+
path: '/upload',
98+
method: 'POST',
99+
headers: {
100+
'Content-Type': 'multipart/form-data; boundary=' + boundary,
101+
'Content-Length': Buffer.byteLength(preamble) + (chunk.length * 10)
102+
}
103+
})
104+
105+
req.on('error', function () {})
106+
req.write(preamble)
107+
req.write(chunk)
108+
109+
setTimeout(function () {
110+
req.destroy()
111+
setTimeout(next, 100)
112+
}, 30)
113+
}
114+
115+
var i = 0
116+
117+
function loop () {
118+
if (i++ >= attempts) {
119+
// give multer's abort cleanup time to run
120+
setTimeout(function () {
121+
var leaked = countLeakedFds(uploadDir)
122+
assert.strictEqual(leaked, 0, 'leaked ' + leaked + ' deleted-file descriptor(s) after ' + attempts + ' aborted uploads')
123+
done()
124+
}, 500)
125+
return
126+
}
127+
128+
abortOnce(loop)
129+
}
130+
131+
loop()
132+
})
133+
})

0 commit comments

Comments
 (0)