Valider les fichiers importés en Python au-delà de l’extension
Apprenez à valider le contenu réel des fichiers importés en Python : signatures binaires pour PDF et audio, inspection sûre des DOCX et pièges à éviter.
Long Nguyen
Développeur fullstack · Ingénieur IA · Chercheur
Une extension ne suffit pas à valider un fichier
Cette faille porte un nom : la vulnérabilité Unrestricted File Upload (répertoriée sous le nom CWE-434), régulièrement impliquée dans des compromissions bien réelles. Se fier à l’extension, ou à l’en-tête Content-Type envoyé par le client, est l’une des erreurs les plus fréquentes — dans les deux cas, il suffit de renommer le fichier ou de falsifier l’en-tête pour contourner le contrôle.
Si votre backend accepte des fichiers importés et fait confiance à leur extension pour déterminer leur type, il ne valide en réalité rien du tout. L’extension n’est que la fin du nom du fichier, et n’importe qui peut renommer n’importe quoi : un fichier malveillant appelé resume.pdf passera sans problème un contrôle d’extension. Pour valider correctement les fichiers importés en Python, vous devez vérifier ce qu’ils contiennent réellement, et non le nom qu’ils prétendent porter.
Ce guide présente un petit module de validation sans dépendance externe : vérification des PDF et des fichiers audio à partir de leur signature binaire, et validation des DOCX par inspection sûre de leur structure d’archive. Tout repose uniquement sur la bibliothèque standard de Python.
Lire l’en-tête sans perturber le traitement
Chaque vérification commence par la lecture des premiers octets du fichier — mais un validateur ne doit pas consommer le fichier, sans quoi le code chargé de l’enregistrer ensuite se retrouverait face à un flux vide. L’utilitaire lit donc un bloc, puis replace le pointeur au début avec seek(0) :
def _read_head(f, size):
f.seek(0)
head = f.read(size)
f.seek(0)
return head
Le premier seek(0) est tout aussi important : lorsqu’un fichier arrive dans votre validateur, un autre traitement a peut-être déjà commencé à le lire et le pointeur peut se trouver au milieu du flux. Revenir au début avant la lecture, puis une nouvelle fois après, rend la fonction sûre à appeler à n’importe quel moment. L’objet fichier utilisé ici se comporte comme n’importe quel fichier Python ; consultez la documentation de io pour comprendre le fonctionnement de seek et read.
Valider un PDF
Un vrai PDF s’identifie grâce au marqueur %PDF-. La subtilité tient au fait que la spécification PDF autorise la présence de données quelconques avant cet en-tête — de nombreux lecteurs acceptent %PDF- n’importe où dans les 1 024 premiers octets, plutôt qu’exclusivement à l’octet zéro. Le contrôle parcourt donc les premiers kilo-octets au lieu de se limiter aux tout premiers octets :
def is_pdf(f):
# The PDF spec allows junk before the header; readers accept %PDF-
# anywhere in the first 1024 bytes.
return b'%PDF-' in _read_head(f, 1024)
C’est un bon exemple de la raison pour laquelle le conseil consistant à « vérifier seulement les 4 premiers octets » est trop simpliste. Les formats ont leurs particularités, et reproduire le comportement observé dans la pratique — ici, parcourir une fenêtre plutôt que vérifier une position fixe — permet de distinguer un validateur réellement efficace d’un contrôle qui rejette des fichiers légitimes. La signature elle-même est définie dans la spécification PDF ; la référence MDN sur les conteneurs offre un aperçu pratique de la manière dont les formats s’identifient.
Valider un DOCX (le cas délicat)
C’est avec les DOCX que les contrôles de signature trop simples montrent leurs limites. Un fichier .docx n’est pas un format autonome : c’est une archive ZIP, qui commence donc par la signature ZIP PK\x03\x04. Mais c’est également le cas de tous les autres fichiers fondés sur ZIP : .xlsx, .pptx, .epub ou un simple .zip. Confirmer qu’il s’agit d’un fichier ZIP ne revient pas à confirmer qu’il s’agit d’un document Word.
La validation se déroule donc en deux étapes : vérifier d’abord la signature ZIP, puis rechercher à l’intérieur de l’archive la structure qu’un vrai DOCX doit posséder — une entrée [Content_Types].xml et un dossier word/ :
import zipfile
def is_docx(f):
# DOCX is a ZIP archive (PK\x03\x04) containing [Content_Types].xml
# and a word/ folder. Checking the archive listing rejects arbitrary
# ZIPs without decompressing anything (zip-bomb safe).
if _read_head(f, 4) != b'PK\x03\x04':
return False
try:
with zipfile.ZipFile(f) as zf:
names = zf.namelist()
return '[Content_Types].xml' in names and any(
n.startswith('word/') for n in names
)
except (zipfile.BadZipFile, OSError, ValueError):
return False
finally:
f.seek(0)
Deux détails de sécurité méritent d’être soulignés. Premièrement, namelist() lit uniquement l’index de l’archive : il ne décompresse jamais son contenu, ce qui protège ce contrôle contre les zip bombs (de minuscules archives qui se déploient en gigaoctets). Consultez la documentation de zipfile pour savoir précisément ce que fait namelist. Deuxièmement, le bloc except large traite toute archive malformée ou illisible comme un simple « DOCX non valide », au lieu de laisser un fichier spécialement conçu faire échouer la requête ; et le bloc finally remet le fichier au début afin qu’il reste utilisable ensuite.
Le contenu doit correspondre à l’extension annoncée
Une fois les contrôles individuels en place, le validateur de CV les relie à l’extension déclarée. C’est une règle subtile mais essentielle : un fichier ne doit pas être accepté simplement parce qu’il correspond à un type autorisé — il doit correspondre au type qu’il prétend être :
from pathlib import Path
def is_valid_resume_file(f):
"""Content must match the claimed extension, not just any allowed type."""
ext = Path(f.name).suffix.lower()
if ext == '.pdf':
return is_pdf(f)
if ext == '.docx':
return is_docx(f)
return False
Pourquoi imposer la concordance entre la déclaration et le contenu ? Parce qu’une incohérence est déjà un signal d’alerte. Un fichier nommé .pdf dont les octets indiquent un DOCX — ou l’inverse — est soit endommagé, soit potentiellement malveillant ; dans les deux cas, il n’a pas sa place dans votre chaîne de traitement. Le return False par défaut signifie également que tout ce qui n’est pas explicitement autorisé est rejeté : c’est une politique de refus par défaut, particulièrement adaptée au code de sécurité.
Valider l’audio : un format, plusieurs signatures
L’audio révèle un autre piège : un même format logique peut légitimement commencer de plusieurs manières. Les navigateurs qui produisent de l’audio avec MediaRecorder utilisent différents formats conteneurs — Chrome, Edge et Firefox produisent généralement du WebM ou de l’Ogg, tandis que Safari produit du MP4/M4A — et le front-end utilisé ici nomme toujours le blob answer.webm, quelle que soit sa nature réelle. Le validateur vérifie donc l’ensemble des signatures produites dans la pratique, et non une seule :
def is_valid_audio_file(f):
head = _read_head(f, 12)
if head.startswith(b'\x1aE\xdf\xa3'): # EBML -> WebM/Matroska
return True
if head.startswith(b'OggS'): # Ogg
return True
if head[4:8] == b'ftyp': # MP4 / M4A
return True
if head.startswith(b'RIFF') and head[8:12] == b'WAVE': # WAV
return True
if head.startswith(b'ID3'): # MP3 with ID3 tag
return True
if len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0: # raw MP3 frame sync
return True
return False
Quelques cas méritent d’être détaillés, car ils montrent à quel point les signatures peuvent varier :
- MP4/M4A ne commence pas à l’octet zéro : son marqueur
ftypse trouve à l’offset 4, après un champ de longueur. C’est pourquoi le contrôle lithead[4:8]plutôt que le début du fichier. - WAV nécessite deux marqueurs : il commence par
RIFF, mais d’autres formats fondés sur RIFF font de même. Il faut donc aussi confirmer queWAVEapparaît aux octets 8 à 12. - MP3 possède deux formes valides : l’une avec une balise de métadonnées
ID3au début, l’autre qui commence directement par l’audio avec une « synchronisation de trame » — 11 bits à 1, détectés par le contrôle bit à bithead[1] & 0xE0 == 0xE0.
Ce dernier cas des MP3 illustre le genre de détail qu’un tableau de signatures trouvé dans un tutoriel ne signale pas toujours : si vous l’oubliez, des MP3 légitimes produits par certains encodeurs seront rejetés. Le véritable travail consiste à prendre en compte la diversité des fichiers réels.
La validation du contenu n’est qu’une couche de protection
Ce module répond précisément à une question — « ce fichier est-il réellement du type annoncé ? » — sans dépendance tierce. Mais il ne constitue qu’une couche parmi d’autres, et non une défense complète contre les risques liés aux importations. Il faut également imposer une taille maximale avant la lecture, stocker les fichiers en dehors de tout chemin exécutable ou servi comme du code, et les restituer de façon à empêcher un nom spécialement conçu de sortir de son répertoire. Le principe directeur est celui des frontières de confiance : un fichier qui passe d’un utilisateur à votre système reste non fiable tant que son contenu n’a pas démontré le contraire.
Avec cette approche en couches, chaque contrôle individuel — comme ceux présentés ci-dessus — s’intègre à une défense capable de résister à des entrées réellement hostiles, plutôt qu’à de simples fichiers de test bien formatés.
Il est également important de connaître les limites de la validation du contenu elle-même : un fichier polyglotte — conçu pour être simultanément valide selon deux formats différents — peut contenir les octets magiques légitimes d’un type autorisé tout en dissimulant un élément dangereux. C’est précisément pourquoi les recommandations sérieuses combinent plusieurs défenses au lieu de faire confiance à un contrôle unique : validation par signature binaire, limites de taille, stockage des fichiers en dehors des chemins exécutables et, pour les images, réencodage afin de supprimer le contenu intégré. Le OWASP File Upload Cheat Sheet est la référence à consulter dans son intégralité.
FAQ
Questions fréquentes
Pourquoi vérifier uniquement l’extension ne suffit-il pas à valider un fichier importé ?
L’extension n’est qu’une partie du nom du fichier, et n’importe qui peut renommer un fichier. Un fichier malveillant renommé avec une extension .pdf passera inchangé un contrôle d’extension. Seule la lecture du contenu réel du fichier — sa signature binaire — permet de déterminer ce qu’il est réellement.
Comment valider un fichier DOCX en Python ?
Un DOCX est une archive ZIP : il faut donc d’abord confirmer la signature ZIP (PK\x03\x04), puis inspecter la liste de l’archive pour vérifier la présence d’une entrée [Content_Types].xml et d’un dossier word/. Lire uniquement l’index de l’archive avec namelist() évite toute décompression et protège ainsi contre les zip bombs.
Pourquoi le validateur audio vérifie-t-il autant de signatures différentes ?
Un même format audio logique peut légitimement commencer de plusieurs façons. Les navigateurs produisent différents conteneurs avec MediaRecorder — WebM, Ogg, MP4/M4A — et le MP3 possède à lui seul deux formes valides. Un bon validateur accepte donc l’ensemble des signatures produites par les encodeurs réels, plutôt qu’une seule signature issue d’un tutoriel.
La validation par signature binaire suffit-elle à sécuriser les importations à elle seule ?
Non. Elle répond efficacement à la question « ce fichier est-il bien du type annoncé ? », mais une gestion robuste des importations doit également limiter la taille avant la lecture, stocker les fichiers en dehors des chemins exécutables et les servir de manière sûre. La validation du contenu constitue une couche importante d’une protection plus large.