Skip to content

Commit 94630f5

Browse files
authored
[Task]: Implement general rate limiting (#1861)
* add missing general rate limiter for API * fix lowest versions
1 parent 7937364 commit 94630f5

7 files changed

Lines changed: 507 additions & 1 deletion

File tree

config/event_subscribers.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,10 @@ services:
1515

1616
Pimcore\Bundle\StudioBackendBundle\EventSubscriber\ApiExceptionSubscriber:
1717
tags: [ 'kernel.event_subscriber' ]
18-
arguments: ["%kernel.environment%", '%pimcore_studio_backend.url_prefix%']
18+
arguments: ["%kernel.environment%", '%pimcore_studio_backend.url_prefix%']
19+
20+
Pimcore\Bundle\StudioBackendBundle\EventSubscriber\RateLimitSubscriber:
21+
tags: [ 'kernel.event_subscriber' ]
22+
arguments:
23+
$urlPrefix: '%pimcore_studio_backend.url_prefix%'
24+
$studioApiGeneralLimiter: '@limiter.studio_api_general'

config/prepend/rate_limiter.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
framework:
22
rate_limiter:
3+
studio_api_general:
4+
policy: 'sliding_window'
5+
limit: 500
6+
interval: '1 minute'
37
reset_password:
48
policy: 'fixed_window'
59
limit: 5
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Rate Limiting
2+
3+
The Studio Backend Bundle includes built-in rate limiting for all API endpoints to protect against abuse and ensure fair usage.
4+
5+
## Overview
6+
7+
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`.
8+
9+
Additionally, specific public endpoints have their own stricter limits that apply on top of the general one.
10+
11+
## Default Limits
12+
13+
| Limiter | Scope | Policy | Limit | Interval |
14+
|---------|-------|--------|-------|----------|
15+
| `studio_api_general` | All Studio API endpoints | Sliding window | 500 requests | 1 minute |
16+
| `reset_password` | `POST /user/reset-password` | Fixed window | 5 requests | 5 minutes |
17+
| `setting_admin_thumbnail` | `GET /setting/admin/thumbnail` | Fixed window | 60 requests | 1 minute |
18+
19+
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.
20+
21+
## Response Headers
22+
23+
Every Studio API response includes rate limit information in the following headers:
24+
25+
| Header | Description |
26+
|--------|-------------|
27+
| `X-RateLimit-Limit` | Maximum number of requests allowed in the current window |
28+
| `X-RateLimit-Remaining` | Number of requests remaining before the limit is reached |
29+
| `X-RateLimit-Reset` | Unix timestamp indicating when the current window resets |
30+
31+
These headers are also included on `429` responses, so clients can determine when to retry.
32+
33+
## Configuration
34+
35+
### Disabling Rate Limiting
36+
37+
To disable the general rate limiter entirely, add the following to your project configuration:
38+
39+
```yaml
40+
# config/config.yaml
41+
pimcore_studio_backend:
42+
rate_limiting:
43+
enabled: false
44+
```
45+
46+
### Customizing Limits
47+
48+
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:
49+
50+
```yaml
51+
# config/packages/framework.yaml
52+
framework:
53+
rate_limiter:
54+
studio_api_general:
55+
policy: 'sliding_window'
56+
limit: 1000
57+
interval: '1 minute'
58+
```
59+
60+
### Storage Backend
61+
62+
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:
63+
64+
```yaml
65+
# config/packages/framework.yaml
66+
framework:
67+
cache:
68+
pools:
69+
cache.rate_limiter:
70+
adapter: cache.adapter.redis
71+
```
72+
73+
Without shared storage, each server tracks limits independently, effectively multiplying the allowed rate by the number of servers.
74+
75+
## Deployment Considerations
76+
77+
### Reverse Proxies and Load Balancers
78+
79+
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:
80+
81+
```yaml
82+
# config/packages/framework.yaml
83+
framework:
84+
trusted_proxies: '127.0.0.1,REMOTE_ADDR'
85+
trusted_headers: ['x-forwarded-for', 'x-forwarded-proto']
86+
```
87+
88+
Without this configuration, all requests appear to come from the proxy's IP address, causing all users to share a single rate limit bucket.

src/DependencyInjection/Configuration.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ public function getConfigTreeBuilder(): TreeBuilder
8787
$this->addGdprDataExtractorNode($rootNode);
8888
$this->addAdminSettingsNode($rootNode);
8989
$this->addMcpNode($rootNode);
90+
$this->addRateLimitingNode($rootNode);
9091
$this->addTranslation($rootNode);
9192
$rootNode->append($this->addTwigSandboxNode());
9293

@@ -809,6 +810,22 @@ private function addAdminSettingsNode(ArrayNodeDefinition $node): void
809810
->end();
810811
}
811812

813+
private function addRateLimitingNode(ArrayNodeDefinition $node): void
814+
{
815+
$node->children()
816+
->arrayNode('rate_limiting')
817+
->addDefaultsIfNotSet()
818+
->info('General rate limiting configuration for all Studio API endpoints.')
819+
->children()
820+
->booleanNode('enabled')
821+
->info('Enable or disable general rate limiting for all Studio API endpoints.')
822+
->defaultTrue()
823+
->end()
824+
->end()
825+
->end()
826+
->end();
827+
}
828+
812829
private function addTranslation(ArrayNodeDefinition $node): void
813830
{
814831
$node

src/DependencyInjection/PimcoreStudioBackendExtension.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Pimcore\Bundle\StudioBackendBundle\Document\Service\DocumentTypeServiceInterface;
2323
use Pimcore\Bundle\StudioBackendBundle\Element\Service\ElementDeleteServiceInterface;
2424
use Pimcore\Bundle\StudioBackendBundle\EventSubscriber\CorsSubscriber;
25+
use Pimcore\Bundle\StudioBackendBundle\EventSubscriber\RateLimitSubscriber;
2526
use Pimcore\Bundle\StudioBackendBundle\Exception\InvalidHostException;
2627
use Pimcore\Bundle\StudioBackendBundle\Exception\InvalidUrlPrefixException;
2728
use Pimcore\Bundle\StudioBackendBundle\Export\Service\CsvExportService;
@@ -100,6 +101,9 @@ public function load(array $configs, ContainerBuilder $container): void
100101
$definition = $container->getDefinition(CorsSubscriber::class);
101102
$definition->setArgument('$allowedHosts', $config['allowed_hosts_for_cors']);
102103

104+
$definition = $container->getDefinition(RateLimitSubscriber::class);
105+
$definition->setArgument('$enabled', $config['rate_limiting']['enabled']);
106+
103107
$definition = $container->getDefinition(DownloadServiceInterface::class);
104108
$definition->setArgument('$defaultFormats', $config['asset_default_formats']);
105109

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
/**
5+
* This source file is available under the terms of the
6+
* Pimcore Open Core License (POCL)
7+
* Full copyright and license information is available in
8+
* LICENSE.md which is distributed with this source code.
9+
*
10+
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
11+
* @license Pimcore Open Core License (POCL)
12+
*/
13+
14+
namespace Pimcore\Bundle\StudioBackendBundle\EventSubscriber;
15+
16+
use Pimcore\Bundle\StudioBackendBundle\Exception\Api\RateLimitException;
17+
use Pimcore\Bundle\StudioBackendBundle\Util\Trait\StudioBackendPathTrait;
18+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
19+
use Symfony\Component\HttpKernel\Event\RequestEvent;
20+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
21+
use Symfony\Component\HttpKernel\KernelEvents;
22+
use Symfony\Component\RateLimiter\RateLimit;
23+
use Symfony\Component\RateLimiter\RateLimiterFactory;
24+
25+
/**
26+
* @internal
27+
*/
28+
final class RateLimitSubscriber implements EventSubscriberInterface
29+
{
30+
use StudioBackendPathTrait;
31+
32+
private const string RATE_LIMIT_ATTRIBUTE = '_studio_rate_limit';
33+
34+
public function __construct(
35+
private readonly string $urlPrefix,
36+
private readonly RateLimiterFactory $studioApiGeneralLimiter,
37+
private readonly bool $enabled = true,
38+
) {
39+
}
40+
41+
public static function getSubscribedEvents(): array
42+
{
43+
return [
44+
KernelEvents::REQUEST => ['onKernelRequest', 200],
45+
KernelEvents::RESPONSE => ['onKernelResponse', -10],
46+
];
47+
}
48+
49+
/**
50+
* @throws RateLimitException
51+
*/
52+
public function onKernelRequest(RequestEvent $event): void
53+
{
54+
if (!$this->enabled || !$event->isMainRequest()) {
55+
return;
56+
}
57+
58+
$request = $event->getRequest();
59+
60+
if (
61+
$request->getMethod() === 'OPTIONS' ||
62+
!$this->isStudioBackendPath($request->getPathInfo(), $this->urlPrefix)
63+
) {
64+
return;
65+
}
66+
67+
$key = $request->getClientIp() ?? 'unknown';
68+
$limiter = $this->studioApiGeneralLimiter->create($key);
69+
$rateLimit = $limiter->consume();
70+
71+
$request->attributes->set(self::RATE_LIMIT_ATTRIBUTE, $rateLimit);
72+
73+
if (!$rateLimit->isAccepted()) {
74+
throw new RateLimitException();
75+
}
76+
}
77+
78+
public function onKernelResponse(ResponseEvent $event): void
79+
{
80+
if (!$this->enabled || !$event->isMainRequest()) {
81+
return;
82+
}
83+
84+
$request = $event->getRequest();
85+
$rateLimit = $request->attributes->get(self::RATE_LIMIT_ATTRIBUTE);
86+
87+
if (!$rateLimit instanceof RateLimit) {
88+
return;
89+
}
90+
91+
$response = $event->getResponse();
92+
$response->headers->set('X-RateLimit-Limit', (string) $rateLimit->getLimit());
93+
$response->headers->set(
94+
'X-RateLimit-Remaining',
95+
(string) $rateLimit->getRemainingTokens()
96+
);
97+
$response->headers->set(
98+
'X-RateLimit-Reset',
99+
(string) $rateLimit->getRetryAfter()->getTimestamp()
100+
);
101+
}
102+
}

0 commit comments

Comments
 (0)