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:
@@ -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)
|
||||
|
||||
@@ -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
76
composer.lock
generated
@@ -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",
|
||||
|
||||
14
config/packages/rate_limiter.yaml
Normal file
14
config/packages/rate_limiter.yaml
Normal 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'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
61
src/EventSubscriber/RateLimiterSubscriber.php
Normal file
61
src/EventSubscriber/RateLimiterSubscriber.php
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
65
tests/EventSubscriber/RateLimiterSubscriberTest.php
Normal file
65
tests/EventSubscriber/RateLimiterSubscriberTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user