Four separate risks
They need different defences, which is why "we check the file type" is never the whole answer.
| Risk | What it looks like |
|---|---|
| Execution | a file that the server or a browser runs: .php, .html, .svg with a script in it |
| Overwrite | a name like ../../config.json landing outside the upload directory |
| Resource | a 4 GB file, ten thousand small ones, or a 10,000×10,000 PNG that fills memory when resized |
| Content | malware you now host, or material you are legally responsible for |
The name is not yours to keep
A generated name removes path traversal, collisions, encoding tricks, case-sensitivity surprises and the user's emoji, all at once. Store the original in the database, as data, and escape it when you display it.
const stored = crypto.randomUUID() + extensionFor(type); // what goes on disk
const label = file.name; // what you show them, escaped
// never
const stored = file.name; // "../../.env", "index.html"
Checking the type, properly
Read the first bytes: a PNG starts 89 50 4E 47, a JPEG FF D8 FF, a PDF %PDF. Check that against an allowlist of types you actually support, and then give the stored file the extension you chose for that type — not the one that arrived.
A blocklist of dangerous extensions loses to avatar.php.png, to .phtml, to a trailing dot, to a case change, and to the next thing your web server decides to execute.
file.name.endsWith(".png") // the user chose this
file.type === "image/png" // the browser sent this — also the user's to choose
magicBytes(buffer) === "image/png" // this is the file
An SVG is an XML document that can contain <script> and event handlers. Served from your origin, it is an XSS. Either rasterise it on upload, or serve it with Content-Disposition: attachment from a different origin — or simply do not accept it.
Serve them from somewhere else
The strongest control is the origin: put user files on uploads.doors.test or an object store, never on the origin that holds the session cookie. Then even a successful HTML upload runs in a context where there is nothing to steal.
Content-Type: image/png # what you determined, not what they said
X-Content-Type-Options: nosniff
Content-Disposition: attachment # for anything not an image you rendered
Content-Security-Policy: sandbox
Limits, before the bytes arrive
- A size limit enforced by the server and the proxy — not by the form.
client_max_body_sizein nginx,limitsin your framework. - A count and rate limit per user: ten files a minute, not ten thousand.
- Dimension limits before decoding an image. A 30,000×30,000 PNG is 200 KB compressed and 3.6 GB decoded — the "decompression bomb" that kills a resizing worker.
- A timeout on processing, and the processing itself out of the request cycle.
Presigned uploads
Letting the browser upload straight to object storage with a short-lived signed URL keeps large files off your servers entirely. The signature fixes the key, the content type and the maximum size, so the client cannot change them — you are still deciding, you are just not carrying the bytes.