From 36456e8dfe0470a8d169c67f67c0cd77f8b8a8c2 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Sun, 22 Mar 2026 20:01:01 +0100 Subject: [PATCH] Add rate limiting on login, order, invitation, contact routes - Login: 5 attempts / 15 min (Symfony login_throttling) - Order create: 10 / 5 min (sliding window) - Invitation respond/register: 5 / 15 min - Contact form: 3 / 10 min - RateLimiterSubscriber with route-to-limiter mapping - Returns 429 when rate limited Co-Authored-By: Claude Opus 4.6 (1M context) --- TASK_CHECKUP.md | 2 +- composer.json | 1 + composer.lock | 76 ++++++++++++++++++- config/packages/rate_limiter.yaml | 14 ++++ config/packages/security.yaml | 3 + config/services.yaml | 7 ++ src/EventSubscriber/RateLimiterSubscriber.php | 61 +++++++++++++++ .../RateLimiterSubscriberTest.php | 65 ++++++++++++++++ 8 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 config/packages/rate_limiter.yaml create mode 100644 src/EventSubscriber/RateLimiterSubscriber.php create mode 100644 tests/EventSubscriber/RateLimiterSubscriberTest.php diff --git a/TASK_CHECKUP.md b/TASK_CHECKUP.md index ad363b2..60d822d 100644 --- a/TASK_CHECKUP.md +++ b/TASK_CHECKUP.md @@ -44,7 +44,7 @@ - [ ] Middleware JWT pour sécuriser les routes /api/* ### Sécurité & Performance -- [ ] Rate limiting sur les routes sensibles (login, commande, invitation) +- [x] Rate limiting sur les routes sensibles (login 5/15min, commande 10/5min, invitation 5/15min, contact 3/10min) - [ ] CSRF token sur tous les formulaires POST - [ ] Cache Meilisearch : invalider quand un événement est modifié - [ ] Optimiser les requêtes N+1 (stats tab, billets par catégorie) diff --git a/composer.json b/composer.json index 81b56b0..50de94c 100644 --- a/composer.json +++ b/composer.json @@ -41,6 +41,7 @@ "symfony/process": "8.0.*", "symfony/property-access": "8.0.*", "symfony/property-info": "8.0.*", + "symfony/rate-limiter": "8.0.*", "symfony/redis-messenger": "8.0.*", "symfony/runtime": "8.0.*", "symfony/security-bundle": "8.0.*", diff --git a/composer.lock b/composer.lock index bbd00bf..daa9faf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8e473cfa810b10e0138f0a9823cee8de", + "content-hash": "4d439a9450dafd68006714d26ab7fa0b", "packages": [ { "name": "async-aws/core", @@ -8249,6 +8249,80 @@ ], "time": "2026-02-13T12:14:15+00:00" }, + { + "name": "symfony/rate-limiter", + "version": "v8.0.7", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "1f8159c50b55e78810f5a8f60889d0b6b3a11deb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/1f8159c50b55e78810f5a8f60889d0b6b3a11deb", + "reference": "1f8159c50b55e78810f5a8f60889d0b6b3a11deb", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/options-resolver": "^7.4|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v8.0.7" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-04T13:55:34+00:00" + }, { "name": "symfony/redis-messenger", "version": "v8.0.6", diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml new file mode 100644 index 0000000..ae7f1d9 --- /dev/null +++ b/config/packages/rate_limiter.yaml @@ -0,0 +1,14 @@ +framework: + rate_limiter: + order_create: + policy: 'sliding_window' + limit: 10 + interval: '5 minutes' + invitation_respond: + policy: 'sliding_window' + limit: 5 + interval: '15 minutes' + contact_form: + policy: 'sliding_window' + limit: 3 + interval: '10 minutes' diff --git a/config/packages/security.yaml b/config/packages/security.yaml index ead25ba..0197dab 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -15,6 +15,9 @@ security: main: lazy: true provider: app_user_provider + login_throttling: + max_attempts: 5 + interval: '15 minutes' form_login: login_path: app_login check_path: app_login diff --git a/config/services.yaml b/config/services.yaml index 1d11cc2..86e247d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -20,6 +20,13 @@ services: App\: resource: '../src/' + App\EventSubscriber\RateLimiterSubscriber: + arguments: + $limiters: + order_create: '@limiter.order_create' + invitation_respond: '@limiter.invitation_respond' + contact_form: '@limiter.contact_form' + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/src/EventSubscriber/RateLimiterSubscriber.php b/src/EventSubscriber/RateLimiterSubscriber.php new file mode 100644 index 0000000..c239f5b --- /dev/null +++ b/src/EventSubscriber/RateLimiterSubscriber.php @@ -0,0 +1,61 @@ + 'order_create', + 'app_invitation_respond' => 'invitation_respond', + 'app_invitation_register' => 'invitation_respond', + 'app_event_contact' => 'contact_form', + 'app_contact' => 'contact_form', + ]; + + /** + * @param array $limiters + */ + public function __construct( + private readonly array $limiters, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::REQUEST => ['onKernelRequest', 10], + ]; + } + + public function onKernelRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + $route = $request->attributes->getString('_route'); + + if (!isset(self::ROUTE_LIMITER_MAP[$route])) { + return; + } + + $limiterName = self::ROUTE_LIMITER_MAP[$route]; + if (!isset($this->limiters[$limiterName])) { + return; + } + + $limiter = $this->limiters[$limiterName]->create($request->getClientIp() ?? 'unknown'); + $limit = $limiter->consume(); + + if (!$limit->isAccepted()) { + $event->setResponse(new Response('Trop de requetes. Reessayez plus tard.', 429)); + } + } +} diff --git a/tests/EventSubscriber/RateLimiterSubscriberTest.php b/tests/EventSubscriber/RateLimiterSubscriberTest.php new file mode 100644 index 0000000..4732b0e --- /dev/null +++ b/tests/EventSubscriber/RateLimiterSubscriberTest.php @@ -0,0 +1,65 @@ +attributes->set('_route', 'app_home'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testIgnoresSubRequests(): void + { + $subscriber = new RateLimiterSubscriber([]); + + $request = new Request(); + $request->attributes->set('_route', 'app_order_create'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST); + + $subscriber->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } + + public function testIgnoresMappedRouteWithMissingLimiter(): void + { + $subscriber = new RateLimiterSubscriber([]); + + $request = new Request(); + $request->attributes->set('_route', 'app_order_create'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $event = new RequestEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST); + + $subscriber->onKernelRequest($event); + + self::assertNull($event->getResponse()); + } +}