backend / fastapi
Sécurité — rate limiting, headers et secrets
Explication
Ce que vous allez apprendre
- Mettre en place un rate limiting robuste sur les routes sensibles comme
/auth/login - Ajouter des headers de sécurité HTTP systématiques via un middleware
- Protéger les secrets applicatifs avec
SecretStrde Pydantic - Reconnaître et éliminer une injection SQL par concaténation de chaînes
- Mettre en place un audit trail sur les actions sensibles
Dans quel contexte ?
Un test d'intrusion commandé avant la mise en production révèle deux failles sur app/routers/auth.py : l'endpoint /auth/login accepte un nombre illimité de tentatives par seconde depuis la même IP, ce qui permettrait une attaque par force brute sur un mot de passe, et la clé secrète JWT est visible en clair dans les logs applicatifs à cause d'un print(settings) de débogage oublié. Ajouter un rate limiting de 5 tentatives par minute sur cette route et migrer les secrets vers SecretStr corrige les deux failles avant le déploiement.
Une discipline transversale, pas une leçon isolée
Cette dernière leçon du cours rassemble plusieurs réflexes de sécurité. Pris ensemble, ils réduisent significativement la surface d'attaque d'une API en production.
Certains ont déjà été effleurés dans des leçons précédentes, d'autres sont nouveaux ici. Commençons par une protection contre l'abus : le rate limiting.
Sans limite, rien n'empêche un client d'envoyer des milliers de requêtes par seconde. En particulier sur une route sensible comme /auth/login, utile pour deviner un mot de passe par force brute.
Limiter le nombre de requêtes par IP et par fenêtre de temps rend ce type d'attaque beaucoup plus coûteux à mener. Un backend partagé comme Redis devient nécessaire dès que l'API tourne sur plusieurs instances.
Une fois cette protection en place, une autre quasi gratuite s'ajoute : les en-têtes de sécurité HTTP. Des en-têtes comme X-Frame-Options demandent au NAVIGATEUR du client d'appliquer lui-même des protections supplémentaires.
Ils coûtent très peu à mettre en place pour un gain de sécurité réel contre des attaques comme le clickjacking. Une fois ces en-têtes configurés, il reste un sujet plus sensible à traiter : les secrets.
Une clé d'API ou un mot de passe de base de données ne doivent jamais apparaître en dur dans le code source. Ce code finit presque toujours par fuiter, via Git ou ailleurs.
SecretStr de Pydantic ajoute une protection supplémentaire. Même un affichage accidentel, comme un print ou un log mal placé, ne révèle pas la vraie valeur.
Il reste une menace ancienne mais toujours d'actualité à ne jamais négliger : l'injection SQL. Construire une requête en concaténant directement une chaîne fournie par l'utilisateur permet d'injecter du SQL arbitraire.
La protection est simple et non négociable. Toujours passer par l'ORM ou des requêtes paramétrées, jamais par de la concaténation de chaînes.
Piège de sécurité
db.execute(text(f"SELECT * FROM products WHERE name = '{name}'")) permet à un attaquant d'injecter du SQL arbitraire via le paramètre name (par exemple ' OR '1'='1). Toujours utiliser une requête paramétrée (text("... WHERE name = :name"), {"name": name}) ou, mieux, l'ORM directement.
| Réflexe de sécurité | Outil |
|---|---|
| Limiter les tentatives de connexion | slowapi + backend Redis |
| Protections navigateur (clickjacking, MIME sniffing) | Headers de sécurité HTTP |
| Protéger un secret dans les logs | SecretStr (Pydantic) |
| Empêcher l'injection SQL | Requêtes paramétrées / ORM |
| Reconstituer un incident après coup | Audit trail |
Pour finir ce cours, un dernier réflexe complète cette discipline : la traçabilité. Journaliser QUI a fait QUOI et QUAND sur les actions à fort impact, comme une suppression, est indispensable pour investiguer un incident après coup — une bonne pratique de clôture pour ce cours, qui aura couvert FastAPI de la première route jusqu'à une application prête pour la production.
Commandes & code
Sécurité — rate limiting, headers et secrets
pip install slowapi# Rate limiting robuste avec slowapi (basé sur limits + un backend Redis en production)
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, storage_uri="redis://localhost:6379")
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/auth/login")
@limiter.limit("5/minute") # 5 tentatives de connexion max par IP et par minute
def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()):
...
@app.get("/api/public-data")
@limiter.limit("100/minute")
def public_data(request: Request):
return {"data": "..."}# Headers de sécurité HTTP via un middleware dédié
from starlette.middleware.base import BaseHTTPMiddleware
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response
app.add_middleware(SecurityHeadersMiddleware)# Gestion des secrets — jamais en dur, chargés et validés via pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
database_url: SecretStr # SecretStr masque la valeur dans les logs/repr accidentels
jwt_secret_key: SecretStr
stripe_api_key: SecretStr
settings = Settings()
# Utilisation : settings.jwt_secret_key.get_secret_value() -- explicite, pas d'exposition accidentelle
print(settings.jwt_secret_key) # affiche "SecretStr('**********')", pas la vraie valeur# Protection contre les injections SQL — TOUJOURS via l'ORM ou des requêtes paramétrées
from sqlalchemy import text
# MAUVAIS — injection SQL possible
def bad_search(db: Session, name: str):
return db.execute(text(f"SELECT * FROM products WHERE name = '{name}'")) # DANGER
# BON — requête paramétrée
def good_search(db: Session, name: str):
return db.execute(text("SELECT * FROM products WHERE name = :name"), {"name": name})
# MEILLEUR — via l'ORM, paramétrage automatique
def best_search(db: Session, name: str):
return db.query(Product).filter(Product.name == name).all()# Limiter la taille des payloads entrants — prévention basique de DoS applicatif
from starlette.middleware.base import BaseHTTPMiddleware
MAX_BODY_SIZE = 1 * 1024 * 1024 # 1 Mo
class LimitBodySizeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
content_length = request.headers.get("content-length")
if content_length and int(content_length) > MAX_BODY_SIZE:
from starlette.responses import JSONResponse
return JSONResponse({"error": "Payload trop volumineux"}, status_code=413)
return await call_next(request)
app.add_middleware(LimitBodySizeMiddleware)# Audit trail — journaliser les actions sensibles (qui, quoi, quand) pour la traçabilité
async def log_sensitive_action(db: AsyncSession, user_id: int, action: str, resource_id: int):
audit_entry = AuditLog(
user_id=user_id,
action=action, # ex. "product.delete"
resource_id=resource_id,
timestamp=datetime.now(timezone.utc),
)
db.add(audit_entry)
await db.commit()
@router.delete("/products/{id}")
async def delete_product(
id: int,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_role(Role.ADMIN))],
):
product = await db.get(Product, id)
await db.delete(product)
await db.commit()
await log_sensitive_action(db, current_user.id, "product.delete", id)
return {"deleted": id}Résumé
slowapiavec un backend Redis applique un rate limiting cohérent même sur plusieurs instances de l'API.- Des headers de sécurité systématiques (HSTS, X-Frame-Options, CSP) réduisent la surface d'attaque XSS/clickjacking.
SecretStrde Pydantic empêche l'exposition accidentelle de secrets dans les logs ou les repr d'objets.- Toute requête SQL doit être paramétrée (ORM ou
text()avec paramètres liés), jamais construite par concaténation de strings. - Un audit trail des actions sensibles (suppression, changement de rôle) est indispensable en environnement de production.
Exercices pratiques
Mission : le rate limiting contourné en changeant d'instance
Objectif : Diagnostiquer un rate limiting inefficace en environnement multi-instances, et corriger une tentative d'échappement manuel dangereuse contre l'injection SQL.
Contexte
Après avoir ajouté le rate limiting de 5 tentatives par minute sur /auth/login avec slowapi comme vu dans cette leçon, un nouveau test d'intrusion parvient malgré tout à effectuer 15 tentatives de connexion en une minute. L'application tourne sur 3 instances derrière un load balancer, et le Limiter a été initialisé sans storage_uri explicite, utilisant donc son stockage en mémoire par défaut. Par ailleurs, un développeur propose de corriger l'injection SQL de app/routers/products.py en échappant manuellement les apostrophes plutôt qu'en utilisant une requête paramétrée.