File uploads

Accept a file from a stranger without letting it become code, a bill, or somebody else’s problem.

09of 16
≈ 12Min read
6Parts

Four separate risks

They need different defences, which is why "we check the file type" is never the whole answer.

RiskWhat it looks like
Executiona file that the server or a browser runs: .php, .html, .svg with a script in it
Overwritea name like ../../config.json landing outside the upload directory
Resourcea 4 GB file, ten thousand small ones, or a 10,000×10,000 PNG that fills memory when resized
Contentmalware 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.

generate one; keep theirs as a label
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.

three checks, and only two of them are worth anything
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
SVG is not an image, for this purpose

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.

the headers that make a hosted file harmless
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_size in nginx, limits in 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.

Practice