backend / fastapi
Upload de fichiers
Explication
Ce que vous allez apprendre
- Recevoir un fichier uploadé avec
UploadFilesans le charger entièrement en mémoire - Combiner upload de fichiers multiples et champs de formulaire classiques
- Vérifier le vrai type d'un fichier via ses "magic bytes", jamais via le
content_typedéclaré - Générer un nom de fichier serveur sûr pour éviter collisions et path traversal
- Comprendre pourquoi un stockage objet (S3) est préférable au disque local en production
Dans quel contexte ?
Sur l'endpoint POST /products/{id}/images de app/routers/products.py, un utilisateur malveillant renomme un script exécutable en photo.png et l'envoie avec un content_type: image/png falsifié dans sa requête. Si le serveur fait confiance à ce content_type déclaré par le client, ce script pourrait être accepté et stocké comme une image légitime. Vérifier les "magic bytes" réels du fichier avec python-magic détecte que le contenu n'est pas réellement une image PNG, quel que soit le nom ou l'en-tête envoyé par le client.
Le problème, d'abord
Un fichier téléversé peut peser plusieurs mégaoctets, voire davantage. Le charger entièrement en mémoire avant de le traiter serait risqué et coûteux en ressources, surtout avec de nombreux uploads simultanés.
UploadFile de FastAPI résout ce problème en exposant le fichier comme un flux. Un fichier temporaire sur disque géré automatiquement, qui peut être lu par morceaux plutôt que d'un seul bloc.
Une fois ce mécanisme compris, un point de sécurité central mérite attention : ne jamais faire confiance à ce que déclare le client. Le content_type qu'un fichier envoie, comme "image/png", est une simple DÉCLARATION faite par le client.
Rien n'empêche un attaquant d'envoyer un script malveillant renommé en .png avec ce content_type falsifié. La seule vérification fiable consiste à inspecter les premiers octets réels du fichier, les "magic bytes".
Une librairie comme python-magic permet cette inspection. Elle détermine la vraie nature du fichier, indépendamment de ce que le client prétend.
Une fois le type vérifié, il reste un autre risque à éviter : le nommage du fichier sur le serveur. Réutiliser le nom fourni par le client est risqué à deux titres.
Un nom identique écraserait un fichier existant, et un nom malicieusement construit pourrait tenter une attaque de "path traversal". Générer un nom unique côté serveur, via un UUID, élimine ces deux risques d'un coup.
Piège de sécurité
Le content_type déclaré par le client dans une requête HTTP est une simple affirmation, jamais une garantie. Rien n'empêche un attaquant d'envoyer un fichier malveillant avec un content_type: image/png falsifié. Seule l'inspection des octets réels du fichier (magic bytes, via python-magic) est fiable.
Il reste une dernière question à se poser : où stocker réellement ces fichiers ? Les sauvegarder directement sur le disque du serveur applicatif pose un problème de scalabilité et de fiabilité.
Le piège fréquent à connaître avant de pratiquer : en production, un service de stockage objet dédié comme S3 est la norme, car il reste accessible depuis n'importe quelle instance de l'application, contrairement au disque local d'un serveur qui peut disparaître à un redémarrage.
| Vérification | Fiable ? | Outil |
|---|---|---|
file.content_type déclaré par le client | Non — falsifiable | — |
| Inspection des octets réels du fichier | Oui | python-magic |
| Nom de fichier fourni par le client | Non — collision/path traversal possibles | — |
| Nom généré côté serveur (UUID) | Oui | uuid.uuid4() |
Commandes & code
Upload de fichiers
from fastapi import FastAPI, UploadFile, File
from typing import Annotated
app = FastAPI()
@app.post("/upload")
async def upload_file(file: Annotated[UploadFile, File()]):
return {
"filename": file.filename,
"content_type": file.content_type,
"size": file.size,
}# Upload multiple + champs additionnels dans le même formulaire
from fastapi import Form
@app.post("/products/{product_id}/images")
async def upload_product_images(
product_id: int,
files: Annotated[list[UploadFile], File()],
caption: Annotated[str, Form()] = "",
):
results = []
for file in files:
results.append({"filename": file.filename, "size": file.size})
return {"product_id": product_id, "caption": caption, "uploaded": results}# Validation du type et de la taille — NE JAMAIS faire confiance au content_type déclaré par le client
import magic # python-magic, lit les vrais "magic bytes" du fichier
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_SIZE_BYTES = 5 * 1024 * 1024 # 5 Mo
async def validate_image(file: UploadFile) -> bytes:
content = await file.read()
if len(content) > MAX_SIZE_BYTES:
raise HTTPException(status_code=413, detail="Fichier trop volumineux (max 5 Mo)")
real_mime_type = magic.from_buffer(content, mime=True) # inspecte le contenu réel
if real_mime_type not in ALLOWED_TYPES:
raise HTTPException(status_code=400, detail=f"Type de fichier non autorisé : {real_mime_type}")
await file.seek(0) # remettre le curseur au début pour une lecture ultérieure
return content# Sauvegarde en streaming — évite de charger tout le fichier en mémoire pour des gros fichiers
import shutil
from pathlib import Path
import uuid
UPLOAD_DIR = Path("./uploads")
UPLOAD_DIR.mkdir(exist_ok=True)
@app.post("/upload-large")
async def upload_large_file(file: Annotated[UploadFile, File()]):
safe_filename = f"{uuid.uuid4()}_{file.filename}" # évite les collisions et les path traversal
destination = UPLOAD_DIR / safe_filename
with destination.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer) # copie par chunks, pas tout en RAM
return {"saved_as": safe_filename}# Upload vers un stockage cloud (S3) plutôt que le disque local du serveur
import boto3
s3_client = boto3.client("s3")
@app.post("/upload-s3")
async def upload_to_s3(file: Annotated[UploadFile, File()]):
content = await validate_image(file)
key = f"uploads/{uuid.uuid4()}-{file.filename}"
s3_client.put_object(
Bucket="mon-bucket",
Key=key,
Body=content,
ContentType=file.content_type,
)
return {"url": f"https://mon-bucket.s3.amazonaws.com/{key}"}Résumé
UploadFileexpose un fichier en streaming (.file), plus efficace quebytespour de gros fichiers.- Ne jamais se fier au
content_typedéclaré par le client : inspecter les vrais octets du fichier (python-magic). - Générer un nom de fichier unique (UUID) empêche les collisions et les attaques de path traversal.
- En production, stocker les fichiers uploadés sur un service dédié (S3, GCS) plutôt que sur le disque du serveur applicatif.
Exercices pratiques
Mission : le faux fichier PNG qui passe la validation
Objectif : Corriger un endpoint d'upload d'image qui fait confiance au content_type déclaré par le client et qui réutilise le nom de fichier fourni.
Contexte
Sur POST /products/{id}/images de app/routers/products.py, la validation actuelle vérifie uniquement if file.content_type not in ALLOWED_TYPES. Un audit de sécurité a réussi à uploader un script exécutable renommé photo.png avec un content_type: image/png falsifié dans la requête, qui a été accepté sans problème. Le fichier est ensuite sauvegardé sous son nom original directement fourni par le client.