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/*
|
- [ ] Middleware JWT pour sécuriser les routes /api/*
|
||||||
|
|
||||||
### Sécurité & Performance
|
### 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
|
- [ ] CSRF token sur tous les formulaires POST
|
||||||
- [ ] Cache Meilisearch : invalider quand un événement est modifié
|
- [ ] Cache Meilisearch : invalider quand un événement est modifié
|
||||||
- [ ] Optimiser les requêtes N+1 (stats tab, billets par catégorie)
|
- [ ] Optimiser les requêtes N+1 (stats tab, billets par catégorie)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"symfony/process": "8.0.*",
|
"symfony/process": "8.0.*",
|
||||||
"symfony/property-access": "8.0.*",
|
"symfony/property-access": "8.0.*",
|
||||||
"symfony/property-info": "8.0.*",
|
"symfony/property-info": "8.0.*",
|
||||||
|
"symfony/rate-limiter": "8.0.*",
|
||||||
"symfony/redis-messenger": "8.0.*",
|
"symfony/redis-messenger": "8.0.*",
|
||||||
"symfony/runtime": "8.0.*",
|
"symfony/runtime": "8.0.*",
|
||||||
"symfony/security-bundle": "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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "8e473cfa810b10e0138f0a9823cee8de",
|
"content-hash": "4d439a9450dafd68006714d26ab7fa0b",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "async-aws/core",
|
"name": "async-aws/core",
|
||||||
@@ -8249,6 +8249,80 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-02-13T12:14:15+00:00"
|
"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",
|
"name": "symfony/redis-messenger",
|
||||||
"version": "v8.0.6",
|
"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:
|
main:
|
||||||
lazy: true
|
lazy: true
|
||||||
provider: app_user_provider
|
provider: app_user_provider
|
||||||
|
login_throttling:
|
||||||
|
max_attempts: 5
|
||||||
|
interval: '15 minutes'
|
||||||
form_login:
|
form_login:
|
||||||
login_path: app_login
|
login_path: app_login
|
||||||
check_path: app_login
|
check_path: app_login
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ services:
|
|||||||
App\:
|
App\:
|
||||||
resource: '../src/'
|
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
|
# add more service definitions when explicit configuration is needed
|
||||||
# please note that last definitions always *replace* previous ones
|
# 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