From ee622f61fd4ea680ee006ee848517ca1da39483b Mon Sep 17 00:00:00 2001 From: lukmzig Date: Thu, 28 May 2026 16:00:58 +0200 Subject: [PATCH 1/2] add missing general rate limiter for API --- config/event_subscribers.yaml | 8 +- config/prepend/rate_limiter.yaml | 4 + .../04_Rate_Limiting.md | 88 ++++++ src/DependencyInjection/Configuration.php | 17 + .../PimcoreStudioBackendExtension.php | 4 + src/EventSubscriber/RateLimitSubscriber.php | 102 ++++++ .../RateLimitSubscriberTest.php | 292 ++++++++++++++++++ 7 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 doc/02_Installation_and_Configuration/04_Rate_Limiting.md create mode 100644 src/EventSubscriber/RateLimitSubscriber.php create mode 100644 tests/Unit/EventSubscriber/RateLimitSubscriberTest.php diff --git a/config/event_subscribers.yaml b/config/event_subscribers.yaml index cbfc3dcd0..45d189b72 100644 --- a/config/event_subscribers.yaml +++ b/config/event_subscribers.yaml @@ -15,4 +15,10 @@ services: Pimcore\Bundle\StudioBackendBundle\EventSubscriber\ApiExceptionSubscriber: tags: [ 'kernel.event_subscriber' ] - arguments: ["%kernel.environment%", '%pimcore_studio_backend.url_prefix%'] \ No newline at end of file + arguments: ["%kernel.environment%", '%pimcore_studio_backend.url_prefix%'] + + Pimcore\Bundle\StudioBackendBundle\EventSubscriber\RateLimitSubscriber: + tags: [ 'kernel.event_subscriber' ] + arguments: + $urlPrefix: '%pimcore_studio_backend.url_prefix%' + $studioApiGeneralLimiter: '@limiter.studio_api_general' \ No newline at end of file diff --git a/config/prepend/rate_limiter.yaml b/config/prepend/rate_limiter.yaml index d655e6fa8..0b8cefe69 100644 --- a/config/prepend/rate_limiter.yaml +++ b/config/prepend/rate_limiter.yaml @@ -1,5 +1,9 @@ framework: rate_limiter: + studio_api_general: + policy: 'sliding_window' + limit: 500 + interval: '1 minute' reset_password: policy: 'fixed_window' limit: 5 diff --git a/doc/02_Installation_and_Configuration/04_Rate_Limiting.md b/doc/02_Installation_and_Configuration/04_Rate_Limiting.md new file mode 100644 index 000000000..5fbcfb597 --- /dev/null +++ b/doc/02_Installation_and_Configuration/04_Rate_Limiting.md @@ -0,0 +1,88 @@ +# Rate Limiting + +The Studio Backend Bundle includes built-in rate limiting for all API endpoints to protect against abuse and ensure fair usage. + +## Overview + +Rate limiting is **enabled by default** for all Studio API endpoints. Every request to a `/pimcore-studio/api/` path is tracked using a sliding window algorithm, keyed by the client's IP address. When the limit is exceeded, the API responds with HTTP `429 Too Many Requests`. + +Additionally, specific public endpoints have their own stricter limits that apply on top of the general one. + +## Default Limits + +| Limiter | Scope | Policy | Limit | Interval | +|---------|-------|--------|-------|----------| +| `studio_api_general` | All Studio API endpoints | Sliding window | 500 requests | 1 minute | +| `reset_password` | `POST /user/reset-password` | Fixed window | 5 requests | 5 minutes | +| `setting_admin_thumbnail` | `GET /setting/admin/thumbnail` | Fixed window | 60 requests | 1 minute | + +The per-endpoint limits are layered on top of the general limit. For example, the `reset_password` endpoint is subject to both its own 5/5min limit and the general 500/min limit. + +## Response Headers + +Every Studio API response includes rate limit information in the following headers: + +| Header | Description | +|--------|-------------| +| `X-RateLimit-Limit` | Maximum number of requests allowed in the current window | +| `X-RateLimit-Remaining` | Number of requests remaining before the limit is reached | +| `X-RateLimit-Reset` | Unix timestamp indicating when the current window resets | + +These headers are also included on `429` responses, so clients can determine when to retry. + +## Configuration + +### Disabling Rate Limiting + +To disable the general rate limiter entirely, add the following to your project configuration: + +```yaml +# config/config.yaml +pimcore_studio_backend: + rate_limiting: + enabled: false +``` + +### Customizing Limits + +The rate limiters use Symfony's [Rate Limiter component](https://symfony.com/doc/current/rate_limiter.html). You can override the defaults by redefining the limiter in your project's framework configuration: + +```yaml +# config/packages/framework.yaml +framework: + rate_limiter: + studio_api_general: + policy: 'sliding_window' + limit: 1000 + interval: '1 minute' +``` + +### Storage Backend + +By default, Symfony stores rate limiter state in the `cache.rate_limiter` cache pool. In a **multi-server deployment**, you must use a shared cache backend (e.g., Redis or Memcached) to ensure rate limits are enforced consistently across all servers: + +```yaml +# config/packages/framework.yaml +framework: + cache: + pools: + cache.rate_limiter: + adapter: cache.adapter.redis +``` + +Without shared storage, each server tracks limits independently, effectively multiplying the allowed rate by the number of servers. + +## Deployment Considerations + +### Reverse Proxies and Load Balancers + +Rate limiting is keyed by the client's IP address, obtained via Symfony's `Request::getClientIp()`. If your application runs behind a reverse proxy or load balancer, you **must** configure [trusted proxies](https://symfony.com/doc/current/deployment/proxies.html) so that the real client IP is used instead of the proxy's IP: + +```yaml +# config/packages/framework.yaml +framework: + trusted_proxies: '127.0.0.1,REMOTE_ADDR' + trusted_headers: ['x-forwarded-for', 'x-forwarded-proto'] +``` + +Without this configuration, all requests appear to come from the proxy's IP address, causing all users to share a single rate limit bucket. \ No newline at end of file diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a1ebb149c..18743225b 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -87,6 +87,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addGdprDataExtractorNode($rootNode); $this->addAdminSettingsNode($rootNode); $this->addMcpNode($rootNode); + $this->addRateLimitingNode($rootNode); $this->addTranslation($rootNode); $rootNode->append($this->addTwigSandboxNode()); @@ -809,6 +810,22 @@ private function addAdminSettingsNode(ArrayNodeDefinition $node): void ->end(); } + private function addRateLimitingNode(ArrayNodeDefinition $node): void + { + $node->children() + ->arrayNode('rate_limiting') + ->addDefaultsIfNotSet() + ->info('General rate limiting configuration for all Studio API endpoints.') + ->children() + ->booleanNode('enabled') + ->info('Enable or disable general rate limiting for all Studio API endpoints.') + ->defaultTrue() + ->end() + ->end() + ->end() + ->end(); + } + private function addTranslation(ArrayNodeDefinition $node): void { $node diff --git a/src/DependencyInjection/PimcoreStudioBackendExtension.php b/src/DependencyInjection/PimcoreStudioBackendExtension.php index 5f59eb45c..615928c17 100644 --- a/src/DependencyInjection/PimcoreStudioBackendExtension.php +++ b/src/DependencyInjection/PimcoreStudioBackendExtension.php @@ -22,6 +22,7 @@ use Pimcore\Bundle\StudioBackendBundle\Document\Service\DocumentTypeServiceInterface; use Pimcore\Bundle\StudioBackendBundle\Element\Service\ElementDeleteServiceInterface; use Pimcore\Bundle\StudioBackendBundle\EventSubscriber\CorsSubscriber; +use Pimcore\Bundle\StudioBackendBundle\EventSubscriber\RateLimitSubscriber; use Pimcore\Bundle\StudioBackendBundle\Exception\InvalidHostException; use Pimcore\Bundle\StudioBackendBundle\Exception\InvalidUrlPrefixException; use Pimcore\Bundle\StudioBackendBundle\Export\Service\CsvExportService; @@ -100,6 +101,9 @@ public function load(array $configs, ContainerBuilder $container): void $definition = $container->getDefinition(CorsSubscriber::class); $definition->setArgument('$allowedHosts', $config['allowed_hosts_for_cors']); + $definition = $container->getDefinition(RateLimitSubscriber::class); + $definition->setArgument('$enabled', $config['rate_limiting']['enabled']); + $definition = $container->getDefinition(DownloadServiceInterface::class); $definition->setArgument('$defaultFormats', $config['asset_default_formats']); diff --git a/src/EventSubscriber/RateLimitSubscriber.php b/src/EventSubscriber/RateLimitSubscriber.php new file mode 100644 index 000000000..49e94745f --- /dev/null +++ b/src/EventSubscriber/RateLimitSubscriber.php @@ -0,0 +1,102 @@ + ['onKernelRequest', 200], + KernelEvents::RESPONSE => ['onKernelResponse', -10], + ]; + } + + /** + * @throws RateLimitException + */ + public function onKernelRequest(RequestEvent $event): void + { + if (!$this->enabled || !$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + + if ( + $request->getMethod() === 'OPTIONS' || + !$this->isStudioBackendPath($request->getPathInfo(), $this->urlPrefix) + ) { + return; + } + + $key = $request->getClientIp() ?? 'unknown'; + $limiter = $this->studioApiGeneralLimiter->create($key); + $rateLimit = $limiter->consume(); + + $request->attributes->set(self::RATE_LIMIT_ATTRIBUTE, $rateLimit); + + if (!$rateLimit->isAccepted()) { + throw new RateLimitException(); + } + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$this->enabled || !$event->isMainRequest()) { + return; + } + + $request = $event->getRequest(); + $rateLimit = $request->attributes->get(self::RATE_LIMIT_ATTRIBUTE); + + if (!$rateLimit instanceof RateLimit) { + return; + } + + $response = $event->getResponse(); + $response->headers->set('X-RateLimit-Limit', (string) $rateLimit->getLimit()); + $response->headers->set( + 'X-RateLimit-Remaining', + (string) $rateLimit->getRemainingTokens() + ); + $response->headers->set( + 'X-RateLimit-Reset', + (string) $rateLimit->getRetryAfter()->getTimestamp() + ); + } +} diff --git a/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php b/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php new file mode 100644 index 000000000..c640b0e8d --- /dev/null +++ b/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php @@ -0,0 +1,292 @@ +assertArrayHasKey(KernelEvents::REQUEST, $events); + $this->assertArrayHasKey(KernelEvents::RESPONSE, $events); + $this->assertSame(['onKernelRequest', 200], $events[KernelEvents::REQUEST]); + $this->assertSame(['onKernelResponse', -10], $events[KernelEvents::RESPONSE]); + } + + /** + * @throws Exception + */ + public function testRequestIsRateLimitedOnStudioPath(): void + { + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + + $subscriber->onKernelRequest($event); + + $rateLimit = $event->getRequest()->attributes->get('_studio_rate_limit'); + $this->assertInstanceOf(RateLimit::class, $rateLimit); + $this->assertSame(499, $rateLimit->getRemainingTokens()); + } + + /** + * @throws Exception + */ + public function testRequestThrowsRateLimitExceptionWhenExceeded(): void + { + $subscriber = $this->createSubscriber(accepted: false, remaining: 0, limit: 500); + $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + + $this->expectException(RateLimitException::class); + $subscriber->onKernelRequest($event); + } + + /** + * @throws Exception + */ + public function testRateLimitAttributeIsSetBeforeExceptionIsThrown(): void + { + $subscriber = $this->createSubscriber(accepted: false, remaining: 0, limit: 500); + $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + + try { + $subscriber->onKernelRequest($event); + } catch (RateLimitException) { + // expected + } + + $rateLimit = $event->getRequest()->attributes->get('_studio_rate_limit'); + $this->assertInstanceOf(RateLimit::class, $rateLimit); + $this->assertSame(0, $rateLimit->getRemainingTokens()); + } + + /** + * @throws Exception + */ + public function testNonStudioPathIsIgnored(): void + { + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $event = $this->createRequestEvent('/admin/some-route'); + + $subscriber->onKernelRequest($event); + + $this->assertNull($event->getRequest()->attributes->get('_studio_rate_limit')); + } + + /** + * @throws Exception + */ + public function testOptionsRequestIsIgnored(): void + { + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $event = $this->createRequestEvent('/pimcore-studio/api/assets/1', 'OPTIONS'); + + $subscriber->onKernelRequest($event); + + $this->assertNull($event->getRequest()->attributes->get('_studio_rate_limit')); + } + + /** + * @throws Exception + */ + public function testDisabledSubscriberSkipsRequest(): void + { + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500, enabled: false); + $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + + $subscriber->onKernelRequest($event); + + $this->assertNull($event->getRequest()->attributes->get('_studio_rate_limit')); + } + + /** + * @throws Exception + */ + public function testSubRequestIsIgnored(): void + { + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $event = $this->createRequestEvent('/pimcore-studio/api/assets/1', 'GET', false); + + $subscriber->onKernelRequest($event); + + $this->assertNull($event->getRequest()->attributes->get('_studio_rate_limit')); + } + + /** + * @throws Exception + */ + public function testResponseHeadersAreSet(): void + { + $retryAfter = new DateTimeImmutable('2026-01-01 00:00:00'); + $rateLimit = new RateLimit(499, $retryAfter, true, 500); + + $request = Request::create('/pimcore-studio/api/assets/1'); + $request->attributes->set('_studio_rate_limit', $rateLimit); + + $response = new Response(); + $event = $this->createResponseEvent($request, $response); + + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber->onKernelResponse($event); + + $this->assertSame('500', $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('499', $response->headers->get('X-RateLimit-Remaining')); + $this->assertSame((string) $retryAfter->getTimestamp(), $response->headers->get('X-RateLimit-Reset')); + } + + /** + * @throws Exception + */ + public function testResponseHeadersOnExceededRequest(): void + { + $retryAfter = new DateTimeImmutable('2026-01-01 00:01:00'); + $rateLimit = new RateLimit(0, $retryAfter, false, 500); + + $request = Request::create('/pimcore-studio/api/assets/1'); + $request->attributes->set('_studio_rate_limit', $rateLimit); + + $response = new Response('', 429); + $event = $this->createResponseEvent($request, $response); + + $subscriber = $this->createSubscriber(accepted: false, remaining: 0, limit: 500); + $subscriber->onKernelResponse($event); + + $this->assertSame('500', $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $this->assertSame((string) $retryAfter->getTimestamp(), $response->headers->get('X-RateLimit-Reset')); + } + + /** + * @throws Exception + */ + public function testResponseWithoutRateLimitAttributeIsNotModified(): void + { + $request = Request::create('/pimcore-studio/api/assets/1'); + $response = new Response(); + $event = $this->createResponseEvent($request, $response); + + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber->onKernelResponse($event); + + $this->assertFalse($response->headers->has('X-RateLimit-Limit')); + $this->assertFalse($response->headers->has('X-RateLimit-Remaining')); + $this->assertFalse($response->headers->has('X-RateLimit-Reset')); + } + + /** + * @throws Exception + */ + public function testDisabledSubscriberSkipsResponse(): void + { + $retryAfter = new DateTimeImmutable('2026-01-01 00:00:00'); + $rateLimit = new RateLimit(499, $retryAfter, true, 500); + + $request = Request::create('/pimcore-studio/api/assets/1'); + $request->attributes->set('_studio_rate_limit', $rateLimit); + + $response = new Response(); + $event = $this->createResponseEvent($request, $response); + + $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500, enabled: false); + $subscriber->onKernelResponse($event); + + $this->assertFalse($response->headers->has('X-RateLimit-Limit')); + } + + /** + * @throws Exception + */ + private function createSubscriber( + bool $accepted, + int $remaining, + int $limit, + bool $enabled = true, + ): RateLimitSubscriber { + $rateLimit = new RateLimit( + $remaining, + new DateTimeImmutable('+1 minute'), + $accepted, + $limit, + ); + + $limiter = $this->makeEmpty(LimiterInterface::class, [ + 'consume' => $rateLimit, + ]); + + $factory = $this->makeEmpty(RateLimiterFactoryInterface::class, [ + 'create' => $limiter, + ]); + + return new RateLimitSubscriber( + self::URL_PREFIX, + $factory, + $enabled, + ); + } + + /** + * @throws Exception + */ + private function createRequestEvent( + string $path, + string $method = 'GET', + bool $isMainRequest = true, + ): RequestEvent { + $request = Request::create($path, $method); + $kernel = $this->makeEmpty(HttpKernelInterface::class); + + return new RequestEvent( + $kernel, + $request, + $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST, + ); + } + + /** + * @throws Exception + */ + private function createResponseEvent( + Request $request, + Response $response, + ): ResponseEvent { + $kernel = $this->makeEmpty(HttpKernelInterface::class); + + return new ResponseEvent( + $kernel, + $request, + HttpKernelInterface::MAIN_REQUEST, + $response, + ); + } +} From fd1d27d8fbb58cc9693ad786e331dfa6cb047fff Mon Sep 17 00:00:00 2001 From: lukmzig Date: Thu, 28 May 2026 16:16:19 +0200 Subject: [PATCH 2/2] fix lowest versions --- src/EventSubscriber/RateLimitSubscriber.php | 4 +- .../RateLimitSubscriberTest.php | 133 +++++++++--------- 2 files changed, 65 insertions(+), 72 deletions(-) diff --git a/src/EventSubscriber/RateLimitSubscriber.php b/src/EventSubscriber/RateLimitSubscriber.php index 49e94745f..551b16d19 100644 --- a/src/EventSubscriber/RateLimitSubscriber.php +++ b/src/EventSubscriber/RateLimitSubscriber.php @@ -20,7 +20,7 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\RateLimiter\RateLimit; -use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; +use Symfony\Component\RateLimiter\RateLimiterFactory; /** * @internal @@ -33,7 +33,7 @@ final class RateLimitSubscriber implements EventSubscriberInterface public function __construct( private readonly string $urlPrefix, - private readonly RateLimiterFactoryInterface $studioApiGeneralLimiter, + private readonly RateLimiterFactory $studioApiGeneralLimiter, private readonly bool $enabled = true, ) { } diff --git a/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php b/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php index c640b0e8d..d72ebba17 100644 --- a/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php +++ b/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php @@ -24,9 +24,9 @@ use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\RateLimiter\LimiterInterface; use Symfony\Component\RateLimiter\RateLimit; -use Symfony\Component\RateLimiter\RateLimiterFactoryInterface; +use Symfony\Component\RateLimiter\RateLimiterFactory; +use Symfony\Component\RateLimiter\Storage\InMemoryStorage; /** * @internal @@ -50,7 +50,7 @@ public function testGetSubscribedEvents(): void */ public function testRequestIsRateLimitedOnStudioPath(): void { - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber = $this->createSubscriber(); $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); $subscriber->onKernelRequest($event); @@ -65,11 +65,13 @@ public function testRequestIsRateLimitedOnStudioPath(): void */ public function testRequestThrowsRateLimitExceptionWhenExceeded(): void { - $subscriber = $this->createSubscriber(accepted: false, remaining: 0, limit: 500); - $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber = $this->createSubscriber(limit: 1); + $firstEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($firstEvent); $this->expectException(RateLimitException::class); - $subscriber->onKernelRequest($event); + $secondEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($secondEvent); } /** @@ -77,16 +79,19 @@ public function testRequestThrowsRateLimitExceptionWhenExceeded(): void */ public function testRateLimitAttributeIsSetBeforeExceptionIsThrown(): void { - $subscriber = $this->createSubscriber(accepted: false, remaining: 0, limit: 500); - $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber = $this->createSubscriber(limit: 1); + $firstEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($firstEvent); + + $secondEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); try { - $subscriber->onKernelRequest($event); + $subscriber->onKernelRequest($secondEvent); } catch (RateLimitException) { // expected } - $rateLimit = $event->getRequest()->attributes->get('_studio_rate_limit'); + $rateLimit = $secondEvent->getRequest()->attributes->get('_studio_rate_limit'); $this->assertInstanceOf(RateLimit::class, $rateLimit); $this->assertSame(0, $rateLimit->getRemainingTokens()); } @@ -96,7 +101,7 @@ public function testRateLimitAttributeIsSetBeforeExceptionIsThrown(): void */ public function testNonStudioPathIsIgnored(): void { - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber = $this->createSubscriber(); $event = $this->createRequestEvent('/admin/some-route'); $subscriber->onKernelRequest($event); @@ -109,7 +114,7 @@ public function testNonStudioPathIsIgnored(): void */ public function testOptionsRequestIsIgnored(): void { - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber = $this->createSubscriber(); $event = $this->createRequestEvent('/pimcore-studio/api/assets/1', 'OPTIONS'); $subscriber->onKernelRequest($event); @@ -122,7 +127,7 @@ public function testOptionsRequestIsIgnored(): void */ public function testDisabledSubscriberSkipsRequest(): void { - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500, enabled: false); + $subscriber = $this->createSubscriber(enabled: false); $event = $this->createRequestEvent('/pimcore-studio/api/assets/1'); $subscriber->onKernelRequest($event); @@ -135,7 +140,7 @@ public function testDisabledSubscriberSkipsRequest(): void */ public function testSubRequestIsIgnored(): void { - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber = $this->createSubscriber(); $event = $this->createRequestEvent('/pimcore-studio/api/assets/1', 'GET', false); $subscriber->onKernelRequest($event); @@ -148,21 +153,17 @@ public function testSubRequestIsIgnored(): void */ public function testResponseHeadersAreSet(): void { - $retryAfter = new DateTimeImmutable('2026-01-01 00:00:00'); - $rateLimit = new RateLimit(499, $retryAfter, true, 500); - - $request = Request::create('/pimcore-studio/api/assets/1'); - $request->attributes->set('_studio_rate_limit', $rateLimit); + $subscriber = $this->createSubscriber(); + $requestEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($requestEvent); $response = new Response(); - $event = $this->createResponseEvent($request, $response); - - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); - $subscriber->onKernelResponse($event); + $responseEvent = $this->createResponseEvent($requestEvent->getRequest(), $response); + $subscriber->onKernelResponse($responseEvent); $this->assertSame('500', $response->headers->get('X-RateLimit-Limit')); $this->assertSame('499', $response->headers->get('X-RateLimit-Remaining')); - $this->assertSame((string) $retryAfter->getTimestamp(), $response->headers->get('X-RateLimit-Reset')); + $this->assertNotNull($response->headers->get('X-RateLimit-Reset')); } /** @@ -170,21 +171,25 @@ public function testResponseHeadersAreSet(): void */ public function testResponseHeadersOnExceededRequest(): void { - $retryAfter = new DateTimeImmutable('2026-01-01 00:01:00'); - $rateLimit = new RateLimit(0, $retryAfter, false, 500); + $subscriber = $this->createSubscriber(limit: 1); + $firstEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($firstEvent); - $request = Request::create('/pimcore-studio/api/assets/1'); - $request->attributes->set('_studio_rate_limit', $rateLimit); + $secondEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); - $response = new Response('', 429); - $event = $this->createResponseEvent($request, $response); + try { + $subscriber->onKernelRequest($secondEvent); + } catch (RateLimitException) { + // expected + } - $subscriber = $this->createSubscriber(accepted: false, remaining: 0, limit: 500); - $subscriber->onKernelResponse($event); + $response = new Response('', 429); + $responseEvent = $this->createResponseEvent($secondEvent->getRequest(), $response); + $subscriber->onKernelResponse($responseEvent); - $this->assertSame('500', $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('1', $response->headers->get('X-RateLimit-Limit')); $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); - $this->assertSame((string) $retryAfter->getTimestamp(), $response->headers->get('X-RateLimit-Reset')); + $this->assertNotNull($response->headers->get('X-RateLimit-Reset')); } /** @@ -196,7 +201,7 @@ public function testResponseWithoutRateLimitAttributeIsNotModified(): void $response = new Response(); $event = $this->createResponseEvent($request, $response); - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500); + $subscriber = $this->createSubscriber(); $subscriber->onKernelResponse($event); $this->assertFalse($response->headers->has('X-RateLimit-Limit')); @@ -209,45 +214,32 @@ public function testResponseWithoutRateLimitAttributeIsNotModified(): void */ public function testDisabledSubscriberSkipsResponse(): void { - $retryAfter = new DateTimeImmutable('2026-01-01 00:00:00'); - $rateLimit = new RateLimit(499, $retryAfter, true, 500); + $subscriber = $this->createSubscriber(enabled: false); $request = Request::create('/pimcore-studio/api/assets/1'); - $request->attributes->set('_studio_rate_limit', $rateLimit); + $request->attributes->set('_studio_rate_limit', new RateLimit(499, new DateTimeImmutable(), true, 500)); $response = new Response(); $event = $this->createResponseEvent($request, $response); - - $subscriber = $this->createSubscriber(accepted: true, remaining: 499, limit: 500, enabled: false); $subscriber->onKernelResponse($event); $this->assertFalse($response->headers->has('X-RateLimit-Limit')); } - /** - * @throws Exception - */ private function createSubscriber( - bool $accepted, - int $remaining, - int $limit, + int $limit = 500, bool $enabled = true, ): RateLimitSubscriber { - $rateLimit = new RateLimit( - $remaining, - new DateTimeImmutable('+1 minute'), - $accepted, - $limit, + $factory = new RateLimiterFactory( + [ + 'id' => 'test_studio_api', + 'policy' => 'sliding_window', + 'limit' => $limit, + 'interval' => '60 seconds', + ], + new InMemoryStorage(), ); - $limiter = $this->makeEmpty(LimiterInterface::class, [ - 'consume' => $rateLimit, - ]); - - $factory = $this->makeEmpty(RateLimiterFactoryInterface::class, [ - 'create' => $limiter, - ]); - return new RateLimitSubscriber( self::URL_PREFIX, $factory, @@ -255,38 +247,39 @@ private function createSubscriber( ); } - /** - * @throws Exception - */ private function createRequestEvent( string $path, string $method = 'GET', bool $isMainRequest = true, ): RequestEvent { $request = Request::create($path, $method); - $kernel = $this->makeEmpty(HttpKernelInterface::class); return new RequestEvent( - $kernel, + $this->createKernelStub(), $request, $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST, ); } - /** - * @throws Exception - */ private function createResponseEvent( Request $request, Response $response, ): ResponseEvent { - $kernel = $this->makeEmpty(HttpKernelInterface::class); - return new ResponseEvent( - $kernel, + $this->createKernelStub(), $request, HttpKernelInterface::MAIN_REQUEST, $response, ); } + + private function createKernelStub(): HttpKernelInterface + { + return new class implements HttpKernelInterface { + public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response + { + return new Response(); + } + }; + } }