Accepting files from strangers
An upload endpoint accepts arbitrary bytes chosen by someone you don't know. Almost every mistake here comes from trusting what they say it is.
File upload is one of the few features where you invite an untrusted party to place arbitrary bytes on your infrastructure. Most of the vulnerabilities come from believing what they tell you about those bytes.
Never trust the filename
The filename is user input. It can contain path traversal sequences, null bytes, or a thousand characters of unicode.
Never use it to construct a storage path. Generate your own identifier and store the original name as metadata for display only — escaped when rendered, because a filename is a perfectly good XSS vector.
Never trust the extension or the content type
Both are supplied by the client. A file named .jpg with an image content type can
contain anything at all.
Detect the type from the content itself — magic bytes — and validate against an allowlist of types you actually support. An allowlist, not a blocklist: blocklists are an endless game against extensions you did not think of.
For images specifically, re-encoding is the strongest move available. Decode and re-encode through an image library, and anything embedded that was not image data does not survive the round trip.
Serve them from somewhere they cannot execute
A file uploaded into a directory your web server will execute is the classic path to remote code execution.
Store uploads outside the document root, or in object storage. Serve them through a controller that sets the content type explicitly, or via signed URLs from storage.
Two headers matter when serving. X-Content-Type-Options: nosniff stops browsers
second-guessing your declared type. And Content-Disposition: attachment for anything
that is not a type you deliberately render inline — an uploaded HTML file served inline
from your origin is stored XSS with your cookies attached.
Serving user content from a separate domain is the stronger version of this, because it removes same-origin access entirely.
Uploads should be served from an origin where a malicious file cannot reach anything worth having.
Limits, and where they must be enforced
Size limits belong at the web server or proxy, before the request reaches your application. An application-level check happens after the bytes have already arrived.
Beyond size, be wary of formats that expand: archives that decompress to enormous sizes, images with dimensions that consume gigabytes when decoded. Validate declared dimensions before decoding, and cap decoded size.
Direct-to-storage does not remove the problem
Signed uploads straight to object storage are efficient and skip your servers. They also skip your validation.
The pattern that works is to validate after the fact — a job that inspects the object, verifies the type, re-encodes if appropriate, and only then marks it usable. Until that completes, nothing should serve it.