Zum Inhalt springen

Symfony Rate Limiter: API-Throttling mit Token Bucket und Fixed Window

Veröffentlicht am 25. März 2025 | ca. 2 Min. Lesezeit |

Rate Limiting ist ein Muss für jede API oder öffentlich zugängliche Webanwendung. Es schützt vor Brute-Force-Angriffen auf Login-Formulare, vor übermäßiger API-Nutzung und vor unbeabsichtigter Überlastung durch fehlerhafte Clients. Symfony bringt seit Version 5.2 eine eigene Rate-Limiter-Komponente mit, die verschiedene Algorithmen unterstützt.

Installation

ddev exec composer require symfony/rate-limiter

Für die Speicherung des Zustands wird Redis (empfohlen für Produktion) oder ein Datenbankadapter benötigt:

ddev exec composer require symfony/cache

Die drei Algorithmen im Überblick

Fixed Window

Der einfachste Algorithmus: In einem festen Zeitfenster sind maximal N Anfragen erlaubt. Das Fenster wird nach Ablauf komplett zurückgesetzt.

Vorteil: Einfach zu verstehen und zu implementieren. Nachteil: Ermöglicht kurze Bursts an der Fenstergrenze (z.B. 50 Anfragen kurz vor Ablauf + 50 kurz nach Reset = 100 in kurzer Zeit).

Sliding Window

Ähnlich wie Fixed Window, aber das Fenster gleitet mit der Zeit. Anfragen aus der Vergangenheit werden nach und nach vergessen.

Vorteil: Gleichmäßigerer Schutz, kein Burst-Problem. Nachteil: Etwas aufwendiger zu implementieren.

Token Bucket

Tokens werden mit einer konstanten Rate aufgefüllt (z.B. 10 Tokens pro Minute). Jede Anfrage verbraucht einen Token. Ist der Bucket leer, wird die Anfrage abgelehnt.

Vorteil: Erlaubt kurze Bursts (wenn Tokens angesammelt wurden), während die langfristige Rate begrenzt bleibt. Nachteil: Etwas schwerer zu erklären.

Konfiguration in Symfony

In config/packages/rate_limiter.yaml:

framework:
    rate_limiter:
        # Login-Schutz: 5 Versuche pro 15 Minuten (Fixed Window)
        login_limiter:
            policy: fixed_window
            limit: 5
            interval: '15 minutes'
            storage_service: null  # Standard: in-memory, für Produktion Cache nutzen

        # API-Throttling: 100 Anfragen pro Stunde (Token Bucket)
        api_limiter:
            policy: token_bucket
            limit: 100
            rate:
                interval: '1 hour'
                amount: 100

        # Globale Suche: Sliding Window
        search_limiter:
            policy: sliding_window
            limit: 30
            interval: '1 minute'

Für Redis als Storage:

framework:
    cache:
        pools:
            rate_limiter.storage:
                adapter: cache.adapter.redis
                provider: 'redis://localhost'

    rate_limiter:
        login_limiter:
            policy: fixed_window
            limit: 5
            interval: '15 minutes'
            storage_service: rate_limiter.storage

Login-Schutz

Der häufigste Anwendungsfall: Brute-Force-Schutz für das Login-Formular.

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;

class LoginController extends AbstractController
{
    public function __construct(
        private readonly RateLimiterFactory $loginLimiterFactory,
    ) {
    }

    #[Route('/login', name: 'login', methods: ['POST'])]
    public function login(Request $request): Response
    {
        // Limiter pro IP-Adresse
        $limiter = $this->loginLimiterFactory->create($request->getClientIp());
        $limit = $limiter->consume(1);

        if (!$limit->isAccepted()) {
            $retryAfter = $limit->getRetryAfter()->getTimestamp() - time();

            return $this->render('security/login.html.twig', [
                'error' => sprintf(
                    'Zu viele Anmeldeversuche. Bitte versuche es in %d Sekunden erneut.',
                    $retryAfter,
                ),
            ]);
        }

        // Normale Login-Logik...
        return $this->render('security/login.html.twig');
    }
}

API-Throttling mit Response-Headern

Für APIs ist es gute Praxis, Rate-Limit-Informationen in den Response-Headern zurückzugeben:

<?php

declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;

class ApiController extends AbstractController
{
    public function __construct(
        private readonly RateLimiterFactory $apiLimiterFactory,
    ) {
    }

    #[Route('/api/products', name: 'api_products', methods: ['GET'])]
    public function products(Request $request): JsonResponse
    {
        $apiKey = $request->headers->get('X-API-Key', $request->getClientIp());
        $limiter = $this->apiLimiterFactory->create($apiKey);
        $limit = $limiter->consume(1);

        $headers = [
            'X-RateLimit-Limit' => $limit->getLimit(),
            'X-RateLimit-Remaining' => $limit->getRemainingTokens(),
            'X-RateLimit-Reset' => $limit->getRetryAfter()?->getTimestamp() ?? 0,
        ];

        if (!$limit->isAccepted()) {
            return new JsonResponse(
                ['error' => 'Too Many Requests', 'retry_after' => $limit->getRetryAfter()?->getTimestamp()],
                Response::HTTP_TOO_MANY_REQUESTS,
                $headers,
            );
        }

        $products = []; // ... Produkte laden

        return new JsonResponse(['data' => $products], Response::HTTP_OK, $headers);
    }
}

Rate-Limiter als Event Subscriber

Eleganter ist ein Event Subscriber, der das Rate Limiting zentral für alle API-Routen übernimmt:

<?php

declare(strict_types=1);

namespace App\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;

class ApiRateLimitSubscriber implements EventSubscriberInterface
{
    public function __construct(
        private readonly RateLimiterFactory $apiLimiterFactory,
    ) {
    }

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::REQUEST => ['onKernelRequest', 20],
        ];
    }

    public function onKernelRequest(RequestEvent $event): void
    {
        $request = $event->getRequest();

        if (!str_starts_with($request->getPathInfo(), '/api/')) {
            return;
        }

        $identifier = $request->headers->get('X-API-Key', $request->getClientIp());
        $limiter = $this->apiLimiterFactory->create($identifier);
        $limit = $limiter->consume(1);

        if (!$limit->isAccepted()) {
            $event->setResponse(new JsonResponse(
                ['error' => 'Rate limit exceeded'],
                Response::HTTP_TOO_MANY_REQUESTS,
                ['Retry-After' => $limit->getRetryAfter()?->getTimestamp()],
            ));
        }
    }
}

Testen des Rate Limiters

Im Test-Umfeld wird der Rate Limiter oft deaktiviert oder mit hohen Limits konfiguriert. In config/packages/test/rate_limiter.yaml:

framework:
    rate_limiter:
        login_limiter:
            policy: fixed_window
            limit: 10000  # Effektiv deaktiviert für Tests
            interval: '1 minute'

Für gezielte Tests des Rate-Limiting-Verhaltens:

<?php

declare(strict_types=1);

namespace App\Tests\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class LoginRateLimitTest extends WebTestCase
{
    public function testRateLimitAfterFiveAttempts(): void
    {
        $client = static::createClient();

        for ($i = 0; $i < 5; $i++) {
            $client->request('POST', '/login', ['_username' => 'test', '_password' => 'wrong']);
        }

        $client->request('POST', '/login', ['_username' => 'test', '_password' => 'wrong']);

        // 6. Versuch: Rate Limit erreicht
        $this->assertStringContainsString('Zu viele Anmeldeversuche', $client->getResponse()->getContent());
    }
}

Fazit

Der Symfony Rate Limiter ist eine saubere, flexible Lösung für API-Throttling und Brute-Force-Schutz. Die verschiedenen Algorithmen decken unterschiedliche Anforderungen ab. In Produktion empfiehlt sich Redis als Storage — der In-Memory-Adapter verliert seinen Zustand bei jedem Neustart. Wer mehrere Load-Balancer-Instanzen betreibt, braucht ohnehin ein zentrales Storage, da sonst jede Instanz ihr eigenes Limit hat.

Thomas Wunner

Thomas Wunner

Fachinformatiker für Anwendungsentwicklung mit Ausbildereignungsprüfung und über 14 Jahre Erfahrung im Aufbau skalierbarer Webanwendungen mit Symfony und Shopware. Abseits der Tastatur ist Thomas als Rettungsschwimmer in der Wasserwacht aktiv, legt als DJ auf und erkundet die Umgebung auf dem Motorrad.

Kommentare

Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.