Protéger son application avec le composant Rate Limiter de Symfony

Les attaques sur le web sont légions, mais certaines comme les brutes force ou les énumérations peuvent être facilement bloquées. Il y a plusieurs solutions pour ça, dans cet article je vais vous montrer comment le faire avec l'utilisation du composant Rate Limiter du framework Symfony.

Protéger son application des attaques

Pour l'exemple, disons que je travaille sur un projet SAAS dans le domaine de la finance. C'est un projet moderne (style 2020) avec une application riche front utilisant une API coté back, bien sûr écrite avec le framework Symfony.
Après analyse des logs, on sait qu'un utilisateur ne consulte pas plus de 60 pages par heure. Il nous semble raisonnable dans ce cas de mettre en place une limite d’accès de 10 pages/minutes. Cela ne devrait pas poser de problème à un utilisateur et devrait limiter les robots de faire des énumérations ou de lancer des brutes force.

Si vous n'avez pas déjà le composant RateLimiter de Symfony d'installé, vous pouvez le faire via cette commande avec Composer.

composer require symfony/rate-limiter

Commençons par créer une nouvelle configuration du RateLimiter dans le fichier config/packages/rate_limiter.yaml

framework:
    rate_limiter:
        authenticated_request:
            policy: 'token_bucket'
            limit: 25
            rate: {interval: '1 minute', amount: 10}

J'ai utilisé la stratégie Token Bucket. Je la préfère aux autres car elle autorise l'utilisateur de consommer son quota non utilisé d'un coup tout en gardant une limite. La documentation de Symfony explique toutes les stratégies disponibles.

Une fois que nous avons notre RateLimiter, il ne reste plus qu'a utiliser les événements du Kernel de Symfony pour vérifier si l'utilisateur a atteint son quota. Un EventSubscriber sur l’événement Request fera l'affaire

<?php
 
declare(strict_types=1);
 
namespace Security\Infrastucture\Event;
 
use Security\Infrastucture\RateLimiting\RequesterIdentifierProvider;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Exception\RateLimitExceededException;
 
class RateLimiterSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly RateLimiterFactory $authenticatedRequestLimiter,
        private readonly RequesterIdentifierProvider $identifierProvider
    ) {
    }
 
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => 'onRequest',
        ];
    }
 
    public function onRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
        $identifier = $this->identifierProvider->getIdentifier($request);
 
        if (null === $identifier) {
            return;
        }
 
        $limiter = $this->authenticatedRequestLimiter->create($identifier)->consume(1);
        $limiter->ensureAccepted();
    }
}

Et voilà! C'est assez simple non? Grace à l'autowire du container de service de Symfony, il suffit de nommer le RateLimiterFactory en fonction du nom donné dans la config pour que la bonne instance soit injectée dans la classe. La classe RequesterIdentifierProvider que je n'ai pas détaillée, me permet de créer un identifiant unique pour chaque utilisateur. Dans mon cas, j'extrais le nom de l'utilisateur du JWT présent dans le header Authorization de la requête que j'associe à son IP. Si vous vous demandez pourquoi l'identifiant peut être null, c'est pour ne pas avoir de rate limiter sur les appels provenant des autres applications (micro services) qui utilisent un scope spécifique présent dans le JWT

Protéger son infrastructure de la surcharge

Maintenant que l'on a un RateLimiter simple en place, je souhaite ajouter plus de contrôle sur certaines routes. Au début de chaque mois, les utilisateurs viennent sur l'application demander la génération du rapport d'activité mensuel. Générer ce rapport demande du temps et même s'il est fait en asynchrone, je ne souhaite pas qu'un utilisateur puisse le demander plusieurs fois ce qui résulterait d'une surcharge de l’infrastructure pour rien.

J'ajoute donc un nouveau RateLimiter avec une limite de 1 requête par jour.

framework:
    rate_limiter:
        authenticated_request:
            policy: 'token_bucket'
            limit: 25
            rate: {interval: '1 minute', amount: 10}

        report_request:
            policy: 'fixed_window'
            limit: 1
            interval: '1 day'
 

Avec 2 configurations du RateLimiter (et plus demain), j'ai besoin de refactoriser mon EventSubscriber.
Je commence par créer une interface RateLimiterInterface, puis je crée deux implémentations de cette interface, une par RateLimiter.

<?php
declare(strict_types=1);
 
namespace Security\Infrastucture\RateLimiting;
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\LimiterInterface;
 
interface RateLimiterInterface
{
    public function support(Request $request): bool;
 
    public function getLimiter(string $identifier): LimiterInterface;
}

Ma première implémentation pour le RateLimiter générale qui accepte toutes les routes de l'application.

<?php
declare(strict_types=1);
 
namespace Security\Infrastucture\RateLimiting;
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
 
class AuthenticatedRequestRateLimiter implements RateLimiterInterface
{
    public function __construct(private readonly RateLimiterFactory $authenticatedRequestLimiter)
    {
    }
 
    public function support(Request $request): bool
    {
        $uri = $request->getRequestUri();
 
        return 0 === stripos($uri, '/api');
    }
 
    public function getLimiter(string $identifier): LimiterInterface
    {
        return $this->authenticatedRequestLimiter->create($identifier);
    }
}

La seconde implémentation pour le RateLimiter de la génération de rapport qui n'accepte que la route de demande de rapport.

<?php
declare(strict_types=1);
 
namespace Security\Infrastucture\RateLimiting;
 
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\RateLimiter\LimiterInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
 
class ReportRequestRateLimiter implements RateLimiterInterface
{
    public function __construct(private readonly RateLimiterFactory $reportRequestLimiter)
    {
    }
 
    public function support(Request $request): bool
    {
        $uri = $request->getRequestUri();
 
        return preg_match('#^/api/activity/export#', $uri) && $request->isMethod(Request::METHOD_POST);
    }
 
    public function getLimiter(string $identifier): LimiterInterface
    {
        return $this->reportRequestLimiter->create($identifier);
    }
}

Ensuite je crée une classe provider qui me permet de choisir quel rate limiter je peux utiliser en fonction de la route.

<?php
declare(strict_types=1);
 
namespace Security\Infrastucture\RateLimiting;
 
use Symfony\Component\HttpFoundation\Request;
use Webmozart\Assert\Assert;
 
class RateLimiterProvider
{
    private array $rateLimiters = [];
 
    public function __construct(iterable $rateLimiters)
    {
        foreach ($rateLimiters as $rateLimiter) {
            Assert::isInstanceOf($rateLimiter, RateLimiterInterface::class);
            $this->rateLimiters[] = $rateLimiter;
        }
    }
 
    /**
     * @return RateLimiterInterface[]
     */
    public function findByRequest(Request $request): array
    {
        return \array_filter(
            $this->rateLimiters,
            fn (RateLimiterInterface $rateLimiter): bool => $rateLimiter->support($request)
        );
    }
}

Grace au container de service de Symfony, il est facile d'injecter toutes les instance de l'interface RateLimiterInterface dans ma classe provider en utilisant un tag.

services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    Security\:
        resource: '%kernel.project_dir%/src/Security/*'

    _instanceof:
        Security\Infrastucture\RateLimiting\RateLimiterInterface:
            tags: ['api.rate_limiter']

    Security\Infrastucture\RateLimiting\RateLimiterProvider:
        arguments:
            $rateLimiters: !tagged_iterator api.rate_limiter

Il ne reste plus qu'à mettre à jour l'EventSubscriber pour utiliser la classe provider.

<?php
 
declare(strict_types=1);
 
namespace Security\Infrastucture\Event;
 
use Security\Infrastucture\RateLimiting\RateLimiterProvider;
use Security\Infrastucture\RateLimiting\RequesterIdentifierProvider;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\Exception\RateLimitExceededException;
 
class RateLimiterSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly RateLimiterProvider $limiterProvider,
        private readonly RequesterIdentifierProvider $identifierProvider
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => 'onRequest',
        ];
    }

    public function onRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();
 
        $rateLimiters = $this->limiterProvider->findByRequest($request);
 
        if (empty($rateLimiters)) {
            return;
        }
 
        $identifier = $this->identifierProvider->getIdentifier($request);
 
        if (null === $identifier) {
            return;
        }
 
        $failedLimiter = null;
        foreach ($rateLimiters as $rateLimiter) {
            $limiter = $rateLimiter->getLimiter($identifier)->consume(1);
            if (false === $limiter->isAccepted()) {
                $failedLimiter = $limiter;
            }
        }
 
        if (null === $failedLimiter) {
            return;
        }
 
        throw new RateLimitExceededException($failedLimiter);
    }
}
 

Je n'utilise plus la méthode ensureAccepted() du RateLimiter, à la place j'utilise la méthode isAccepted() car je veux consommer un token pour tous les RateLimiter qui supportent ma requête et je lève l'exception qui est levée par la méthode ensureAccepted() plus tard.
Il y a un problème ici, si plusieurs RateLimiter rejettent la requête, j'utilise les données du dernier pour créer l'exception et ajouter les headers de ce RateLimiter à la réponse. Cela me convient pour le moment, à voir avec le temps si ça pose problème sur le projet.

Avec cette solution, je peux créer autant de RateLimiter que besoin assez facilement. Maintenant vous n'avez plus d'excuses pour laisser un utilisateur surcharger votre infrastructure ou exposer la liste de vos clients avec une attaque par énumération.

PS: Si vous cherchez une protection contre les attaques DDOS, ce code n'est pas utile. Il démarre une application Symfony qui nécessite un process PHP. Il faut ajouter des protections bien avant.

Il y a un commentaire.

Ecrit par extrablind le 9 mai 2023

Bonjour Merci pour cet article ! Erreur sur la ligne : $limiter->ensureAccepted()); => $limiter->ensureAccepted();

Ajouter un commentaire