Validating file uploads properly
Extension checks are trivially bypassed, Content-Type is attacker-controlled, and magic bytes alone fall to polyglots. What actually works is parsing, isolating and renaming.
File upload is one of the few features that hands an attacker a channel to put bytes of their choosing onto your infrastructure. It deserves more care than it usually gets, and most of the care it does get is aimed at the wrong layer.
The three checks that do not work alone
The extension. Attacker-controlled, and the filename is not the file. Beyond the obvious, extension handling has a long tail of parser differences: double extensions, trailing dots and spaces, null bytes, and case sensitivity that differs between your validator and your web server.
The Content-Type header. Set by the client. It is a hint about intent and nothing more.
Magic bytes alone. Better, and still insufficient, because a polyglot file can carry a valid image signature at the front while remaining a valid archive or script overall. Both parsers are satisfied; only one of them is the one you were thinking about. The mechanics of signatures and where they fail are covered in magic bytes, and why a file extension is not a file type.
None of these are useless. They are cheap filters that belong early. They are simply not a boundary.
What actually holds
Parse the file with a real parser. If it claims to be a PNG, decode it. If it claims to be a spreadsheet, open it with the library that will eventually open it anyway. A file that survives decoding is a file you understand.
Re-encode where you can. For images this is the strongest single control. Decode to a pixel buffer and write a fresh file from it. Whatever was hiding in the metadata, in appended data after the image, or in a second format sharing the container, is gone. It also normalises the output, which is convenient.
Rename server-side. Discard the supplied filename entirely and generate your own identifier. This kills path traversal, extension confusion and every trick that depends on you echoing the name back. Keep the original as a display label in the database if users need it, and never as a path component.
Store outside the execution path. Blob storage, or at minimum a directory
the web server will not execute and ideally cannot serve directly. If your
pipeline can do the validation client-side before anything is transmitted, the
argument for that is in why a file tool should never upload your
file; it is not a substitute for server
checks, but it removes a class of exposure entirely. The classic
catastrophic upload bug is not a clever parser exploit, it is a .php file
landing somewhere the interpreter can reach.
Bound the decompression, not just the upload. A zip bomb is small on the wire and enormous in memory. Cap the number of entries, the total uncompressed size and the compression ratio, and abort when any limit is exceeded. The same reasoning applies to image dimensions: a modestly sized file can declare a 50,000 by 50,000 canvas and exhaust memory the moment you decode it.
Set the response headers. Serve uploads with an explicit Content-Type,
X-Content-Type-Options: nosniff, and Content-Disposition: attachment where
inline display is not required. Sniffing is what turns a text file into an HTML
page executing in your origin.
Testing it
Every control above has a fixture that tests it, and most take seconds to build.
| Fixture | What it should catch |
|---|---|
| Valid extension, wrong signature | Code trusting the filename |
| Valid signature, corrupted body | Parser error handling |
| Correct type, zero bytes | The empty-file path nobody writes |
| Deeply nested archive | Decompression limits |
| Enormous declared dimensions | Decoder memory limits |
| Filename with traversal characters | Path handling |
The second row is the one people skip, and it is the one that finds unhandled exceptions and unbounded allocations. A file with an intact header and a destroyed interior gets past your cheap filters and into the parser, which is exactly where you want the test to land. You can generate malformed test uploads for any of the formats you accept, or start from a specific one such as the image corrupter if that is your main intake.
Once the fixtures stop finding anything, the next step is coverage-guided fuzzing against the same parser, described in from a corrupted file to a fuzzing harness.
The short version
Filter cheaply, validate by parsing, re-encode when you can, rename always, store somewhere inert, and bound every resource the file can ask for. Then test each of those with a file built specifically to violate it.