Handling Uploads

Validating, Storing and Serving Uploaded Files

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.

Limits, an allowlist filter, and an error handlerJavaScript
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:

Output of 33
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.

Verifying the PNG signature instead of trusting the headerJavaScript
const magic = req.file.buffer.subarray(0, 8).toString('hex');
if (magic !== '89504e470d0a1a0a') return res.status(415).json({ error: 'not a PNG' });
Output
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.