Two options do the gatekeeping. limits is enforced by the parser, so an oversized body is aborted while it streams rather than after it lands. fileFilter runs per part, before a byte of that part is stored; ALLOWED is the MIME-to-extension map from the previous subsection.
const upload = multer({ storage,
limits: { fileSize: 2 * 1024 * 1024, files: 3, fields: 10 },
fileFilter: (req, file, cb) => ALLOWED.has(file.mimetype) ? cb(null, true)
: cb(new multer.MulterError('LIMIT_UNEXPECTED_FILE', file.fieldname)) });
app.use((err, req, res, next) => {
if (!(err instanceof multer.MulterError)) return next(err);
res.status(400).json({ error: err.code, field: err.field });
});Without that handler every rejection becomes a 500 with a stack trace. With it, each failure answers with a code a client can act on. The four responses below are real curl 3,008 -F results:
3 MB file 400 {"error":"LIMIT_FILE_SIZE","field":"cover"}
text/plain part 400 {"error":"LIMIT_UNEXPECTED_FILE","field":"cover"}
field "avatar" 400 {"error":"LIMIT_UNEXPECTED_FILE","field":"avatar"}
4 of 3 photos 400 {"error":"LIMIT_FILE_COUNT"}After the size rejection the upload directory held only the earlier successful upload: multer 12,087 deletes the partial file it had begun writing. It does not remove files stored for earlier parts of the same request, so a handler that rejects on its own rules must unlink req.files first.
file.mimetype is copied from the part header the client sent, so it is a claim, not a fact — row two above was a text file labeled image/png. Read the first bytes instead; with memoryStorage that is a one-liner, and the runs below show the fake and the real PNG.
const magic = req.file.buffer.subarray(0, 8).toString('hex');
if (magic !== '89504e470d0a1a0a') return res.status(415).json({ error: 'not a PNG' });notes.txt sent as image/png -> {"bytes":24,"magic":"706c61696e207465","isPng":false}
cover.png sent as image/png -> {"bytes":70,"magic":"89504e470d0a1a0a","isPng":true}For more formats use file-type 4,325 (https://github.com/sindresorhus/file-type 4,325 ), which matches several hundred signatures from a buffer or a stream.
Store uploads outside every static root: a directory express.static publishes will serve an uploaded .html or .svg from your own origin, which is stored XSS. Keep the bytes where the web server never maps them, record the generated name in the database, and serve them from a route that looks the record up, checks permission, and ends with res.type(rec.mimetype).sendFile(path.join(UPLOAD_DIR, rec.storedName)) (Redirects and Downloads). The URL then carries a record id, never a path.