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..551b16d19 --- /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..d72ebba17 --- /dev/null +++ b/tests/Unit/EventSubscriber/RateLimitSubscriberTest.php @@ -0,0 +1,285 @@ +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(); + $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(limit: 1); + $firstEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($firstEvent); + + $this->expectException(RateLimitException::class); + $secondEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($secondEvent); + } + + /** + * @throws Exception + */ + public function testRateLimitAttributeIsSetBeforeExceptionIsThrown(): void + { + $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($secondEvent); + } catch (RateLimitException) { + // expected + } + + $rateLimit = $secondEvent->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(); + $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(); + $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(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(); + $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 + { + $subscriber = $this->createSubscriber(); + $requestEvent = $this->createRequestEvent('/pimcore-studio/api/assets/1'); + $subscriber->onKernelRequest($requestEvent); + + $response = new Response(); + $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->assertNotNull($response->headers->get('X-RateLimit-Reset')); + } + + /** + * @throws Exception + */ + public function testResponseHeadersOnExceededRequest(): void + { + $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($secondEvent); + } catch (RateLimitException) { + // expected + } + + $response = new Response('', 429); + $responseEvent = $this->createResponseEvent($secondEvent->getRequest(), $response); + $subscriber->onKernelResponse($responseEvent); + + $this->assertSame('1', $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $this->assertNotNull($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(); + $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 + { + $subscriber = $this->createSubscriber(enabled: false); + + $request = Request::create('/pimcore-studio/api/assets/1'); + $request->attributes->set('_studio_rate_limit', new RateLimit(499, new DateTimeImmutable(), true, 500)); + + $response = new Response(); + $event = $this->createResponseEvent($request, $response); + $subscriber->onKernelResponse($event); + + $this->assertFalse($response->headers->has('X-RateLimit-Limit')); + } + + private function createSubscriber( + int $limit = 500, + bool $enabled = true, + ): RateLimitSubscriber { + $factory = new RateLimiterFactory( + [ + 'id' => 'test_studio_api', + 'policy' => 'sliding_window', + 'limit' => $limit, + 'interval' => '60 seconds', + ], + new InMemoryStorage(), + ); + + return new RateLimitSubscriber( + self::URL_PREFIX, + $factory, + $enabled, + ); + } + + private function createRequestEvent( + string $path, + string $method = 'GET', + bool $isMainRequest = true, + ): RequestEvent { + $request = Request::create($path, $method); + + return new RequestEvent( + $this->createKernelStub(), + $request, + $isMainRequest ? HttpKernelInterface::MAIN_REQUEST : HttpKernelInterface::SUB_REQUEST, + ); + } + + private function createResponseEvent( + Request $request, + Response $response, + ): ResponseEvent { + return new ResponseEvent( + $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(); + } + }; + } +}