Je suis Charlie

Autres trucs

Accueil

Seulement les RFC

Seulement les fiches de lecture

Ève

Limiter le nombre de requêtes sur des scripts WSGI

Première rédaction de cet article le 3 décembre 2012


Le standard WSGI permet de développer facilement des scripts en Python pour réaliser un service accessible via le Web. Ces services nécessitent souvent pas mal de traitement côté serveur et risquent donc de faire souffrir celui-là si un client maladroit appelle le service en boucle (ou, pire, si un client méchant essaye délibérement de planter le serveur en lançant plein de requêtes). Il est donc souhaitable de limiter le nombre de requêtes.

Voici la technique que j'utilise (il y en a plein d'autres, avec Netfilter, ou avec Apache). On crée un seau qui fuit (dans le fichier LeakyBucket.py). Chaque requête en provenance d'une adresse IP donnée ajoute une unité dans le seau. Le temps qui passe vide le seau. Lorsqu'une requête arrive, on vérifie si le seau est plein et on refuse la requête autrement. Cela nécessite une entrée par client dans la table des seaux (chaque client a un seau différent). Avec le LeakyBucket.py, cela donne :

if buckets[ip_client].full():
    # Refus
else:
    buckets[ip_client].add(1)

En pratique, on va regrouper les adresses IP des clients en préfixes IP, de manière à limiter le nombre de seaux et à éviter qu'un attaquant ne disposant d'un préfixe contenant beaucoup de machines ne puisse éviter la limitation en lançant simplement beaucoup d'adresses IP différentes à l'attaque. De manière assez arbitraire, on a mis 28 bits pour IPv4 et 64 pour IPv6. Pour faire les calculs sur les adresses IP, on se sert de l'excellent module Python netaddr :

ip_client = netaddr.IPAddress(environ['REMOTE_ADDR'])
if ip_client.version == 4:
    ip_prefix = netaddr.IPNetwork(environ['REMOTE_ADDR'] + "/28")
elif ip_client.version == 6:
    ip_prefix = netaddr.IPNetwork(environ['REMOTE_ADDR'] + "/64")
# Et on continue comme avant :
if buckets[ip_prefix.cidr].full():
...

Et comment se manifeste le refus ? On renvoie le code HTTP 429, Too Many Requests, normalisé par le RFC 6585. En WSGI :

status = '429 Too many requests'
output = "%s sent too many requests" % environ['REMOTE_ADDR']
response_headers = [('Content-type', 'text/plain'),
                        ('Content-Length', str(len(output)))]
start_response(status, response_headers)
return [output]

Cela donne quoi en pratique ? Testons avec curl :

% for i in $(seq 1 50); do
    curl --silent --output /dev/null \
          --write-out "$i HTTP status: %{http_code} ; %{size_download} bytes downloaded\n" \
       http://www.bortzmeyer.org/apps/counter
done
1 HTTP status: 200 ; 278 bytes downloaded
2 HTTP status: 200 ; 278 bytes downloaded
3 HTTP status: 200 ; 278 bytes downloaded
4 HTTP status: 200 ; 278 bytes downloaded
5 HTTP status: 200 ; 278 bytes downloaded
6 HTTP status: 200 ; 278 bytes downloaded
7 HTTP status: 200 ; 278 bytes downloaded
8 HTTP status: 200 ; 278 bytes downloaded
9 HTTP status: 200 ; 278 bytes downloaded
10 HTTP status: 200 ; 278 bytes downloaded
11 HTTP status: 429 ; 36 bytes downloaded
12 HTTP status: 429 ; 36 bytes downloaded
13 HTTP status: 200 ; 278 bytes downloaded
14 HTTP status: 200 ; 278 bytes downloaded
15 HTTP status: 429 ; 36 bytes downloaded
16 HTTP status: 429 ; 36 bytes downloaded
...

On voit bien l'acceptation initiale, puis le rejet une fois le nombre maximal de requêtes fait, puis à nouveau l'acceptation lorsque le temps a passé. Attention en configurant la taille du seau (default_bucket_size dans LeakyBucket.py) : il y a plusieurs démons WSGI qui tournent et le nombre de requêtes acceptées est donc la taille du seau multipliée par le nombre de démons.

Comme souvent avec les mesures de sécurité, elles peuvent avoir des effets secondaires, parfois graves. Ici, le serveur alloue une table indexée par le nombre de préfixes IP. Si un attaquant méchant peut envoyer des paquets depuis d'innombrables adresses IP usurpées (c'est non trivial en TCP), il peut vous faire allouer de la mémoire en quantité impressionnante.

Si vous voulez les fichiers authentiques et complets, voir les fichiers de ce blog (fichiers wsgis/LeakyBucket.py et wsgis/dispatcher.py).

Merci à David Larlet pour l'amélioration du code.

Version PDF de cette page (mais vous pouvez aussi imprimer depuis votre navigateur, il y a une feuille de style prévue pour cela)

Source XML de cette page (cette page est distribuée sous les termes de la licence GFDL)