Retour au cours

backend / fastapi

Upload de fichiers

Leçon 171 exercice

Explication

Ce que vous allez apprendre

  • Recevoir un fichier uploadé avec UploadFile sans 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_type dé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érificationFiable ?Outil
file.content_type déclaré par le clientNon — falsifiable
Inspection des octets réels du fichierOuipython-magic
Nom de fichier fourni par le clientNon — collision/path traversal possibles
Nom généré côté serveur (UUID)Ouiuuid.uuid4()

Commandes & code

Upload de fichiers

python
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,
    }
python
# 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}
python
# 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
python
# 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}
python
# 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é

  • UploadFile expose un fichier en streaming (.file), plus efficace que bytes pour de gros fichiers.
  • Ne jamais se fier au content_type dé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

1 disponible
1

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.

Résoudre l’exercice →