Security

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.

Files Corrupter ·

Validating file uploads properly

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.

FixtureWhat it should catch
Valid extension, wrong signatureCode trusting the filename
Valid signature, corrupted bodyParser error handling
Correct type, zero bytesThe empty-file path nobody writes
Deeply nested archiveDecompression limits
Enormous declared dimensionsDecoder memory limits
Filename with traversal charactersPath 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.