cyber / cybersecurite-fondamentale
Sécurité des API
Explication
Ce que vous allez apprendre
- Distinguer clairement authentification (qui es-tu) et autorisation (qu'as-tu le droit de faire)
- Reconnaître et corriger un mass assignment via des schémas d'entrée stricts (
extra = "forbid") - Adapter le rate limiting selon le coût et la sensibilité réelle de chaque route
- Vérifier explicitement l'algorithme attendu lors du décodage d'un JWT pour éviter l'attaque "alg=none"
- Éviter l'accumulation d'API fantômes grâce à un versionnement et une dépréciation clairement documentés
Dans quel contexte ?
Une API permet à un utilisateur de mettre à jour son profil via PATCH /users/me en acceptant directement le JSON envoyé. Un attaquant ajoute discrètement le champ "role": "admin" à sa requête. Si le schéma d'entrée ne rejette pas les champs inattendus, ce champ est silencieusement accepté et l'utilisateur se retrouve administrateur du système, sans qu'aucune vulnérabilité "visible" n'ait été exploitée : c'est un mass assignment, une des failles les plus sournoises du Top 10 API OWASP.
Une synthèse plutôt qu'un sujet nouveau
Cette leçon rassemble les principes vus jusqu'ici (authentification, moindre privilège, injection, rate limiting) et les applique spécifiquement au contexte des API, qui sont aujourd'hui la porte d'entrée principale de la majorité des applications modernes (frontend web, mobile, intégrations tierces).
Authentification n'est pas autorisation
C'est la confusion la plus fréquente chez les développeurs débutants : vérifier qu'un utilisateur EST bien connecté (authentification) ne dit rien sur ce qu'il a le DROIT de faire (autorisation). Un endpoint qui vérifie seulement la présence d'un jeton valide, sans vérifier les permissions associées, laisse n'importe quel utilisateur connecté accéder à des actions réservées aux administrateurs.
Le mass assignment, un piège silencieux
Piège fréquent
Quand une API accepte directement un objet JSON envoyé par le client et le convertit tel quel en objet de base de données, elle prend un risque énorme : un attaquant peut ajouter des champs non prévus dans sa requête (comme "role": "admin" ou "is_verified": true) et les voir acceptés silencieusement si le schéma d'entrée n'interdit pas explicitement les champs inattendus. La bonne pratique est de définir des schémas d'entrée stricts qui rejettent tout champ non prévu.
Pourquoi le rate limiting doit être différencié
Toutes les routes n'ont pas le même coût ni le même risque.
| Type de route | Risque principal | Stratégie de limite |
|---|---|---|
| Login / authentification | Brute force, credential stuffing | Limite basse par IP + par compte |
| Recherche en base de données | Coût CPU/DB élevé | Limite modérée + pagination obligatoire |
| Export de données volumineuses | Exfiltration, déni de service | Limite très basse + file d'attente |
Appliquer une limite uniforme partout laisse souvent les routes les plus sensibles sous-protégées.
Le versionnement d'API comme discipline de sécurité
Bonne pratique
Garder d'anciennes versions d'API actives indéfiniment, sans dépréciation claire, crée des "API fantômes" : des routes oubliées, moins surveillées, souvent moins bien sécurisées que la version actuelle. Documentez et planifiez systématiquement la fin de vie de chaque version d'API exposée.
Commandes & code
Sécurité des API
# OWASP API Security Top 10 (extrait pertinent)
API1 : Broken Object Level Authorization (= IDOR appliqué aux API, voir leçon dédiée)
API2 : Broken Authentication
API3 : Broken Object Property Level Authorization (exposition de champs sensibles)
API4 : Unrestricted Resource Consumption (absence de rate limiting)
API5 : Broken Function Level Authorization (accès à des endpoints admin sans contrôle de rôle)
API8 : Security Misconfiguration
API9 : Improper Inventory Management (API "fantômes" non documentées, oubliées)# Authentification API par JWT : structure et vérification correcte
import jwt
from datetime import datetime, timedelta, timezone
JWT_SECRET = settings.jwt_secret # chargé depuis vault/env, jamais en dur
ALGORITHM = "HS256"
def create_access_token(user_id: int) -> str:
payload = {
"sub": str(user_id),
"iat": datetime.now(timezone.utc),
"exp": datetime.now(timezone.utc) + timedelta(minutes=15), # durée de vie courte
"type": "access",
}
return jwt.encode(payload, JWT_SECRET, algorithm=ALGORITHM)
def verify_access_token(token: str) -> dict:
try:
return jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) # spécifier explicitement l'algo attendu
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expiré")
except jwt.InvalidTokenError:
raise HTTPException(401, "Token invalide")
# Piège classique : ne PAS spécifier "algorithms=[...]" ouvre à l'attaque "alg=none"
# où un attaquant forge un JWT avec l'algorithme "none" pour contourner la vérification de signature# Validation stricte des entrées avec Pydantic (rejette tout ce qui n'est pas explicitement attendu)
from pydantic import BaseModel, EmailStr, Field, field_validator
class CreateUserRequest(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=32, pattern=r"^[a-zA-Z0-9_-]+$")
age: int = Field(ge=13, le=120)
class Config:
extra = "forbid" # rejette tout champ non déclaré (empêche le mass assignment / injection de champs)
@field_validator("username")
@classmethod
def username_not_reserved(cls, v):
if v.lower() in {"admin", "root", "system"}:
raise ValueError("nom d'utilisateur réservé")
return v# Mass assignment : ne JAMAIS binder directement l'entrée utilisateur sur un modèle DB complet
# VULNÉRABLE : l'utilisateur peut injecter "role": "admin" ou "is_verified": true dans le JSON
@app.post("/users")
async def create_user_vulnerable(payload: dict, db=Depends(get_db)):
user = User(**payload) # accepte AVEUGLÉMENT tous les champs fournis
db.add(user)
# CORRECT : passer par un schéma d'entrée explicite qui ne contient QUE les champs autorisés
@app.post("/users")
async def create_user_safe(payload: CreateUserRequest, db=Depends(get_db)):
user = User(email=payload.email, username=payload.username, role="viewer") # role forcé côté serveur
db.add(user)# Rate limiting par utilisateur/IP avec slowapi (wrapper de limits pour FastAPI)
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.get("/api/search")
@limiter.limit("30/minute") # limite spécifique à cet endpoint coûteux
async def search(request: Request, q: str):
...
@app.post("/api/export")
@limiter.limit("5/hour") # endpoints lourds : quotas plus stricts
async def export_data(request: Request):
...# Documentation exhaustive de l'inventaire API (contre API9 - Improper Inventory Management)
# OpenAPI généré automatiquement par FastAPI, mais à compléter avec le versionnement explicite
openapi: 3.0.0
info:
title: Technologik API
version: "2.3.0"
paths:
/api/v1/courses:
get:
deprecated: false
/api/v1/legacy/courses:
get:
deprecated: true # endpoint legacy à surveiller/retirer, jamais laisser "oublié" en production# Checklist sécurité API avant mise en production
- Chaque endpoint vérifie l'authentification ET l'autorisation (pas seulement l'authentification)
- Les schémas Pydantic utilisent "extra = forbid", jamais de binding direct dict -> modèle DB
- Rate limiting en place sur les endpoints sensibles/coûteux (recherche, export, login)
- Versionnement explicite des routes (/api/v1/...) avec dépréciation documentée des anciennes versions
- Aucune donnée sensible (mot de passe hashé, secrets) dans les réponses JSON, même par erreurRésumé
- Toujours spécifier l'algorithme JWT attendu à la vérification (
algorithms=[...]), jamais l'accepter dynamiquement. - Le mass assignment se contre avec des schémas d'entrée stricts (
extra = "forbid"), jamais un binding direct. - Le rate limiting doit être différencié par endpoint selon son coût réel (login, export, recherche).
- Documenter et déprécier explicitement les anciennes versions d'API évite les "API fantômes" oubliées.
Exercices pratiques
Mission : l'utilisateur qui s'est auto-promu administrateur
Objectif : Identifier une faille de mass assignment à partir d'une requête réseau et corriger le schéma d'entrée en conséquence.
Contexte
Un utilisateur standard de Technologik envoie cette requête à PATCH /users/me :
{"display_name": "Alex", "role": "admin"}Après cet appel, il découvre qu'il a désormais accès aux fonctions d'administration. Le endpoint utilise aujourd'hui payload: dict et fait User(**payload) directement sur le modèle de base de données.