Une comparaison == sur un token paraît anodine. En pratique, elle laisse filtrer une information mesurable : le temps d’exécution varie selon le nombre de caractères corrects. C’est le principe d’une timing attack, et c’est suffisant pour qu’un attaquant reconstitue le token caractère par caractère.
Le problème : la comparaison qui s’arrête trop tôt
Python compare les chaînes caractère par caractère et s’arrête dès qu’une différence est trouvée.
def verify_token(request):
user_token = request.GET.get('token')
valid_token = "secret_abc123"
if user_token == valid_token:
return JsonResponse({'access': 'granted'})
return JsonResponse({'access': 'denied'})
Concrètement :
user_token = "x"échoue au premier caractère, retour quasi immédiat.user_token = "secret_ab"échoue au 10e caractère, retour légèrement plus lent.
La différence est de l’ordre de la nanoseconde, mais elle devient mesurable avec suffisamment de requêtes. Un attaquant automatise des milliers d’appels, mesure les temps de réponse et déduit le token caractère par caractère. C’est une timing attack, une attaque par canal auxiliaire qui exploite les variations de temps d’exécution.
La correction : constant_time_compare()
Django fournit constant_time_compare() dans django.utils.crypto. Cette fonction compare deux chaînes en temps constant : le résultat prend exactement le même temps à calculer, quelle que soit la similarité entre les deux valeurs.
from django.utils.crypto import constant_time_compare
def verify_token(request):
user_token = request.GET.get('token', '')
valid_token = "secret_abc123"
if constant_time_compare(user_token, valid_token):
return JsonResponse({'access': 'granted'})
return JsonResponse({'access': 'denied'})
Deux détails importants dans cette correction :
request.GET.get('token', '')plutôt queget('token'): éviter qu’une valeurNonedéclenche une exception dans la comparaison.- La valeur de
valid_tokenne doit jamais être codée en dur en production. Elle doit venir d’une variable d’environnement ou d’un secret manager.
Sous le capot : hmac.compare_digest
constant_time_compare() est un wrapper autour de hmac.compare_digest() de la bibliothèque standard Python. Cette fonction garantit un temps d’exécution identique en comparant les octets sans court-circuit.
import hmac
# Équivalent direct hors contexte Django
if hmac.compare_digest(user_token, valid_token):
...
Dans un projet Django, constant_time_compare() est préférable car elle normalise les types en entrée et gère les cas limites comme la comparaison de chaînes Unicode.
Autres cas d’usage
Cette protection s’applique à tout contexte où une comparaison de secret est en jeu.
Vérification de signature de webhook :
from django.conf import settings
from django.utils.crypto import constant_time_compare
import hashlib
import hmac
def verify_webhook(request):
payload = request.body
received_sig = request.headers.get('X-Hub-Signature-256', '')
expected_sig = 'sha256=' + hmac.new(
settings.WEBHOOK_SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
if not constant_time_compare(received_sig, expected_sig):
return HttpResponse(status=403)
# traiter le webhook
Vérification d’une clé API :
from django.utils.crypto import constant_time_compare
def api_key_middleware(get_response):
def middleware(request):
provided_key = request.headers.get('X-Api-Key', '')
if not constant_time_compare(provided_key, settings.API_SECRET_KEY):
return JsonResponse({'error': 'unauthorized'}, status=401)
return get_response(request)
return middleware
Quand ce risque est concret
| Contexte | Niveau de risque |
|---|---|
| API interne, réseau local | ⚠️ Élevé : latence faible, mesures précises |
| API publique sur internet | ⚠️ Modéré : faisable avec de nombreuses requêtes |
| Valeur non confidentielle (slug, statut, ID public) | ✅ Aucun risque : == convient |
La règle de décision est simple : dès qu’une comparaison porte sur un secret (token, signature, clé API), utiliser constant_time_compare(). Le coût en performance est négligeable, la protection est réelle.
