Skip to main content
Back to Blog

The Importance of Mime-Type Validations in Security Testing

Written by Ankur Prajapati

Published 2025-12-30

LinkedIn profile
Advertisement

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

  1. The file name and extension. Typed or chosen by the user. Completely under their control.
  2. The Content-Type of 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.
  3. 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

FormatOffsetBytes (hex)
JPEG0FF D8 FF
PNG089 50 4E 47 0D 0A 1A 0A
GIF047 49 46 38 (GIF8)
WebP0 and 852 49 46 46 (RIFF) ... 57 45 42 50 (WEBP)
PDF025 50 44 46 2D (%PDF-)
ZIP, DOCX, XLSX050 4B 03 04 (PK..)
MP4466 74 79 70 (ftyp)
WebM01A 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/plain with nosniff, and dangerous if it is served as text/html from your main domain.

Serving uploads safely

Validation on the way in is half the job. The other half is how files are served:

  • Content-Type set from the detected type.
  • X-Content-Type-Options: nosniff, so the browser does not second-guess that type.
  • Content-Disposition: attachment for 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.

UploadNameHeader sentExpected
Valid PNGa.pngimage/pngAccepted
Valid PNGa.jpgimage/jpegAccepted as PNG, or rejected (your policy)
Text filea.pngimage/pngRejected
Valid PDFa.pngimage/pngRejected for an image field
Valid ZIPa.docxapplication/vnd.openxmlformats-officedocument.wordprocessingml.documentRejected (not a real DOCX)
HTML filea.txttext/plainAccepted only if served as text/plain with nosniff
0-byte filea.pdfapplication/pdfRejected

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.

Advertisement