Decide the type from the bytes, and never let the client pick the name. Use multer memoryStorage with limits.fileSize, require the extension, file.mimetype and the PNG or JPEG signature to agree, write under a random name outside the web root, and serve through a route that sends res.attachment() and X-Content-Type-Options: nosniff.
- Find every multer (or busboy, formidable) upload and where its files end up: a destination under the folder express.static serves, a filename callback that uses file.originalname, or a fileFilter that only checks file.mimetype.
- Read the upload with multer.memoryStorage() and limits: { fileSize, files: 1 }, so an oversized file fails with LIMIT_FILE_SIZE before anything is written, and answer that error with 413 in your error handler.
- Accept the file only if path.extname(file.originalname).toLowerCase() is on your allowlist, file.mimetype is the type that extension names, and file.buffer starts with that type's signature (89 50 4E 47 0D 0A 1A 0A for PNG, FF D8 FF for JPEG). Answer 415 otherwise.
- Write it under randomBytes(16).toString('hex') plus the extension of the verified type, in a folder outside the web root, with the 'wx' flag so an existing file is never replaced.
- Serve it from a route that accepts only names your code generates and sends res.attachment(name) with X-Content-Type-Options: nosniff, or serve uploads from a separate origin.
- Add regression tests with this card's payloads: an HTML page and an SVG with script, HTML named avatar.png, avatar.png.html, a PNG that is also a page, ../ and absolute file names, a file one byte over the limit, and two users uploading the same name.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
const upload = multer({
storage: multer.diskStorage({
destination: path.join(webRoot, 'uploads'),
filename: (req, file, cb) => cb(null, file.originalname),
}),
});
router.post('/avatars', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'attach the image in the "avatar" field' });
}
// express.static serves the web root, so the file is public as soon as it is written.
res.status(201).json({ url: `/uploads/${encodeURIComponent(req.file.filename)}` });
});With IMPL=vulnerable, 7 of 13 tests fail (7 exploit tests).
The fix: check and store the upload
const MAX_BYTES = 512 * 1024;
// The only types this feature needs, keyed by the extensions that may name them.
const PNG = { mime: 'image/png', ext: '.png', signature: Buffer.from('89504e470d0a1a0a', 'hex') };
const JPEG = { mime: 'image/jpeg', ext: '.jpg', signature: Buffer.from('ffd8ff', 'hex') };
const TYPES = new Map([['.png', PNG], ['.jpg', JPEG], ['.jpeg', JPEG]]);
export function avatarRoutes({ storageDir }) {
const router = express.Router();
// memoryStorage: nothing reaches the disk before the checks pass. limits.fileSize stops reading
// past MAX_BYTES and fails the request with LIMIT_FILE_SIZE, which app.js answers with 413.
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: MAX_BYTES, files: 1 } });
router.post('/avatars', upload.single('avatar'), async (req, res) => {
const file = req.file;
if (!file) {
return res.status(400).json({ error: 'attach the image in the "avatar" field' });
}
// extname() returns the last extension only: '.html' for avatar.png.html.
const type = TYPES.get(path.extname(file.originalname).toLowerCase());
if (!type || file.mimetype !== type.mime
|| !file.buffer.subarray(0, type.signature.length).equals(type.signature)) {
throw new UploadError(415, 'avatars must be PNG or JPEG images');
}
// A name the server makes up, in a folder express.static does not serve; 'wx' never overwrites.
const name = randomBytes(16).toString('hex') + type.ext;
await writeFile(path.join(storageDir, name), file.buffer, { flag: 'wx' });
res.status(201).json({ url: `/avatars/${name}` });
});With IMPL=fixed, 13 of 13 tests pass.
The fix: serve it as a download
router.get('/avatars/:name', async (req, res) => {
const { name } = req.params;
// Only names this code generates; anything else, ../ included, is simply not found.
if (!/^[0-9a-f]{32}\.(png|jpg)$/.test(name)) {
return res.status(404).json({ error: 'not found' });
}
const content = await readFile(path.join(storageDir, name)).catch((err) => {
if (err.code === 'ENOENT') return null;
throw err;
});
if (content === null) {
return res.status(404).json({ error: 'not found' });
}
// attachment() sets Content-Disposition: attachment and the Content-Type of our own .png or
// .jpg name; nosniff tells the browser not to second-guess that type.
res.attachment(name).set('X-Content-Type-Options', 'nosniff').send(content);
});With IMPL=fixed, 13 of 13 tests pass.
A fix that looks safe but is not
const ALLOWED_TYPES = ['image/png', 'image/jpeg'];
const upload = multer({
storage: multer.diskStorage({
destination: path.join(webRoot, 'uploads'),
filename: (req, file, cb) =>
cb(null, randomBytes(16).toString('hex') + path.extname(file.originalname).toLowerCase()),
}),
limits: { fileSize: 512 * 1024, files: 1 },
fileFilter: (req, file, cb) => {
if (ALLOWED_TYPES.includes(file.mimetype)) cb(null, true);
else cb(new UploadError(415, 'avatars must be PNG or JPEG images'));
},
});
router.post('/avatars', upload.single('avatar'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'attach the image in the "avatar" field' });
}
res.status(201).json({ url: `/uploads/${req.file.filename}` });
});With IMPL=wrong, 3 of 13 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
test('an HTML page with a script is refused', async () => {
await assertRefused(await upload('profile.html', 'text/html', html), 415);
});
test('an SVG with a script is refused', async () => {
await assertRefused(await upload('avatar.svg', 'image/svg+xml', svg), 415);
});
test('HTML named avatar.png and declared image/png is refused (mismatched type)', async () => {
await assertRefused(await upload('avatar.png', 'image/png', html), 415);
});
test('a double extension is refused even when the bytes start like a PNG', async () => {
await assertRefused(await upload('avatar.png.html', 'image/png', polyglot), 415);
});
test('a PNG that is also an HTML page is served only as an image download', async () => {
const res = await upload('avatar.png', 'image/png', polyglot);
assert.equal(res.status, 201);
const served = await fetchUrl(res.url);
assert.match(served.headers.get('content-type'), /^image\/png\b/);
assert.equal(served.headers.get('x-content-type-options'), 'nosniff');
assert.match(served.headers.get('content-disposition') ?? 'inline', /^attachment\b/);
});
test('../ in the file name cannot put the file outside the upload folder', async () => {
await upload('../escaped.png', 'image/png', png);
assert.deepEqual(await filesOutsideUploadFolders(), []);
});
test('an absolute file name cannot put the file outside the upload folder', async () => {
await upload(path.join(tmp, 'escaped.png'), 'image/png', png);
assert.deepEqual(await filesOutsideUploadFolders(), []);
});
test('a file one byte over the limit is refused with 413', async () => {
await assertRefused(await upload('avatar.png', 'image/png', padTo(png, seed.limitBytes + 1)), 413);
});
test("an upload cannot replace another user's file by reusing its name", async () => {
const alice = await upload('avatar.png', 'image/png', png);
const mallory = await upload('avatar.png', 'image/png', otherPng);
assert.equal(alice.status, 201);
assert.equal(mallory.status, 201);
const served = await fetchUrl(alice.url);
assert.deepEqual(Buffer.from(await served.arrayBuffer()), png, "Alice's URL must still serve her own image");
});
});- file.mimetype is the Content-Type of the multipart part, which the client writes. Browsers derive it from the extension; a script can send image/png with an HTML body, which is how avatar.png.html gets past the wrong implementation.
- Keeping the client's extension on a random name, as in Date.now() + path.extname(file.originalname), still lets the extension decide how express.static serves the file: <random>.html is served as text/html.
- express.static (serve-static 2.2.1 and send 1.2.1) takes the Content-Type from the extension and sends files inline without X-Content-Type-Options; it adds nosniff only to its own error and redirect pages.
- path.extname() returns only the last extension, which is what the fixed code relies on. Checks such as /\.(png|jpe?g)/.test(name) without a $ anchor, or name.includes('.png'), accept avatar.png.html.
- busboy, which multer uses, keeps only the base name of the file name unless preservePath is true, so ../escaped.png and an absolute name stayed inside the upload folder in this card's vulnerable code. The name still chooses the extension and which earlier upload a repeated name replaces.
- limits.fileSize defaults to Infinity. Since multer 2.3.0 a file of exactly limits.fileSize is accepted; earlier versions refused it.
- A file signature proves how a file starts, not that it is harmless: the polyglot test is a valid PNG that is also a working page. The serving headers are what keep it from rendering; re-encode images with a maintained library if you also need to strip trailing data and metadata.
- Content-Disposition: attachment does not stop an <img> element from showing the avatar; it makes a direct visit to the URL download the file instead of rendering it.
- The multer README warns never to add multer as global middleware (for example app.use(upload.any())), because clients could then upload files to routes you did not plan for.
- The upload route here has no sign-in. Behind cookie authentication, a multipart form post is a cross-site request like any other, so it needs the CSRF protection described in the CSRF card.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs file-upload/node-express does this for you).
- Against your own endpoint, upload an HTML file named avatar.png with Content-Type image/png and a .svg with a <script> element; both must be refused with nothing stored. Open the URL of an accepted avatar and check for Content-Disposition: attachment and X-Content-Type-Options: nosniff.
- Do not count on static analysis here: the Semgrep rules above found nothing in any implementation, and CodeQL's js/http-to-file-access flags the write in the fixed code rather than the vulnerable one. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | No findings on app.js, impl/ or lib/. The semgrep-rules repository (develop at 9bbf021) has no rule for multer, file.originalname or file.mimetype, so none of the three implementations is checked for what it accepts or where it stores it.Checked: Semgrep 1.180.0 (CE); semgrep scan --config p/default --config p/security-audit --config p/owasp-top-ten app.js impl lib |
| Semgrep | javascript.lang.security.audit.detect-non-literal-fs-filename.detect-non-literal-fs-filename | No findings. The vulnerable and wrong code make no fs call at all (multer's diskStorage writes the file), and the fixed code imports node:fs/promises, which the rule does not recognise.Checked: Semgrep 1.180.0 (CE); semgrep --config r/javascript.lang.security.audit.detect-non-literal-fs-filename.detect-non-literal-fs-filename impl (Ran 1 rule on 3 files) |
| Semgrep | javascript.express.security.audit.express-path-join-resolve-traversal.express-path-join-resolve-traversal | No findings: the client's file name reaches path.join only inside multer's filename callback, and the implementations import node:path, which the rule does not recognise.Checked: Semgrep 1.180.0 (CE); semgrep --config r/javascript.express.security.audit.express-path-join-resolve-traversal.express-path-join-resolve-traversal impl app.js (Ran 1 rule on 3 files) |
| CodeQL | js/path-injection | From Express.qll: req.file and req.files are modelled as request input, but the file object multer passes to the diskStorage filename callback is not, and the vulnerable code has no file-system call of its own for the name to reach. Expect no alert for the vulnerable code.Checked: github/codeql main at b5b8165; not executed |
| CodeQL | js/http-to-file-access | Tagged CWE-434. From HttpToFileAccessSpecific.qll and HttpToFileAccessCustomizations.qll: any request input that reaches the data argument of a file write is reported, so the fixed code's writeFile(..., file.buffer) would be flagged while the vulnerable code, whose write happens inside multer, would not. It has medium precision, so it runs in the security-extended suite, not the default code scanning suite.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| multer | 2.4.0 |
| busboy (multer's multipart parser) | 1.6.0 |
| node:crypto, node:fs/promises and node:path (built in) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the file-upload/node-expressfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs file-upload/node-express runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- express 5.3.0
- multer 2.4.0
- busboy 1.6.0
- Matrix
- fixed: 13 of 13 tests pass; vulnerable: 7 of 13 tests fail (7 exploit tests); wrong: 3 of 13 tests fail (3 exploit tests)
- Test results
- IMPL=fixed 13/13 pass; IMPL=vulnerable 7/13 fail, all exploit tests (HTML, SVG, HTML named avatar.png, avatar.png.html, the polyglot's headers, one byte over the limit, the reused name); IMPL=wrong 3/13 fail, all exploit tests (HTML named avatar.png, avatar.png.html served as text/html, the polyglot's headers). The ../ and absolute-name tests pass even for the vulnerable code because busboy keeps only the base name of the file name. The same results came from a copy installed with empty package caches and run in a no-network sandbox, and a headless Chrome 154 probe against this app confirmed that the vulnerable HTML, SVG and avatar.png.html uploads run their script when opened, while the fixed route's response is downloaded.
- Dependency freshness
- multer 2.4.0 was published 2026-09-14 (26 days before verification) with npm provenance; it and 2.3.0 fix several CVEs, among them CVE-2026-88932 in 2.4.0. express 5.3.0 was published 2026-10-09 and is taken at one day old because it is a security release (qs, body-parser and proxy-addr updates), as in the other cards. busboy 1.6.0 (2022-04-19) is the latest release. npm audit reported 0 vulnerabilities, and npm audit signatures verified the registry signatures of all 73 packages (multer, express and proxy-addr also carry provenance attestations).
- Scanners
- Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CodeQL read from source, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP File Upload Cheat Sheet
- OWASP ASVS 5.0.0, V5 File Handling
- multer README: storage, limits and fileFilter
- multer changelog (2.0.0 to 2.4.0 security fixes)
- busboy README: preservePath and the filename warning
- Express 5: res.attachment()
- WHATWG MIME Sniffing: matching an image type pattern
- MDN: X-Content-Type-Options
- RFC 6266: Content-Disposition in HTTP
Attack patterns: