Learning & reference
File upload validation: a checklist that actually works
Most upload validation checks the extension, glances at the Content-Type, and calls it done.
Both are strings the client chose. Here are the checks that hold up, in the order they have to run,
with a downloadable file that proves each one.
Why the order matters
These are not twelve independent checks. Several of them only work if an earlier one has already run, and one of them is dangerous if it runs too late.
Size is first, always
Every other check reads the file. If you have not bounded the size, the act of validating is itself the denial of service.
Cheap before expensive
Extension and declared type are worthless as proof but free to check. Use them to reject early, then spend real work only on what survives.
Parsing is the real check
Signatures tell you what a file claims. Only parsing it with the library that will later consume it tells you what it is.
The checklist
Each step lists what it catches and links to a fixture that exercises it. Every fixture is generated from source, has a published SHA-256, and is free to redistribute.
1. Cap the size before you read the body
Enforce a limit at the proxy or web server (client_max_body_size,
LimitRequestBody, MultipartConfig) so an oversized request is refused before
your code allocates anything. Check Content-Length too, but do not trust it: enforce the
cap on the stream as you read, because the header can lie or be absent on a chunked upload.
Catches: memory exhaustion, full disks, and the request that times out your worker pool. Test with: the size ladder from 0 bytes to 250 MB, or stream up to 2 GB on demand.
2. Reject zero bytes explicitly
An empty file passes an extension check, passes a MIME check, and fails every signature check in a way that is easy to mishandle. It is also what you get from an interrupted upload, so it will happen in production whether or not anyone is attacking you.
Catches: validators that check type but never size > 0, and pipelines that
store a placeholder then never notice. Test with:
the 0-byte file.
3. Treat the filename as hostile, then throw it away
Do not sanitise the user's filename and use it. Generate your own name (a UUID is ideal), store the
original as a display label only, and never let it reach the filesystem. If you must derive a name from
it: strip every path separator including backslash, reject .., normalise Unicode to NFC,
cap the length in bytes not characters, reject trailing dots and spaces, and reject the Windows
reserved names (CON, PRN, AUX, NUL,
COM1-COM9, LPT1-LPT9) with or without an extension.
Catches: path traversal, overwriting other users' files, filenames that cannot be created on Windows, and the right-to-left override trick that makes an executable look like an image. Test with: the filename edge cases, including CON.txt, trailing-dot.txt. and report.pdf.exe.
4. Ignore the Content-Type the client sent
On a multipart upload that header is filled in by the browser from the local file extension, or by whatever is posting the form. It is a hint, not evidence. Use it for a fast rejection if you like, but never let it be the reason a file is accepted.
Catches: the single most common upload bypass, which needs no tooling beyond an intercepting proxy. Read more: MIME types reference.
5. Check the magic number, and know where it stops helping
Read the leading bytes and match them against the format you expect. This is a real improvement on the extension, but it has three specific limits worth knowing before you rely on it:
• The ZIP family is indistinguishable at byte 0. DOCX, XLSX, PPTX, ODT, EPUB, JAR and APK
all begin 50 4B 03 04. Telling them apart means looking inside at
[Content_Types].xml or the mimetype entry.
• Not every signature is at the start. TAR's ustar magic sits 257 bytes in, so
a check that reads the first 16 bytes misses it entirely.
• A correct header does not mean a valid file. A truncated or corrupted file keeps its
header.
Catches: renamed executables and type confusion. Test with: fake-jpeg-is-executable.jpg, a PNG with no extension at all, and the file signature table.
6. Parse it, do not just sniff it
Open the file with the same library that will later process it, and do it now, synchronously, while you can still reject the upload. A file whose header is perfect and whose body is damaged will otherwise fail deep in a background job, days later, where the error has no user to report to.
Catches: corrupt files that pass every cheap check. Test with: a DOCX missing [Content_Types].xml (a valid ZIP that Word refuses to open), a PDF with a corrupt xref table, and a SQLite file with an intact header and a cut-short body.
7. Bound decompression, by output not input
If you unpack anything (archives, but also images, XML and JSON), limit the decompressed total and the entry count, and abort the moment either is exceeded. Stream through a counting wrapper rather than extracting to memory. Never trust the uncompressed sizes declared in the archive: they are attacker-controlled.
The same principle covers XML entity expansion and deeply nested JSON, which are decompression bombs by another name.
Catches: zip bombs, billion laughs, stack-overflow-by-nesting. Test with: a deliberately modest zip bomb, a 10-deep nested archive, billion-laughs.xml and 100,000-deep JSON.
8. Refuse archive entries that escape the target directory
Resolve every entry path against the destination and confirm the result is still inside it, after
following symlinks. Reject absolute paths, anything containing .., and entries that are
themselves symlinks or hard links. Do this per entry, not once for the archive.
Catches: Zip Slip, which writes ../../etc/cron.d/ straight out of an upload form.
Test with: zip-slip-traversal.zip and
absolute-path.zip.
9. Treat SVG, HTML and XML as executable content
SVG is XML that can carry <script>, event handlers and external references. Served
as image/svg+xml from your origin, it is stored XSS with an image icon. Pick one of:
sanitise through a strict allow-list parser, rasterise on upload, or serve from a separate domain.
Disable external entity resolution in every XML parser you own.
Catches: stored XSS and XXE file disclosure. Test with: svg-with-script.svg, svg-external-entity.svg and xxe-file-disclosure.xml.
10. Assume a file can be two formats at once
A polyglot satisfies two sets of rules simultaneously: a working GIF that is also a working HTML page. A validator that checks "is this a valid image" gets a yes, and a browser asked to render it as HTML also gets a yes. This is why step 11 matters more than any amount of validation.
Catches: content-type confusion that survives a correct signature check. Test with: polyglot-gif-html.gif.
11. Store it where it cannot execute
Outside the web root, or in object storage, under a name you generated. No user-supplied path segment anywhere. Strip the execute bit. If uploads must live under a served directory, turn off script handling for that directory at the server, and verify it, because this is exactly the misconfiguration that turns an upload form into remote code execution.
Catches: the web shell. Every other check on this page is defence in depth for this one.
12. Serve it back safely
Send X-Content-Type-Options: nosniff so the browser cannot second-guess the type you
declared. Send Content-Disposition: attachment for anything you do not intend to render
inline, and use the RFC 5987 filename*=UTF-8''... form for non-ASCII names. Serve
user content from a different origin than your application, so a mistake cannot reach your cookies.
Set a restrictive Content-Security-Policy on that origin.
Catches: content sniffing, reflected downloads, and cookie theft from a file you served. Read more: MIME types.
Allow-list, never deny-list
A deny-list is a list of the attacks you have thought of. An allow-list is a list of what your product needs, which is almost always short.
| Approach | What happens with an unknown type | Verdict |
|---|---|---|
Deny-list (.exe, .php, .sh…) |
Accepted. Including .phtml, .phar, .cgi, .jsp, a double extension, an uppercase variant, or a trailing dot or space that the filesystem quietly strips. |
Fails open |
| Allow-list of extensions | Rejected. Good, but says nothing about the contents. | Necessary, not sufficient |
| Allow-list of extension and verified signature and successful parse | Rejected, and anything that lied about itself is rejected too. | This is the bar |
Also allow-list on the way out. The accept attribute on an
<input type="file"> is a convenience for the file picker and nothing more. It is trivially
bypassed and is not validation.
Checks that look like validation but are not
Trusting Content-Length
A header, therefore client-controlled, and absent entirely on a chunked upload. Count bytes as you read them instead.
Checking the extension after the last dot
report.pdf.exe ends in .exe, but plenty of code splits on the
first dot, or displays only the first extension to the user.
Validating on the client
JavaScript checks improve the experience and provide no security whatsoever. The request can be made without your page.
Scanning for malware and stopping there
Antivirus finds known malware. It does not find a zip bomb, an SVG with a script, or a polyglot, and it cannot see inside an encrypted archive at all.
Re-encoding images and assuming it is safe
Re-encoding does strip most payloads, which is genuinely worth doing. But the decoder still parsed attacker-controlled bytes to get there, so the decoder itself must be bounded and current.
Checking only the first chunk
A signature check on the first 8 KB of a streamed upload says nothing about the remaining 499 MB.
The fixture map
One file per check, and what a correctly built pipeline should do with it.
| Check | File | Expected result |
|---|---|---|
| Zero-byte guard | 0b.bin | Rejected with a clear message, not stored |
| Size limit | size ladder, up to 250 MB | Refused above your cap, before the body is buffered |
| Filename sanitisation | CON.txt | Stored under a generated name; never written as CON |
| Double extension | report.pdf.exe | Rejected by the allow-list, on the real final extension |
| Declared type is a lie | fake-jpeg-is-executable.jpg | Rejected at the signature check |
| Signature without an extension | no-extension | Correctly identified as PNG from its bytes |
| Header fine, body broken | corrupt-missing-content-types.docx | Rejected at parse, not accepted then failed later |
| Decompression bound | modest-zip-bomb.zip | Aborted at your output-size limit |
| Entity expansion | billion-laughs.xml | Rejected; entity expansion disabled |
| External entities | xxe-file-disclosure.xml | Parsed with no file read attempted |
| Archive path traversal | zip-slip-traversal.zip | Entry refused; nothing written outside the target |
| Absolute archive paths | absolute-path.zip | Entry refused |
| Active content in an image | svg-with-script.svg | Sanitised, rasterised, or served off-origin |
| Two formats at once | polyglot-gif-html.gif | Served with nosniff so it can only ever be an image |
| Malware scanning works at all | eicar.com.txt | Flagged by your scanner, exactly as real malware would be |
| Unicode filename round trip | 🦊-emoji-name.txt | Stored and returned unchanged, NFC-normalised |
On the security fixtures: none of them is malware. EICAR is a harmless 68-byte string that scanners are required to detect so you can prove detection works, and the payload files are inert text. They are served from a separate hostname so that a scanner blocking them cannot take the rest of the site with it. Use them in a test environment, not on a production machine you care about.
Work through it
Security test files File signatures → Filename compatibility → Character encodings →