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) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-22 20:01:01 +01:00
parent 207e985821
commit 36456e8dfe
8 changed files with 227 additions and 2 deletions

View File

@@ -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)

View File

@@ -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.*",

76
composer.lock generated
View File

@@ -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",

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,61 @@
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;
class RateLimiterSubscriber implements EventSubscriberInterface
{
private const ROUTE_LIMITER_MAP = [
'app_order_create' => 'order_create',
'app_invitation_respond' => 'invitation_respond',
'app_invitation_register' => 'invitation_respond',
'app_event_contact' => 'contact_form',
'app_contact' => 'contact_form',
];
/**
* @param array<string, RateLimiterFactory> $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));
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Tests\EventSubscriber;
use App\EventSubscriber\RateLimiterSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
class RateLimiterSubscriberTest extends TestCase
{
public function testSubscribedEvents(): void
{
$events = RateLimiterSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::REQUEST, $events);
}
public function testIgnoresNonMappedRoutes(): void
{
$subscriber = new RateLimiterSubscriber([]);
$request = new Request();
$request->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());
}
}