Validating Uploads by File Extension Is Not Validation
The allowlist checked the extension, and the extension is the one field the attacker writes. A renamed payload sails through, and if the upload is served…
The upload endpoint had validation, and the validation was an allowlist, and the allowlist was of extensions. jpg, png, pdf, docx. It read like security and it was theatre, because every field it checked is written by the client, and the client is the attacker. The file's name is the least true thing about the file.
This is the autopsy of extension based validation, why it fails, what actually works, and the second half of the defect that most writeups skip: even a perfectly validated upload is dangerous if it is served back from your own origin.
This was a Node 22.14 upload service behind nginx 1.27, storing to object storage, and the findings are the standard ones, which is exactly why they belong in one place.
Why the extension tells you nothing
An extension is a convention for humans and operating systems, not a property of the bytes. Renaming a file changes nothing about its content. A payload named invoice.pdf that is actually an executable, or an HTML document with script, passes any extension allowlist, because the allowlist is checking the label the attacker chose.
The MIME type the client sends is equally attacker controlled, and checking it adds nothing. Both the name and the declared type are inputs, and validating inputs against values the attacker supplies is the definition of the defect in SQL injection inside the ORM, relocated to the filesystem.
What actually identifies a file
The honest check is the content, and the cheapest content check is the magic bytes, the fixed signatures at the start of most formats. A PNG begins with a specific eight byte sequence, a PDF with the percent PDF marker, a GIF with its name in ascii. Reading the first bytes and comparing to the signatures of the formats you accept rejects the renamed payload, because the bytes do not lie the way the name does.
Magic bytes are necessary but not sufficient for the dangerous formats, because some formats are permissive. An HTML file can begin with almost anything before its script, and archives and documents can embed executables. So the second layer is to parse the file with a real parser for the claimed type and reject what the parser refuses, which for images means actually decoding the image, and for documents means a parsing library that fails on malformed input.
The third layer, for the genuinely paranoid and the genuinely targeted, is to render or transcode the file through a safe pipeline and keep only the sanitized output, which is what the serious attachment processors do, and it is the only layer that defuses a polyglot that is valid as two things at once.
The size and dimension checks
Validation is also a resource boundary, and the extension mindset misses this half too. A decompression bomb is a small upload that expands to gigabytes, defeating any size check on the uploaded bytes. Checking the decoded dimensions and the decompressed size against limits, after parsing, closes it. The upload's byte size is the label. The decoded size is the truth, and the truth is what you must bound.
The serving half, where the rename becomes execution
Most upload writeups end at validation, and most real exploits live in the serving. If the uploaded file is served back from your own origin, then an HTML or script payload you stored is now a page on your domain, with your cookies' reach and your users' trust. The attacker uploads, gets the URL, and sends the URL to a victim, and the payload runs same origin with you.
The fix is structural. Serve uploads from a different origin, a separate domain or at least a host with no session cookies and no access to your application's privileges, so a stored payload is a foreign page, not yours. Set the content type from your own mapping of the validated type, never from the upload's declared type, and add the header that stops the browser from sniffing a text file into a script, because sniffing is the browser's own extension based validation, and it is as fooled as yours was.
For images specifically, serving the transcoded output rather than the original bytes removes whole classes of parser exploits in image decoders, because your users' browsers parse your sanitized output, not the attacker's original.
The checklist that replaces the extension allowlist
Accept the upload to quarantine, not to the served store. Check magic bytes against the allowlist of formats. Parse with a real parser and reject what it rejects. Bound the decoded size and dimensions. Transcode or sanitize where the format allows. Store under a name you generate, never the client's name, so the stored object's label is also yours. Serve from a separate origin with your own content type and no sniffing. Log every rejection, because the rejection stream is your probe detection, the same discipline as the refusal log in the webhook URL field that could reach your metadata endpoint.
Each layer is cheap. The stack is the policy, and no single layer is the policy, because the attacker only needs one layer to be a label. And when the audit asks what the upload path trusts, the answer should be a list of checked properties of the bytes, never a property of the name, because the name is the one field in the whole pipeline that was written by the person attacking you.
What I now do
A file's name and declared type are attacker written inputs, and validating them is validating the attacker's claim about the attack. Check the bytes, parse with a real parser, bound the decoded truth, store under your own name, and serve from an origin that cannot borrow your trust.
The extension allowlist is the file upload's version of the per IP rate limit in the rate limit that counted IPs and not accounts: a check on the dimension the attacker controls most cheaply, reported as a defence.