Node.js · lesson 13 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
File Uploads with multer
An unhandled MulterError crashes into a 500; catch its codes for 413 and 400, see busboy strip a traversal filename, and fool a fileFilter with a lie.
By SkillByExample · Updated
A multipart/form-data upload is just a request body with a different encoding — multer parses it into req.file, but what it does with a file that’s too big, has the wrong field name, or carries a hostile filename depends entirely on whether anything downstream is actually checking for those cases.
An Unhandled MulterError Is a Generic 500
The plainest possible upload route: one field, avatar, with a 1024-byte size limit, and nothing watching for errors from multer itself.
// server-plain.js
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 1024 },
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({
field: req.file.fieldname,
original: req.file.originalname,
stored: req.file.filename,
size: req.file.size,
});
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
package.json pins the two dependencies this entire lesson uses, with no ^ ranges so a reader installs exactly what produced the output below:
{
"name": "file-uploads-with-multer",
"version": "1.0.0",
"type": "module",
"dependencies": {
"express": "5.3.0",
"multer": "2.5.0"
}
}
node_modules lives in its own named Docker volume, nodejs-file-uploads-with-multer-modules — a bind mount shares files with the Linux VM slowly enough that installing straight into it is minutes slower than a volume:
$ docker run --rm -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund
added 73 packages in 4s
Two files: a small one well under the limit, and one at roughly double it, to upload against the same route:
$ echo "a small avatar" > small.txt
$ head -c 2000 /dev/zero | tr '\0' 'x' > big.txt
$ docker run -d --name nodejs-uploads-plain -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-plain.js
b1ffd13a556c2895be74ad5db147978a70f82bbf25ebaf16393a77f7df526e7d
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt"
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-EpElqDL1KqA78+V9n/vwi8XPGkI"
Date: Tue, 03 Nov 2026 12:32:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"field":"avatar","original":"small.txt","stored":"db79c4ae86e56f1a61c951fe32c8c03c","size":15}
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 973
Date: Tue, 03 Nov 2026 12:32:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>MulterError: File too large<br> at abortWithCode (/app/node_modules/multer/lib/make-middleware.js:225:22)<br> at FileStream.<anonymous> (/app/node_modules/multer/lib/make-middleware.js:330:11)<br> at FileStream.emit (node:events:514:20)<br> at SBMH.ssCb [as _cb] (/app/node_modules/busboy/lib/types/multipart.js:479:32)<br> at feed (/app/node_modules/streamsearch/lib/sbmh.js:219:14)<br> at SBMH.push (/app/node_modules/streamsearch/lib/sbmh.js:104:16)<br> at Multipart._write (/app/node_modules/busboy/lib/types/multipart.js:567:19)<br> at writeOrBuffer (node:internal/streams/writable:574:12)<br> at _write (node:internal/streams/writable:503:10)<br> at Writable.write (node:internal/streams/writable:512:10)</pre>
</body>
</html>
$ docker rm -f nodejs-uploads-plain
nodejs-uploads-plain
The 15-byte small.txt comes back 201 Created with multer’s own metadata about the file. big.txt is 2000 bytes against a 1024-byte limit, so multer’s upload.single("avatar") middleware calls next(err) with a MulterError — and because nothing downstream ever added an error-handling middleware, Express’s own default handler catches it and answers 500 Internal Server Error with an HTML page that includes the full stack trace, straight out of node_modules/multer and node_modules/busboy inside the container. The size limit did its job; nothing told the client why in a way an API consumer could act on, and the response leaked internal file paths in the process.
That stack trace is also conditional on one environment variable: Express’s own default handler only writes it into the response when NODE_ENV isn’t "production". Nothing in server-plain.js changed — only the container’s environment did:
$ docker run -d --name nodejs-uploads-plain-prod -p 3000:3000 -e NODE_ENV=production -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-plain.js
1ef1c60cb1253c5de50ce80755b14627e5cd577fea23be4939150751827862a6
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 148
Date: Tue, 03 Nov 2026 12:32:50 GMT
Connection: keep-alive
Keep-Alive: timeout=5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Internal Server Error</pre>
</body>
</html>
$ docker rm -f nodejs-uploads-plain-prod
nodejs-uploads-plain-prod
Same oversized file, same route, same missing error-handling middleware — but the body is now just Internal Server Error, with none of the earlier page’s stack trace or node_modules paths in it. That’s Express’s own default handler switching behavior on a single environment variable, not anything this lesson’s code checks for — which is exactly why the handled version in the next section answers with a real status code and a readable message instead of depending on it.
Catching MulterError by Its code
Multer’s errors all share one class, multer.MulterError, with a code property naming exactly what went wrong. A four-argument error-handling middleware, registered after the route, maps the two codes this lesson triggers to real status codes:
// server-handled.js
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 1024 },
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({
field: req.file.fieldname,
original: req.file.originalname,
stored: req.file.filename,
size: req.file.size,
});
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(413).json({ error: err.code, message: "File too large" });
return;
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
return;
}
}
res.status(500).json({ error: "internal error" });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
Three requests, each changing exactly one thing from the success case: the same oversized file as before, then the same small file under a field name the route never asked for:
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
c4c289267a0eeb7ecf1d70aa37a3f1439fbae07b2c524f2f4a02e4e141703dc9
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt"
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-XN6dwsB1hrvMexuAFLzBxXWEunA"
Date: Tue, 03 Nov 2026 12:32:56 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"field":"avatar","original":"small.txt","stored":"78e414c41fc704e146b4c168e37da9d7","size":15}
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 413 Payload Too Large
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 54
ETag: W/"36-r1sQ5w0H606K4infMuq1tRU4tPk"
Date: Tue, 03 Nov 2026 12:32:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"LIMIT_FILE_SIZE","message":"File too large"}
$ curl -s -i -X POST http://localhost:3000/avatar -F "photo=@small.txt"
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 72
ETag: W/"48-uGXgRETkOcElfDj3YGkspmpFo74"
Date: Tue, 03 Nov 2026 12:32:57 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"LIMIT_UNEXPECTED_FILE","message":"Unexpected field \"photo\""}
Same oversized file, same route, but now a 413 Payload Too Large with {"error":"LIMIT_FILE_SIZE",...} instead of a stack trace. The third request keeps the file small and valid — only the field name changed, from avatar to photo — and multer’s LIMIT_UNEXPECTED_FILE fires because upload.single("avatar") only ever expects that one named field; the middleware reports it as 400 with the field name quoted from err.field, not a guess.
What multer Does to a Client-Supplied Filename
The same nodejs-uploads-handled container is still running. This time the upload’s filename itself is an attempted path-traversal string:
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=../../etc/x"
{"field":"avatar","original":"x","stored":"6581ec00d0908ee46b542a68386600b6","size":15}
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=..\\..\\etc\\x"
{"field":"avatar","original":"x","stored":"a50a3af46fef75558a7ec4f2dcc0f40a","size":15}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled
Both original fields come back x, whether the traversal used forward slashes or backslashes — not because multer sanitizes anything itself, but because busboy, the multipart parser multer wraps, strips the filename down to a basename before multer’s code ever runs:
$ docker run --rm -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 sh -c "grep -n -A 12 'function basename' node_modules/busboy/lib/utils.js"
479:function basename(path) {
480- if (typeof path !== 'string')
481- return '';
482- for (let i = path.length - 1; i >= 0; --i) {
483- switch (path.charCodeAt(i)) {
484- case 0x2F: // '/'
485- case 0x5C: // '\'
486- path = path.slice(i + 1);
487- return (path === '..' || path === '.' ? '' : path);
488- }
489- }
490- return (path === '..' || path === '.' ? '' : path);
491-}
0x2F and 0x5C are the character codes for / and \ — this basename() strips both unconditionally, unlike Node’s own path.basename, which only treats / as a separator on Linux. That’s also why a custom filename callback that naively uses file.originalname directly is still safe from this particular attack: by the time multer’s own code runs, file.originalname has already lost every directory segment.
// server-naive.js
import express from "express";
import multer from "multer";
const storage = multer.diskStorage({
destination: "uploads/",
filename: (req, file, cb) => cb(null, file.originalname), // looks risky; busboy already stripped the path by here
});
const upload = multer({ storage, limits: { fileSize: 1024 } });
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({ original: req.file.originalname, stored: req.file.filename, path: req.file.path });
});
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-naive -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-naive.js
c983374c5ec29bcfadcc25da8f499123c4b5294de362c7555cf4c7311ff3c245
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=../../../etc/passwd"
{"original":"passwd","stored":"passwd","path":"uploads/passwd"}
$ docker rm -f nodejs-uploads-naive
nodejs-uploads-naive
stored and path both show passwd, not etc/passwd or anything above uploads/ — file.originalname was already reduced to passwd before this callback ever ran, so even this deliberately naive storage function never saw the traversal attempt at all.
That protection is conditional on busboy’s own default behavior, not anything multer’s naive callback does itself. multer’s preservePath: true option tells busboy to skip basename() and hand over the filename exactly as the client sent it — which brings the same naive callback’s risk straight back:
// server-naive-preservepath.js
import express from "express";
import multer from "multer";
const storage = multer.diskStorage({
destination: "uploads/",
filename: (req, file, cb) => cb(null, file.originalname),
});
const upload = multer({ storage, preservePath: true, limits: { fileSize: 1024 } });
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({ original: req.file.originalname, stored: req.file.filename, path: req.file.path });
});
app.use((err, req, res, next) => res.status(500).json({ error: err.message }));
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-naive-pp -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-naive-preservepath.js
b038343a6cfa0aad480e8b181194e72a808ecef12481fb8a1ea90ad6c951be09
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=../x.txt"
{"original":"../x.txt","stored":"../x.txt","path":"x.txt"}
$ docker exec nodejs-uploads-naive-pp sh -c "ls /app/x.txt"
/app/x.txt
$ docker rm -f nodejs-uploads-naive-pp
nodejs-uploads-naive-pp
original and stored both come back ../x.txt this time — busboy never touched it — and path is x.txt, resolved relative to uploads/’s parent, /app, not inside uploads/ itself. ls /app/x.txt finds the file sitting one directory above the destination the server was configured with. And even without preservePath, the base filename itself is still whatever the client sent: this same naive callback would let one upload silently overwrite another user’s file of the same name, create a hidden dotfile, or accept a client-chosen extension like .html — stored XSS if that file is ever served back to a browser.
A fileFilter Checks a Claim, Not the Bytes
fileFilter runs before a file is saved and can reject it outright — but the only thing it has to check is file.mimetype, the Content-Type the client’s own multipart request declared for that part:
// server-filter.js
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 1024 },
fileFilter(req, file, cb) {
if (file.mimetype !== "image/png" && file.mimetype !== "image/jpeg") {
cb(new Error(`unsupported mimetype: ${file.mimetype}`));
return;
}
cb(null, true);
},
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({
original: req.file.originalname,
mimetype: req.file.mimetype,
stored: req.file.filename,
});
});
app.use((err, req, res, next) => {
res.status(400).json({ error: "upload rejected", message: err.message });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
Three uploads: a real PNG, the same small text file as every earlier test, and that same text file again — relabeled with a .png name and an image/png type, both of which curl lets a caller set by hand:
$ printf '\x89PNG\r\n\x1a\n' > fake.png
$ docker run -d --name nodejs-uploads-filter -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-filter.js
bbf908897cefc983fe461930e8a307c29a1c4da14b8c36781de56dc18f3bf4ee
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@fake.png"
{"original":"fake.png","mimetype":"image/png","stored":"23e92671c9c83f53b78b8a4b2b15b609"}
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt"
{"error":"upload rejected","message":"unsupported mimetype: text/plain"}
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=fake.png;type=image/png"
{"original":"fake.png","mimetype":"image/png","stored":"b39930220c3164a7b3c1ccd04f825078"}
$ docker rm -f nodejs-uploads-filter
nodejs-uploads-filter
The real PNG passes with "mimetype":"image/png". small.txt on its own is rejected — curl guesses text/plain from the .txt extension, which fails the allowlist. But the third request is the exact same plain-text bytes as the second, with only its filename and declared Content-Type changed on the command line — and it’s accepted, reporting "mimetype":"image/png" for a file that is still, byte for byte, plain text. fileFilter never opened the file to check; it trusted a header the client wrote itself.
None of this lesson’s checks read a single byte of file content: the size limit counts bytes without looking at them, the field-name check only reads request metadata, and fileFilter reads a header the client controls. None of it defends against a malicious payload inside an otherwise-valid-looking file, a virus-scanned upload, or a user quietly filling a disk with uploads under the size cap — see Unsafe File Uploads: secure_filename, Allowlists and Size Limits for checking a file’s actual leading bytes, naming an extension allowlist, and the size-limit and random-filename patterns this lesson’s own examples already lean on.
Practice
1. Raise server-handled.js’s limits.fileSize from 1024 to 5120, and confirm the same 2000-byte big.txt that triggered 413 earlier now uploads successfully.
Solution
// server-handled.js (fileSize raised)
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 5120 }, // was 1024
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({
field: req.file.fieldname,
original: req.file.originalname,
stored: req.file.filename,
size: req.file.size,
});
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(413).json({ error: err.code, message: "File too large" });
return;
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
return;
}
}
res.status(500).json({ error: "internal error" });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
cb81953eff30d8dbaf5e3a8f25235959189301d4a5b1f8630c43e693e268ef45
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
HTTP/1.1 201 Created
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 95
ETag: W/"5f-Hbj129ddfntbWM4Rz6a3HhtmKeE"
Date: Tue, 03 Nov 2026 12:33:32 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"field":"avatar","original":"big.txt","stored":"aed9fd2942ef6f6af06c0e889b89f42c","size":2000}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled
201 Created this time — the same 2000-byte file is now comfortably under the new 5120-byte limit, so multer never raises LIMIT_FILE_SIZE at all.
2. Add limits.files: 1 to server-handled.js’s existing upload.single("avatar") route (at most one file in the whole request) and upload two files under the same field name in one request. Confirm the response is a generic 500 first, then add a case for LIMIT_FILE_COUNT to the error middleware and confirm it becomes a 400.
Solution
maxCount on upload.array("avatar", 1) — effectively what the main example’s upload.single("avatar") already is — reports going over as LIMIT_UNEXPECTED_FILE, the same code the wrong-field-name test triggered earlier. LIMIT_FILE_COUNT is a separate limit: multer’s own make-middleware.js wires busboy’s filesLimit event straight to LIMIT_FILE_COUNT regardless of which upload.* method sits in front of it, so adding limits.files: 1 to upload.single("avatar") triggers it the exact same way it would on upload.any() or upload.array() — nothing about the method matters, only the count:
// server-handled.js (limits.files added, LIMIT_FILE_COUNT not handled yet)
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 1024, files: 1 },
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({ field: req.file.fieldname, stored: req.file.filename });
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(413).json({ error: err.code, message: "File too large" });
return;
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
return;
}
}
res.status(500).json({ error: "internal error" });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
75a77db47bae81ff6bb9e93167494af2d1f4869ad7265711d362f7e8ea2b9f25
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt" -F "avatar=@small.txt"
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 26
ETag: W/"1a-nO1SMtFqjAylRZvcD/JJ2LGv/qs"
Date: Tue, 03 Nov 2026 12:33:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"internal error"}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled
That first run’s error middleware has no LIMIT_FILE_COUNT branch, so multer’s next(err) falls through every if, and the catch-all’s own res.status(500).json({ error: "internal error" }) is what answers — a generic 500, same as the very first, unhandled example in this lesson, not a crash the process itself didn’t survive. Add a branch for it:
// server-handled.js (limits.files added, LIMIT_FILE_COUNT handled)
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 1024, files: 1 },
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({ field: req.file.fieldname, stored: req.file.filename });
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === "LIMIT_FILE_SIZE") {
res.status(413).json({ error: err.code, message: "File too large" });
return;
}
if (err.code === "LIMIT_UNEXPECTED_FILE") {
res.status(400).json({ error: err.code, message: `Unexpected field "${err.field}"` });
return;
}
if (err.code === "LIMIT_FILE_COUNT") { // new
res.status(400).json({ error: err.code, message: "Too many files" }); // new
return; // new
}
}
res.status(500).json({ error: "internal error" });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
Same two-file request, against a fresh container running the edited file:
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
48b66cdb0bddeca92c22cec5e79119f9060dd2cb83c013d68455f3a2ddccdf4f
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -i -X POST http://localhost:3000/avatar -F "avatar=@small.txt" -F "avatar=@small.txt"
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 55
ETag: W/"37-bc1uXe7Pn9piOTKx1vaBYfA1sUw"
Date: Tue, 03 Nov 2026 12:33:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"LIMIT_FILE_COUNT","message":"Too many files"}
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled
Same two-file request, same files: 1 limit — but now 400 Bad Request with {"error":"LIMIT_FILE_COUNT","message":"Too many files"}, because the only thing that changed between the two runs is whether that err.code had a branch waiting for it.
3. Add "image/webp" to server-filter.js’s mimetype allowlist, then repeat the spoofing request from the main example with a .webp name and image/webp type instead of .png/image/png, and confirm it’s still accepted.
Solution
// server-filter.js (webp added to the allowlist)
import express from "express";
import multer from "multer";
const upload = multer({
dest: "uploads/",
limits: { fileSize: 1024 },
fileFilter(req, file, cb) {
if (
file.mimetype !== "image/png" &&
file.mimetype !== "image/jpeg" &&
file.mimetype !== "image/webp" // new
) {
cb(new Error(`unsupported mimetype: ${file.mimetype}`));
return;
}
cb(null, true);
},
});
const app = express();
app.get("/healthz", (req, res) => res.json({ status: "ok" }));
app.post("/avatar", upload.single("avatar"), (req, res) => {
res.status(201).json({
original: req.file.originalname,
mimetype: req.file.mimetype,
stored: req.file.filename,
});
});
app.use((err, req, res, next) => {
res.status(400).json({ error: "upload rejected", message: err.message });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ docker run -d --name nodejs-uploads-filter -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-filter.js
7fec7b233a607c241172cb97711c2870b6ffd48e8d8fca52126a6f1f2780839c
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ curl -s -X POST http://localhost:3000/avatar -F "avatar=@small.txt;filename=fake.webp;type=image/webp"
{"original":"fake.webp","mimetype":"image/webp","stored":"066a702571bf6d22b9aa9d0c54538437"}
$ docker rm -f nodejs-uploads-filter
nodejs-uploads-filter
Still 201, still plain text underneath, now reported as "mimetype":"image/webp". Widening the allowlist widens exactly what fileFilter will believe when a client lies about it — it does nothing to make the check itself read any actual bytes.
4. After a 413 from an oversized upload, confirm no new file was actually written to the upload folder.
Solution
uploads/ is a plain folder inside this lesson’s own project directory, bind-mounted into every container the lesson has started — every earlier section’s successful uploads are still sitting in it, so checking that it’s empty isn’t the right test any more. Counting its files before and after the oversized request is, reusing server-handled.js as Practice 2 last left it — limits.files: 1 and the LIMIT_FILE_COUNT branch are both still in there, but this request only uploads one file, so neither one fires; only the original limits.fileSize: 1024 is in play, the same as in the main example:
$ docker run -d --name nodejs-uploads-handled -p 3000:3000 -v "$(pwd)":/app -v nodejs-file-uploads-with-multer-modules:/app/node_modules -w /app node:26.13 node server-handled.js
41eff0c08da90b228844afb9d2807e6cad81d23dd99099a2ab6dce0aa38b69fb
$ until curl -s -o /dev/null http://localhost:3000/healthz; do sleep 0.3; done
$ before=$(docker exec nodejs-uploads-handled sh -c 'ls uploads | wc -l'); echo "$before"
9
$ curl -s -o /dev/null -w '%{http_code}\n' -X POST http://localhost:3000/avatar -F "avatar=@big.txt"
413
$ after=$(docker exec nodejs-uploads-handled sh -c 'ls uploads | wc -l'); echo "$after"
9
$ [ "$before" = "$after" ] && echo SAME
SAME
$ docker rm -f nodejs-uploads-handled
nodejs-uploads-handled
413 comes back, and the file count is identical before and after — SAME. multer removes the partially-written file itself once the size limit trips mid-stream; a rejected upload never leaves bytes behind for a client to retry into eventually filling the disk one byte-limit violation at a time.