Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion config/event_subscribers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ services:

Pimcore\Bundle\StudioBackendBundle\EventSubscriber\ApiExceptionSubscriber:
tags: [ 'kernel.event_subscriber' ]
arguments: ["%kernel.environment%", '%pimcore_studio_backend.url_prefix%']
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'
4 changes: 4 additions & 0 deletions config/prepend/rate_limiter.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
88 changes: 88 additions & 0 deletions doc/02_Installation_and_Configuration/04_Rate_Limiting.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/DependencyInjection/PimcoreStudioBackendExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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']);

Expand Down
102 changes: 102 additions & 0 deletions src/EventSubscriber/RateLimitSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);

/**
* This source file is available under the terms of the
* Pimcore Open Core License (POCL)
* Full copyright and license information is available in
* LICENSE.md which is distributed with this source code.
*
* @copyright Copyright (c) Pimcore GmbH (https://www.pimcore.com)
* @license Pimcore Open Core License (POCL)
*/

namespace Pimcore\Bundle\StudioBackendBundle\EventSubscriber;

use Pimcore\Bundle\StudioBackendBundle\Exception\Api\RateLimitException;
use Pimcore\Bundle\StudioBackendBundle\Util\Trait\StudioBackendPathTrait;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimit;
use Symfony\Component\RateLimiter\RateLimiterFactory;

/**
* @internal
*/
final class RateLimitSubscriber implements EventSubscriberInterface
{
use StudioBackendPathTrait;

private const string RATE_LIMIT_ATTRIBUTE = '_studio_rate_limit';

public function __construct(
private readonly string $urlPrefix,
private readonly RateLimiterFactory $studioApiGeneralLimiter,
private readonly bool $enabled = true,
) {
}

public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['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()
);
}
}
Loading
Loading