Vérifier qu’un fichier est bien du type annoncé
Savoir ce qu’un fichier téléversé contient vraiment, avant de l’accepter, de le stocker ou de le servir.
Pas besoin d’IA
GratuitInstantanéVos données ne sortent pas
Le verdict
N0, parce que la question a une réponse exacte dans les premiers octets et que l’enjeu est de savoir d’où vient la réponse : le nom du fichier et l’en-tête Content-Type sont écrits par le client, le rapport les traite comme une affirmation à contredire et pas comme une donnée. Le code va une ligne plus loin que les bibliothèques quand il le faut : `file-type` ouvre une archive ZIP pour distinguer un .docx d’un .xlsx, `puremagic` ne le fait pas, et douze lignes de `zipfile` alignent le Python sur le JavaScript. Ce que ce niveau ne sait pas faire est dit plutôt que deviné — les formats texte reviennent non reconnus, et un fichier non reconnu n’est jamais autorisé.
Signature des premiers octets, et l’archive ouverte pour les conteneurs ZIP
Pas besoin d’IA
Règle et algorithme classiqueN0GratuitInstantané
Python
"""
Décider ce qu'est un fichier téléversé d'après ses octets, et non d'après ce
qu'il prétend être.
Niveau N0. Les premiers octets d'un fichier sont son format : 89 50 4E 47
pour un PNG, %PDF- pour un PDF, PK pour tout ce qui est bâti sur une archive
ZIP. Ces signatures sont des tables publiées, et les lire est une
comparaison — aucun modèle n'intervient, et aucun ne le pourrait, parce que
la réponse est exacte.
D'où vient la réponse est tout l'intérêt de ce niveau. Le nom du fichier et
l'en-tête Content-Type voyagent avec le téléversement, autrement dit c'est le
client qui les a écrits : ce sont une affirmation, pas un fait. Cette
fonction les prend comme une affirmation, lit les octets de son côté, et dit
si les deux concordent.
`puremagic` porte la table des signatures ici, `file-type` en JavaScript. Une
différence entre les deux est traitée dans le fichier Python : tous les
formats bâtis sur ZIP commencent par PK, `file-type` ouvre l'archive pour
distinguer un .docx d'un .xlsx, et `puremagic` rend une demi-douzaine de
candidats au même score. Douze lignes de `zipfile` y lisent l'archive de la
même façon, pour que les deux langages répondent la même chose.
Ce qu'aucune table ne sait faire, c'est un format dont le fichier est du
texte. Un CSV, un JSON, un SVG et une note en clair n'ont aucun en-tête
binaire : ils reviennent non reconnus plutôt que devinés — et un fichier non
reconnu n'est jamais autorisé.
"""
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
/**
* Décider ce qu'est un fichier téléversé d'après ses octets, et non d'après ce
* qu'il prétend être.
*
* Niveau N0. Les premiers octets d'un fichier sont son format : 89 50 4E 47
* pour un PNG, %PDF- pour un PDF, PK pour tout ce qui est bâti sur une archive
* ZIP. Ces signatures sont des tables publiées, et les lire est une
* comparaison — aucun modèle n'intervient, et aucun ne le pourrait, parce que
* la réponse est exacte.
*
* D'où vient la réponse est tout l'intérêt de ce niveau. Le nom du fichier et
* l'en-tête Content-Type voyagent avec le téléversement, autrement dit c'est le
* client qui les a écrits : ce sont une affirmation, pas un fait. Cette
* fonction les prend comme une affirmation, lit les octets de son côté, et dit
* si les deux concordent.
*
* `file-type` porte la table des signatures ici, `puremagic` en Python. Une
* différence entre les deux est traitée dans le fichier Python : tous les
* formats bâtis sur ZIP commencent par PK, `file-type` ouvre l'archive pour
* distinguer un .docx d'un .xlsx, et `puremagic` rend une demi-douzaine de
* candidats au même score. Douze lignes de `zipfile` y lisent l'archive de la
* même façon, pour que les deux langages répondent la même chose.
*
* Ce qu'aucune table ne sait faire, c'est un format dont le fichier est du
* texte. Un CSV, un JSON, un SVG et une note en clair n'ont aucun en-tête
* binaire : ils reviennent non reconnus plutôt que devinés — et un fichier non
* reconnu n'est jamais autorisé.
*/
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,
};
}Essayer
Ce code ne peut pas tourner dans un navigateur. Les sorties ci-dessous sont calculées à la construction du site, en exécutant le vrai code.
Chaque cas est un téléversement : un nom de fichier annoncé par le client, et des octets. Les sorties sont celles de l’extrait, exécutées à la construction du site.
Une vraie image, annoncée comme telle
Donné
photo-de-profil.png — 48 octets commençant par 89 50 4E 47
Ce qu’il renvoie
{ "detected": "png", "claimed": "png", "matches_claim": true, "allowed": true, "reason": null }
Ce qu’il en conclut
Accepté — png
Le type lu est dans la liste autorisée (png, jpg, pdf).
Une archive renommée en image
Donné
photo-de-profil.png — une archive ZIP, commençant par 50 4B
Ce qu’il renvoie
{ "detected": "zip", "claimed": "png", "matches_claim": false, "allowed": false, "reason": null }
Ce qu’il en conclut
Refusé — les octets disent zip, le nom dit png
Le type lu, zip, n’est pas dans la liste autorisée (png, jpg, pdf).
Un document Word, reconnu en ouvrant l’archive
Donné
contrat.docx — une archive ZIP qui contient word/document.xml
Ce qu’il renvoie
{ "detected": "docx", "claimed": "docx", "matches_claim": true, "allowed": false, "reason": null }
Ce qu’il en conclut
Refusé — docx n’est pas dans la liste autorisée
Le type lu, docx, n’est pas dans la liste autorisée (png, jpg, pdf).
Une image valide qui porte une archive collée derrière
Donné
photo-de-profil.png — un PNG suivi d’une archive ZIP
Ce qu’il renvoie
{ "detected": "png", "claimed": "png", "matches_claim": true, "allowed": true, "reason": null }
Ce qu’il en conclut
Accepté — png
Le type lu est dans la liste autorisée (png, jpg, pdf).
Là, ça casseLe fichier est accepté : ses premiers octets sont bien ceux d’un PNG, et c’est la règle — la signature est en tête. Le reste du fichier n’est pas lu, et un polyglotte passe donc comme son format d’ouverture ; c’est à votre traitement d’aval, pas à cette lecture, de décider ce qu’il fait des octets qui suivent.
Un export de tableur en CSV
Donné
clients.csv — du texte, sans aucun en-tête binaire
Ce qu’il renvoie
{ "detected": null, "claimed": "csv", "matches_claim": null, "allowed": false, "reason": "no signature read from the bytes" }
Ce qu’il en conclut
Refusé — aucune signature lue dans les octets
Refusé — aucune signature lue dans les octets
Là, ça casseAucune signature à lire : un CSV est du texte, et la table ne connaît que des en-têtes binaires. Le fichier revient non reconnu, donc non autorisé — ce qui est le bon défaut, mais ce qui veut aussi dire que ce contrôle ne sait rien dire des formats texte, CSV, JSON, SVG et HTML compris.
Ce que ça implique
- Vos données ne sortent pas
- Résultat toujours identique
- Se teste unitairement, cas par cas
- Dépend d’une bibliothèque
- Empreinte négligeable
Ce que cela ajoute à votre périmètre réglementaire
- Aucun périmètre spécifique ajouté : le fichier est lu dans votre processus et n’est transmis à personne
- Ne dispense pas de l’analyse antivirale : reconnaître un format n’est pas examiner ce qu’il contient
Point de rupture
Un fichier texte n’a pas d’en-tête à lire : un CSV, un JSON, un SVG et une note en clair reviennent non reconnus, donc non autorisés, même quand ils sont ce qu’ils prétendent être. Témoin dans le même test : un PNG, un JPEG, un PDF et un DOCX rendent le bon type.
Quand passer au niveau suivant
Il n’y a pas de niveau au-dessus pour cette question-là. Le jour où ce que vous voulez savoir n’est plus « quel format » mais « que contient-il » — une macro, un script dans un SVG, une charge utile —, c’est une analyse antivirale ou un bac à sable, pas une lecture de signature.
Les quatre options
- RecommandéN0Signature des premiers octets, et l’archive ouverte pour les conteneurs ZIPGratuit · Instantané
- N1Niveau sans objet ici
- N2Niveau sans objet ici
- N3Niveau sans objet ici
Les autres options
N1Niveau sans objet ici
Niveau sans objet ici
Modèle classique légerN1
Il existe bien une technique classique pour cela — classer un fragment de fichier sur des statistiques d’octets, employée en analyse post-mortem quand il n’y a plus d’en-tête —, et elle est exactement ce qu’un contrôle de téléversement ne doit pas faire : elle rend un type probable là où la question appelle une réponse vérifiable. Sur un fichier qui porte sa signature, elle n’ajoute rien ; sur un fichier qui n’en porte pas, elle remplacerait un refus explicite par une supposition.
N2Niveau sans objet ici
Niveau sans objet ici
Petit modèle spécialisé auto-hébergéN2
Même argument, et une conséquence de plus : un modèle spécialisé auto-hébergé devrait lire le fichier entier pour valoir mieux qu’une lecture de seize octets, ce qui revient à faire passer chaque téléversement non contrôlé dans un composant beaucoup plus gros que celui qu’on protège. Un contrôle de sécurité gagne à être court et à échouer de façon prévisible.
N3Niveau sans objet ici
Niveau sans objet ici
API de LLM généralisteN3
Ce niveau se disqualifie par ce qu’il demande de faire du fichier : l’envoyer chez un tiers. Un contrôle de sécurité dont la première action est de transmettre à l’extérieur le fichier non vérifié qu’on lui confie ne protège plus grand-chose, et il ajoute un coût par téléversement et une latence à un chemin où l’on attend une réponse immédiate — pour approcher une table de signatures que les deux bibliothèques nommées ici lisent hors ligne.
Pour aller plus loin
- 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
Source des chiffres
- 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
Métadonnées
Information générale, tenue à jour au mieux. Ce n’est pas un avis juridique. Faites valider votre situation par un professionnel.