Requests, files, and servers
Handle file uploads
Validate upload purpose, size, type, content, storage, permissions, and delivery without trusting filenames or browser-provided metadata.
An upload is untrusted bytes plus untrusted metadata. The filename, the extension, and the Content-Type all come from the client, and all three can lie. Treat everything about an upload as hostile until your server has checked it.
The classic attack is a file called avatar.png that contains HTML and a script. If the server trusts the name and serves the file inline from your origin, the “image” runs as a page on your domain, with your users’ cookies. An upload feature just became stored XSS.
Limit, inspect, rename, isolate
Start with a hard size limit at the parser, so one big upload can’t fill memory or disk:
const upload = multer({ dest: '/srv/uploads', limits: { fileSize: 2 * 1024 * 1024 } })
Send a 5 MB file and multer rejects it with a LIMIT_FILE_SIZE error before the body finishes reading. That’s the behavior you want.
Next, check the content, not the claimed type. Read the first bytes of the file and match them against a short allowlist:
import { fileTypeFromFile } from 'file-type'
const kind = await fileTypeFromFile(req.file.path)
if (!kind || !['image/png', 'image/jpeg'].includes(kind.mime)) {
return res.status(415).end() // not a real image: reject
}
Rename an HTML file to avatar.png and upload it. fileTypeFromFile returns undefined because the bytes don’t start with a PNG signature, and the route answers 415. A real PNG returns { ext: 'png', mime: 'image/png' } and passes.
Then throw the original filename away. Generate the storage name yourself, something like 3f9a2c7e.png, and keep the file outside any directory the web server executes or serves directly. Two users uploading photo.jpg no longer collide, and ../ in a name has nothing to traverse.
Serve it on your terms
Deliver files through a dedicated route that sets the type from your own record and forces a download instead of inline rendering:
res.set('Content-Type', 'image/png')
res.set('Content-Disposition', 'attachment; filename="download.png"')
res.set('X-Content-Type-Options', 'nosniff')
nosniff tells the browser to believe your Content-Type and not guess from the bytes. Even if something slipped through, it downloads as a file instead of running as a page. For images you show inline, serve them from a separate origin like uploads.flaviocopes.com, so a bad file has no cookies to steal.
Don’t chase perfection
Deep scanning every upload adds time and still misses new tricks. Start with a narrow accepted format, a strict size, real content checks, generated names, and safe download headers. That covers almost everything.
Try this on your own project: upload a valid image, an oversized one, an HTML file renamed to .png, a truncated JPEG, and two files with the same name. For each, write down the decision, the stored name, the detected type, and the response headers when you fetch it back.
Lesson completed