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.

ApproachWhat happens with an unknown typeVerdict
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.

CheckFileExpected result
Zero-byte guard0b.binRejected with a clear message, not stored
Size limitsize ladder, up to 250 MBRefused above your cap, before the body is buffered
Filename sanitisationCON.txtStored under a generated name; never written as CON
Double extensionreport.pdf.exeRejected by the allow-list, on the real final extension
Declared type is a liefake-jpeg-is-executable.jpgRejected at the signature check
Signature without an extensionno-extensionCorrectly identified as PNG from its bytes
Header fine, body brokencorrupt-missing-content-types.docxRejected at parse, not accepted then failed later
Decompression boundmodest-zip-bomb.zipAborted at your output-size limit
Entity expansionbillion-laughs.xmlRejected; entity expansion disabled
External entitiesxxe-file-disclosure.xmlParsed with no file read attempted
Archive path traversalzip-slip-traversal.zipEntry refused; nothing written outside the target
Absolute archive pathsabsolute-path.zipEntry refused
Active content in an imagesvg-with-script.svgSanitised, rasterised, or served off-origin
Two formats at oncepolyglot-gif-html.gifServed with nosniff so it can only ever be an image
Malware scanning works at alleicar.com.txtFlagged by your scanner, exactly as real malware would be
Unicode filename round trip🦊-emoji-name.txtStored 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 →