The Importance of Mime-Type Validations in Security Testing
Unrestricted file upload is a long-standing entry in the OWASP list of web application risks. The failure is usually simple: the server decides what a file is based on information the client controls. This post explains the three signals you get about an uploaded file, which ones can be trusted, how to check the content on the server, and how to test that the checks work.
Three different claims about the same file
- The file name and extension. Typed or chosen by the user. Completely under their control.
- The
Content-Typeof the multipart part. Set by the browser, which usually derives it from the extension using the operating system's type mapping. It is not based on the file's contents, and it can be anything when the request is made by a script. - The magic bytes. The first few bytes of the content, which most binary formats define as a fixed signature. This is the only one of the three that describes what the file actually is, and even it only covers the start of the file.
How easy it is to fake the first two: one curl command sets any file name and any type.
curl -F "file=@payload.html;type=image/jpeg;filename=cat.jpg" \
https://staging.example.test/api/upload
The server receives a part named cat.jpg with Content-Type: image/jpeg, containing HTML. A validator that only looks at the name and header accepts it.
Common signatures
| Format | Offset | Bytes (hex) |
|---|---|---|
| JPEG | 0 | FF D8 FF |
| PNG | 0 | 89 50 4E 47 0D 0A 1A 0A |
| GIF | 0 | 47 49 46 38 (GIF8) |
| WebP | 0 and 8 | 52 49 46 46 (RIFF) ... 57 45 42 50 (WEBP) |
| 0 | 25 50 44 46 2D (%PDF-) | |
| ZIP, DOCX, XLSX | 0 | 50 4B 03 04 (PK..) |
| MP4 | 4 | 66 74 79 70 (ftyp) |
| WebM | 0 | 1A 45 DF A3 |
Note that DOCX shares the ZIP signature, because a DOCX is a ZIP archive. Telling them apart means looking inside the archive for [Content_Types].xml and word/document.xml. A good detection library does this for you. You can inspect any local file with the magic-byte sniffer, and check what an extension normally maps to with MIME lookup.
Checking content on the server
In Node.js, the file-type package detects the type from the bytes:
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['application/pdf', 'pdf'],
]);
export async function detect(buffer) {
const t = await fileTypeFromBuffer(buffer); // { ext, mime } or undefined
if (!t || !ALLOWED.has(t.mime)) return null;
return { mime: t.mime, ext: ALLOWED.get(t.mime) };
}
In Python, python-magic wraps libmagic (the library behind the file command):
import magic
ALLOWED = {"image/jpeg": "jpg", "image/png": "png", "application/pdf": "pdf"}
def detect(head: bytes):
mime = magic.from_buffer(head, mime=True) # pass the first 2-8 KB
return (mime, ALLOWED[mime]) if mime in ALLOWED else None
Three rules make these checks effective:
- Allowlist, never denylist. Accept the handful of types you need. Trying to block "dangerous" types always misses one.
- Store the detected type, not the claimed one. Use the detected MIME type when serving the file later, and generate the stored file name yourself (a random ID plus the extension from the allowlist). Never reuse the user's extension on disk.
- Check before any processing. Thumbnailers, PDF parsers and archive extractors are where malformed input does damage. The type gate must run before the file reaches them.
Formats with no signature
Text formats (CSV, JSON, TXT, XML, YAML, SVG, HTML) have no magic bytes. Detection libraries either return nothing or guess text/plain. For these:
- Parse them. The only reliable check that a file is JSON is that a JSON parser accepts it. The same goes for CSV with a known header, or XML against a schema.
- Treat SVG as active content. SVG is XML that can contain
<script>and event handlers. Either sanitise it, convert it to a raster image, or serve it only as a download. - Watch for HTML. A text upload that contains HTML is harmless if it is served as
text/plainwithnosniff, and dangerous if it is served astext/htmlfrom your main domain.
Serving uploads safely
Validation on the way in is half the job. The other half is how files are served:
Content-Typeset from the detected type.X-Content-Type-Options: nosniff, so the browser does not second-guess that type.Content-Disposition: attachmentfor anything that is not meant to be displayed inline.- A separate domain for user content, so that anything that does execute cannot read your application's cookies.
A test matrix
Each row is a file you can make with the generator and a rename. Fill in the expected column before running anything.
| Upload | Name | Header sent | Expected |
|---|---|---|---|
| Valid PNG | a.png | image/png | Accepted |
| Valid PNG | a.jpg | image/jpeg | Accepted as PNG, or rejected (your policy) |
| Text file | a.png | image/png | Rejected |
| Valid PDF | a.png | image/png | Rejected for an image field |
| Valid ZIP | a.docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document | Rejected (not a real DOCX) |
| HTML file | a.txt | text/plain | Accepted only if served as text/plain with nosniff |
| 0-byte file | a.pdf | application/pdf | Rejected |
Send the header column with curl rather than a browser, since the browser will derive it from the name. Then, for accepted files, fetch them back and check the Content-Type and X-Content-Type-Options headers of the response:
curl -sI https://staging.example.test/files/ID | grep -iE 'content-type|nosniff|disposition'
The extension versus sniffed MIME scenario walks through this matrix in more detail, and our worked example on hardening upload validation shows how the layers fit together. Only run mismatch tests against systems you own or are authorised to test.
