Check a file is really the format it claims
Know what an uploaded file really holds, before accepting it, storing it or serving it back.
No AI needed
FreeInstantYour data never leaves
The verdict
N0, because the question has an exact answer in the first bytes and what matters is where the answer comes from: the file name and the Content-Type header are written by the client, and the report treats them as a claim to be contradicted rather than as data. The code goes one line further than the libraries where it must: file-type opens a ZIP archive to tell a .docx from a .xlsx, puremagic does not, and twelve lines of zipfile bring the Python side in line with the JavaScript one. What this level cannot do is stated rather than guessed — text formats come back unrecognised, and an unrecognised file is never allowed.
Signature in the first bytes, and the archive opened for ZIP containers
No AI needed
Rule and classic algorithmN0FreeInstant
Python
"""
Decide what an uploaded file is from its bytes, not from what it says it is.
Rung N0. The first bytes of a file are its format: 89 50 4E 47 for a PNG,
%PDF- for a PDF, PK for anything built on a ZIP archive. Those signatures are
published tables, and reading them is a comparison — no model is involved,
and none could be, because the answer is exact.
Where the answer comes from is the whole point of this rung. The file name and
the Content-Type header travel with the upload, which means the client wrote
them: they are a claim, not a fact. This function takes them as a claim, reads
the bytes on its own, and reports whether the two agree.
`puremagic` carries the signature table here, `file-type` in JavaScript. One
difference between them is handled in this file: every ZIP-based format starts
with PK, `file-type` opens the archive to tell a .docx from a .xlsx, and
`puremagic` returns half a dozen candidates at the same score. The twelve lines
of `_inside_zip` read the archive the same way, so both languages answer the
same thing.
What no table can do is a format whose file is text. A CSV, a JSON file, an
SVG and a plain note have no binary header, so they are reported as
unrecognised rather than guessed — and an unrecognised file is never allowed.
One format, one name. `.jpeg` and `.jpg` are the same picture, `.tif` and
`.tiff` the same scan, and the two tables do not always pick the same spelling.
The detected type, the claimed extension and every entry of the caller's allow
list are compared under one canonical name, so a photo exported by a phone as
`photo.jpeg` is not reported as a mismatch.
"""
from __future__ import annotations
import io
import zipfile
import puremagic
# What the ZIP container really holds, read from the archive rather than
# guessed from the PK signature that all of them share.
ODF_MIMETYPES = {
"application/vnd.oasis.opendocument.text": "odt",
"application/vnd.oasis.opendocument.spreadsheet": "ods",
"application/vnd.oasis.opendocument.presentation": "odp",
}
OOXML_FOLDERS = {"word/": "docx", "xl/": "xlsx", "ppt/": "pptx"}
# Signatures live in the first bytes; reading more than this to find one buys
# nothing. It is not a bound on the whole read, and saying so matters on an
# upload path: a ZIP keeps its table of contents at the end of the file, so
# `_inside_zip` below holds the whole upload in memory. That is the price of
# answering `docx` rather than `zip`, and of answering it in both languages —
# `file-type` tells the two apart from the head alone, `puremagic` cannot. A
# caller that will not pay it bounds the upload before calling.
HEAD = 4096
# The two tables do not need the same number of bytes: `puremagic` calls a PNG
# on its eight-byte signature, `file-type` wants the sixteen that carry the
# first chunk header too. Below the larger of the two, this snippet answers
# nothing rather than answering differently in each language.
MIN_BYTES = 16
# The same format under two names, in the tables and in the wild.
ALIASES = {"jfif": "jpg", "jpeg": "jpg", "tif": "tiff", "htm": "html"}
# What a web upload actually carries. A signature table holds a thousand
# formats, and on binary noise it finds one: a hundred null bytes come back
# from `puremagic` as a Compucon-Singer embroidery design, at the same
# confidence as a real PNG. Answering only for this list turns that into a
# refusal instead of a wrong answer — and text formats are not on it, because
# they have no binary signature to read whatever a table claims.
KNOWN = {
"png", "jpg", "gif", "webp", "avif", "heic", "tiff", "bmp", "ico",
"pdf", "rtf", "zip", "docx", "xlsx", "pptx", "odt", "ods", "odp",
"mp3", "mp4", "wav", "ogg", "webm", "gz", "7z", "rar", "exe",
}
def _canonical(extension: str) -> str:
"""The one name this snippet uses for a format, however it was written."""
name = extension.strip().lstrip(".").lower()
return ALIASES.get(name, name)
def _inside_zip(data: bytes) -> str:
"""Which ZIP-based format this archive is, or 'zip' when it is just one."""
try:
archive = zipfile.ZipFile(io.BytesIO(data))
names = archive.namelist()
if "mimetype" in names:
return ODF_MIMETYPES.get(archive.read("mimetype").decode("ascii", "replace"), "zip")
if "[Content_Types].xml" in names:
for folder, extension in OOXML_FOLDERS.items():
if any(name.startswith(folder) for name in names):
return extension
except (zipfile.BadZipFile, KeyError, OSError):
return "zip"
return "zip"
def sniff_file(data, *, claimed_name: str | None = None, allowed=()) -> dict:
"""
Say what the bytes are, and whether that matches what was claimed.
`claimed_name` is the file name the client sent. It is never used to
decide, only to be contradicted: `matches_claim` is the comparison, and a
caller that logs a False there is looking at either a mistake or an attack.
`allowed` is the caller's own list of extensions. `allowed` in the report
is true only when something was recognised and it is on that list: an
unrecognised file is never allowed by default.
"""
if not isinstance(data, (bytes, bytearray, memoryview)):
return _report([], claimed_name, allowed, f"a file is bytes, not {type(data).__name__}")
head = bytes(data[:HEAD])
if not head:
return _report([], claimed_name, allowed, "the file is empty")
if len(head) < MIN_BYTES:
return _report([], claimed_name, allowed,
f"under {MIN_BYTES} bytes: too short to carry a signature")
try:
matches = puremagic.magic_string(head)
# puremagic raises PureError (a LookupError) when nothing matches, and
# PureValueError (a ValueError) on empty input. An unrecognised file is an
# answer here, not a crash.
except (LookupError, ValueError):
matches = []
candidates = [_canonical(m.extension) for m in matches]
detected = [c for c in candidates if c in KNOWN][:1]
if detected and detected[0] in {"zip", "docx", "xlsx", "pptx", "odt", "ods", "odp"}:
detected = [_inside_zip(bytes(data))]
return _report(detected, claimed_name, allowed,
None if detected else "no signature read from the bytes")
def _report(detected: list, claimed_name: str | None, allowed, reason: str | None) -> dict:
claimed = None
if claimed_name and "." in claimed_name:
claimed = claimed_name.rsplit(".", 1)[1].lower()
# `claimed` is reported as the client spelled it; the comparisons are made
# on canonical names, on all three sides. Comparing a canonical `jpg` to a
# raw `jpeg` raised a mismatch on half the photos on the web, and refused
# them when the allow list was spelled `jpeg` too.
permitted = {_canonical(extension) for extension in allowed}
return {
"detected": detected[0] if detected else None,
"claimed": claimed,
# None when there is nothing to compare: no claim, or nothing read.
"matches_claim": None if (claimed is None or not detected) else _canonical(claimed) in detected,
"allowed": bool(detected) and detected[0] in permitted,
"reason": reason,
}JavaScript
/**
* Decide what an uploaded file is from its bytes, not from what it says it is.
*
* Rung N0. The first bytes of a file are its format: 89 50 4E 47 for a PNG,
* %PDF- for a PDF, PK for anything built on a ZIP archive. Those signatures
* are published tables, and reading them is a comparison — no model is
* involved, and none could be, because the answer is exact.
*
* Where the answer comes from is the whole point of this rung. The file name
* and the Content-Type header travel with the upload, which means the client
* wrote them: they are a claim, not a fact. This function takes them as a
* claim, reads the bytes on its own, and reports whether the two agree.
*
* `file-type` carries the signature table here, `puremagic` in Python. One
* difference between them is handled in the Python file: every ZIP-based
* format starts with PK, `file-type` opens the archive to tell a .docx from a
* .xlsx, and `puremagic` returns half a dozen candidates at the same score.
* Twelve lines of `zipfile` there read the archive the same way, so both
* languages answer the same thing.
*
* What no table can do is a format whose file is text. A CSV, a JSON file, an
* SVG and a plain note have no binary header, so they are reported as
* unrecognised rather than guessed — and an unrecognised file is never
* allowed.
*
* One format, one name. `.jpeg` and `.jpg` are the same picture, `.tif` and
* `.tiff` the same scan, and the two tables do not always pick the same
* spelling. The detected type, the claimed extension and every entry of the
* caller's allow list are compared under one canonical name, so a photo
* exported by a phone as `photo.jpeg` is not reported as a mismatch.
*/
import { fileTypeFromBuffer } from 'file-type';
// Signatures live in the first bytes; reading more than this buys nothing.
// Here it really is the whole read — `file-type` tells a .docx from a .xlsx
// from the head alone. `puremagic` cannot, so the Python side opens the
// archive and holds the whole upload in memory to answer the same thing.
export const HEAD = 4096;
// The two tables do not need the same number of bytes: `puremagic` calls a PNG
// on its eight-byte signature, `file-type` wants the sixteen that carry the
// first chunk header too. Below the larger of the two, this snippet answers
// nothing rather than answering differently in each language.
export const MIN_BYTES = 16;
// The same format under two names, in the tables and in the wild.
const ALIASES = { jfif: 'jpg', jpeg: 'jpg', tif: 'tiff', htm: 'html' };
/** The one name this snippet uses for a format, however it was written. */
const canonical = (extension) => {
const name = String(extension).trim().replace(/^\.+/, '').toLowerCase();
return ALIASES[name] ?? name;
};
// What a web upload actually carries. A signature table holds a thousand
// formats, and on binary noise it finds one: a hundred null bytes come back
// from `puremagic` as a Compucon-Singer embroidery design, at the same
// confidence as a real PNG. Answering only for this list turns that into a
// refusal instead of a wrong answer — and text formats are not on it, because
// they have no binary signature to read whatever a table claims.
const KNOWN = new Set([
'png', 'jpg', 'gif', 'webp', 'avif', 'heic', 'tiff', 'bmp', 'ico',
'pdf', 'rtf', 'zip', 'docx', 'xlsx', 'pptx', 'odt', 'ods', 'odp',
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'gz', '7z', 'rar', 'exe',
]);
/**
* Say what the bytes are, and whether that matches what was claimed.
*
* `claimedName` is the file name the client sent. It is never used to decide,
* only to be contradicted: `matches_claim` is the comparison, and a caller
* that logs a false there is looking at either a mistake or an attack.
*
* `allowed` is the caller's own list of extensions. `allowed` in the report is
* true only when something was recognised and it is on that list: an
* unrecognised file is never allowed by default.
*
* @param {Uint8Array} data
* @param {{claimedName?: string, allowed?: Iterable<string>}} [options]
*/
export async function sniffFile(data, { claimedName, allowed = [] } = {}) {
if (!ArrayBuffer.isView(data) && !(data instanceof ArrayBuffer)) {
return report(null, claimedName, allowed, `a file is bytes, not ${kindOf(data)}`);
}
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer ?? data, data.byteOffset ?? 0, data.byteLength);
if (bytes.length === 0) return report(null, claimedName, allowed, 'the file is empty');
if (bytes.length < MIN_BYTES) {
return report(null, claimedName, allowed,
`under ${MIN_BYTES} bytes: too short to carry a signature`);
}
const match = await fileTypeFromBuffer(bytes.subarray(0, HEAD));
const extension = match ? canonical(match.ext) : null;
if (extension === null || !KNOWN.has(extension)) {
return report(null, claimedName, allowed, 'no signature read from the bytes');
}
return report(extension, claimedName, allowed, null);
}
const kindOf = (value) => (value === null ? 'null' : typeof value);
function report(detected, claimedName, allowed, reason) {
const claimed = claimedName && claimedName.includes('.')
? claimedName.slice(claimedName.lastIndexOf('.') + 1).toLowerCase()
: null;
// `claimed` is reported as the client spelled it; the comparisons are made
// on canonical names, on all three sides. Comparing a canonical `jpg` to a
// raw `jpeg` raised a mismatch on half the photos on the web, and refused
// them when the allow list was spelled `jpeg` too.
const permitted = new Set([...allowed].map(canonical));
return {
detected,
claimed,
// null when there is nothing to compare: no claim, or nothing read.
matches_claim: claimed === null || detected === null ? null : canonical(claimed) === detected,
allowed: detected !== null && permitted.has(detected),
reason,
};
}Try it
This code cannot run in a browser. The outputs below are computed when the site is built, by running the real code.
Each case is an upload: a file name the client claims, and some bytes. The outputs are the snippet’s own, run when the site is built.
A real image, claimed as one
Given
photo-de-profil.png — 48 bytes starting with 89 50 4E 47
What it returns
{ "detected": "png", "claimed": "png", "matches_claim": true, "allowed": true, "reason": null }
What it concludes
Accepted — png
The type read is on the allow list (png, jpg, pdf).
An archive renamed as an image
Given
photo-de-profil.png — a ZIP archive, starting with 50 4B
What it returns
{ "detected": "zip", "claimed": "png", "matches_claim": false, "allowed": false, "reason": null }
What it concludes
Refused — the bytes say zip, the name says png
The type read, zip, is not on the allow list (png, jpg, pdf).
A Word document, recognised by opening the archive
Given
contrat.docx — a ZIP archive holding word/document.xml
What it returns
{ "detected": "docx", "claimed": "docx", "matches_claim": true, "allowed": false, "reason": null }
What it concludes
Refused — docx is not on the allow list
The type read, docx, is not on the allow list (png, jpg, pdf).
A valid image with an archive stuck behind it
Given
photo-de-profil.png — a PNG followed by a ZIP archive
What it returns
{ "detected": "png", "claimed": "png", "matches_claim": true, "allowed": true, "reason": null }
What it concludes
Accepted — png
The type read is on the allow list (png, jpg, pdf).
Here it breaksThe file is accepted: its first bytes really are a PNG’s, and that is the rule — the signature comes first. The rest of the file is not read, so a polyglot passes as its leading format; what to do with the bytes that follow is for your downstream processing to decide, not for this read.
A spreadsheet export as CSV
Given
clients.csv — text, with no binary header at all
What it returns
{ "detected": null, "claimed": "csv", "matches_claim": null, "allowed": false, "reason": "no signature read from the bytes" }
What it concludes
Refused — no signature read from the bytes
Refused — no signature read from the bytes
Here it breaksThere is no signature to read: a CSV is text, and the table only knows binary headers. The file comes back unrecognised, so not allowed — which is the right default, and also means this check has nothing to say about text formats, CSV, JSON, SVG and HTML included.
What this means for you
- Your data stays put
- Always the same result
- Unit-testable, case by case
- Depends on a library
- Negligible footprint
What this adds to your regulatory scope
- No specific scope added: the file is read inside your own process and is sent to nobody
- Does not replace malware scanning: recognising a format is not examining what it holds
Breaking point
A text file has no header to read: a CSV, a JSON file, an SVG and a plain note come back unrecognised, so not allowed, even when they are exactly what they claim. The witness is in the same test: a PNG, a JPEG, a PDF and a DOCX all return the right type.
When to move up a level
There is no level above this one for that question. The day what you want to know stops being “which format” and becomes “what is inside it” — a macro, a script in an SVG, a payload —, that is malware scanning or a sandbox, not reading a signature.
The four options
- RecommendedN0Signature in the first bytes, and the archive opened for ZIP containersFree · Instant
- N1Not worth it here
- N2Not worth it here
- N3Not worth it here
The other options
N1Not worth it here
Not worth it here
Lightweight classic modelN1
There is a classic technique for this — classifying a file fragment from byte statistics, used in forensics when no header is left — and it is exactly what an upload check must not do: it returns a probable type where the question calls for a verifiable answer. On a file that carries its signature it adds nothing; on a file that does not, it would replace an explicit refusal with a guess.
N2Not worth it here
Not worth it here
Small self-hosted specialised modelN2
The same argument, with one extra consequence: a self-hosted specialised model would have to read the whole file to beat a sixteen-byte read, which means running every unvetted upload through a component far larger than the one being protected. A security check gains from being short and from failing predictably.
N3Not worth it here
Not worth it here
General-purpose LLM APIN3
This level disqualifies itself by what it asks you to do with the file: send it to a third party. A security check whose first action is to forward the unvetted file it was handed no longer protects much, and it adds a cost per upload and latency to a path where the answer is expected immediately — to approximate a signature table that both libraries named here read offline.
Further reading
- puremagic — la table de signatures en Python pur, dépôt du projet
- file-type — la table de signatures en JavaScript, dépôt du projet
- OWASP — recommandations sur le téléversement de fichiers
- ECMA-376 — le paquet Open Packaging Conventions, qui fait des .docx des archives ZIP
Where the figures come from
- puremagic sur PyPI (consulté) : version 2.2.0, publiée le 8 avril 2026, licence MIT, aucune dépendance système
- file-type sur son dépôt (consulté) : version 22.1.1, publiée le 17 septembre 2026, licence MIT ; il ouvre les archives ZIP pour distinguer les formats bâtis dessus
- OWASP, File Upload Cheat Sheet (consultée) : l’extension et le Content-Type sont fournis par le client et ne valent pas contrôle
Metadata
General information, kept up to date as best we can. It is not legal advice. Have a professional review your situation.