Undefined


Mettre en place du Rate Limit grâce au composant Symfony RateLimiter

Ajouté à la version 5.2 de Symfony, le composant RateLimiter est un composant qui permet de contrôler le nombre de requêtes ou d'actions qu'un utilisateur ou un programme peut effectuer. On va voir comment le mettre en place de manière globale afin de limiter le nombre de requête par utilisateur mais aussi de sanctionner les mauvais utilisateurs.

Le rate limit est souvent mis en place pour se prémunir des attaques par déni de service (DoS), ou des crawlers un peu trop gourmands. Il peut aussi permettre de limiter le nombre d'appel fait à une API. Par exemple, un utilisateur avec le free-tier pourra faire 10 requêtes par minute et un utilisateur premium 100 par minute.

En général, la règle que l'on veut mettre en oeuvre est de limiter l'utilisateur, ou une adresse IP, à X requêtes sur une période de Y minutes. On va voir comment faire ça dans un projet Symfony. Puis dans un deuxième temps comment rajouter des règles personnalisées.

Installation et configuration du composant RateLimiter

On commence d'abord par installer le composant RateLimiter :

composer require symfony/rate-limiter

Et il va nous falloir aussi le composant Lock :

composer require symfony/lock

Le composant lock n'est pas obligatoire, comme expliqué dans la documentation Symfony, mais il permet de mieux gérer les accès concurrents.

Autre élément important sur lequel s'appuie le RateLimiter : le composant Cache. Il va nous permettre de stocker l'information sur le nombre restant de jeton disponible pour un consommateur (une adresse IP ou un autre élément permettant de définir notre utilisateur).

Si ce n'est pas déjà le cas, alors pareil, il vous faut l'installer :

composer require symfony/cache

Bien, maintenant que l'installation est faite, on peut passer à la configuration de tout ça :

Déjà pour le cache vous devriez avoir la configuration suivante :

# config/packages/cache.yaml
framework:
    cache:
        app: cache.adapter.filesystem
        system: cache.adapter.system

On défini des adapter pour 2 "pool" de cache :

  • system : utilisé en interne par Symfony
  • app : celui que l'on utilise nous par défaut.

Et dans notre cas, le pool est rate_limiter. Cependant si on ne le déclare pas il utilisera automatiquement le pool app. On va donc avoir notre cache qui sera stocker sur notre filesystem.

Dans une application distribué il vaudra mieux utiliser un cache partagé comme, par exemple, redis qui a, bien entendu, un adapter déjà fourni.

Ensuite, pour le composant lock on va faire la solution la plus simple en utilisant aussi le filesystem :

# config/packages/lock.yaml
framework:
    lock: 'flock'

Finalement, on peut configurer notre rate limiter :

# config/packages/rate_limiter.yaml
framework:
    rate_limiter:
        main:
            policy: 'fixed_window'
            limit: 100
            interval: '60 minutes'

On a donc ici un rate limiter : main avec trois paramètres.

  1. policy : la stratégie de rate limit que l'on souhaite mettre en place. Il en existe 3 qui sont très bien documenté dans la documentation Symfony, ou alors, pour aller un peu plus loin, dans cet article "An alternative approach to rate limiting" qui nous présent un cas d'utilisation du rate limit pour un problème rencontré par les équipes de Figma.
  2. limit : ici c'est le nombre de jeton (et non pas spécifiquement de requête) que l'on octroie a un utilisateur.
  3. interval : et pour finir l'intervalle que couvre notre stratégie.

En résumé si l'on reprend ce qui est défini au dessus, on va conférer 100 jetons a notre utilisateur sur une fenêtre de 1 heure. Ainsi, si il effectue ça première requête a 9h15, il aura jusqu'à 10h15 pour consommer un maximum de 100 jetons. Si il décide d'utiliser les 100 durant le premier quart d'heure il sera obligé d'attendre 10h15 pour en avoir d'autres.

Dernière étape, écrire le code qui correspond a la manière dont l'on souhaite consommer les jetons : 1 jeton = 1 requête.

Utilisation du composant RateLimiter

Pour le faire de manière globale, on va utiliser un EventListener sur l'évènement kernel.request de Symfony. Ainsi on pourra a chaque requête d'un utilisateur venir rajouter le comportement voulu de notre rate limit.

// src/EventListener/RateLimiterEventListener.php

namespace App\EventListener;

/**
 * L'attribut permet de déclarer la méthode onRequest 
 * comme un event listener sur l'event RequestEvent (kernel.request)
 **/
#[AsEventListener(RequestEvent::class, 'onRequest')]
final readonly class RateLimiterEventListener
{
    /**
     * Avec l'autowiring de Symfony on peut directement récupérer 
     * le RateLimiter main en lui donnant le nom $mainLimiter
     **/
    public function __construct(private RateLimiterFactory $mainLimiter) {}

    public function onRequest(RequestEvent $event): void
    {
      /**
       * On va récupérer le limiter associé a notre user ici via l'IP
       **/
      $limiter = $this->mainLimiter->create(
        $event->getRequest()->getClientIp()
      );

      /**
       * on peut directement faire un consume (1 jeton)
       * et vérifier si l'utilisateur a le droit
       * dans le cas ou il y a un échec on peut renvoyer
       * une réponse HTTP 429 à l'utilisateur sans même que notre
       * controller soit appelé :)
       **/
      if (false === $limiter->consume()->isAccepted()) {
        $event->setResponse(
          new Response(status: Response::HTTP_TOO_MANY_REQUESTS)
        );
      }
    }
}

Et voilà, on a un rate limit de fonctionnel ! 🥳

Plutôt simple a faire pour le coup et on peut faire un peu ce que l'on veut de cette base en venant rajouter un rate limit différent par en fonction du role de notre utilisateur, ou alors appliquer des valeurs différentes en fonction de l'heure de la journée. Pour ma part ce qui me pose problème c'est les crawlers qui viennent sur mon site est chercher des URL qui n'existent pas ou alors a exploiter des failles connues.

Pour ça, on peut assez facilement sanctionner les mauvais usages. En consommant des jetons aussi quand une réponse n'est pas un code 2xx.

Dans la même classe on va rajouter un event listener sur le kernel.response :

#[AsEventListener(RequestEvent::class, 'onRequest')]
+ #[AsEventListener(ResponseEvent::class, 'onResponse')]

Et on peut rajouter la méthode onResponse :

public function onResponse(ResponseEvent $event): void
{
    $limiter = $this->publicLimiter->create($event->getRequest()->getClientIp());

    $limit = $limiter->consume(match ($event->getResponse()->getStatusCode()) {
        Response::HTTP_NOT_FOUND => 5,
        Response::HTTP_FORBIDDEN, Response::HTTP_METHOD_NOT_ALLOWED, Response::HTTP_BAD_REQUEST => 10,         
        Response::HTTP_UNAUTHORIZED => 100,
        default => 0,
    });
}

On récupère de la même manière que dans la méthode onRequest le limiter par l'IP. Puis on appel la méthode consume mais au lieu de consommer 1 jeton, on va en consommer 5 supplémentaire pour les 404, 10 pour les 403, 405 ou 400 et carrément 100 pour les 401. Les valeurs sont définis par rapport a mon cas précis; c'est pas forcément à utiliser dans d'autres cas surtout que ça dépendra de la limite défini au global.

À noter que si l'utilisateur dépasse le nombre maximum de jeton lors d'une réponse il ne recevra une 429 que lors de ça prochaine requête et on lui renverra bien sa réponse.

Le composant est vraiment bien construit est permet de facilement l'intégrer et de le faire fonctionner sur n'importe quelle application grâce la flexibilité des composants lock et cache. Il permet de mettre en place une stratégie de cache très simplement mais aussi de construire un système plus complexe.

J'ai mis en place la stratégie que l'on a vu sur ce blog et vous pouvez retrouver le code source sur le projet GitHub : https://github.com/adrien-chinour/blog


  • Symfony

  • PHP