planeta jupiter planeta tierra

Rate limiting en Symfony: el componente que nadie usa y debería

· 7 min de lectura · Symfony

Un contador de peticiones frenando una avalancha de intentos de login
nave extraterrestre
Un contador de peticiones frenando una avalancha de intentos de login

Tienes un endpoint de login, uno de «recuperar contraseña» o un formulario de contacto público. Funciona bien hasta que alguien escribe un script que le pega 200 peticiones por segundo, ya sea para probar contraseñas, spamear el formulario o simplemente porque tu API es la única sin protección de una lista de objetivos fáciles.

La solución típica es «eso se hace en nginx» o «eso lo pone Cloudflare». Y sí, ahí es donde debería vivir la protección más básica. Pero hay reglas que solo tienen sentido a nivel de aplicación: «5 intentos de login por usuario cada 15 minutos» no lo puedes expresar bien en una regla de nginx, porque nginx no sabe qué usuario es. Symfony sí.

El componente RateLimiter

Viene de serie desde Symfony 5.2, no hay que instalar nada raro:

composer require symfony/rate-limiter

Se configura en config/packages/rate_limiter.yaml:

framework:
    rate_limiter:
        login_attempts:
            policy: 'sliding_window'
            limit: 5
            interval: '15 minutes'

        contact_form:
            policy: 'fixed_window'
            limit: 3
            interval: '1 hour'

        api_public:
            policy: 'token_bucket'
            limit: 100
            rate: { interval: '1 minute', amount: 20 }

Cada limitador tiene un nombre (login_attempts, contact_form…) y una política. Las tres que vas a usar el 95% de las veces:

  • fixed_window: cuenta peticiones en bloques de tiempo fijos. Simple, pero tiene un problema: si el límite es 3 por hora y alguien manda 3 a las 12:59 y 3 más a las 13:01, ha mandado 6 en 2 minutos y no ha saltado el límite. Vale para casos donde eso no importa.
  • sliding_window: corrige ese problema mirando una ventana móvil real, no bloques fijos. Es la que quieres para límites de seguridad como intentos de login.
  • token_bucket: permite ráfagas controladas y luego un goteo constante. Ideal para APIs donde quieres dejar que un cliente haga varias peticiones seguidas pero no de forma indefinida.

Usarlo en un controlador

use Symfony\Component\RateLimiter\RateLimiterFactory;

class SecurityController extends AbstractController
{
    public function __construct(
        private RateLimiterFactory $loginAttemptsLimiter,
    ) {
    }

    #[Route('/login', methods: ['POST'])]
    public function login(Request $request): Response
    {
        $identifier = $request->request->get('email') ?? $request->getClientIp();
        $limiter = $this->loginAttemptsLimiter->create($identifier);

        if (!$limiter->consume(1)->isAccepted()) {
            return $this->json(
                ['error' => 'Demasiados intentos. Prueba de nuevo en unos minutos.'],
                Response::HTTP_TOO_MANY_REQUESTS
            );
        }

        // lógica normal de login
    }
}

Fíjate en $loginAttemptsLimiter: Symfony inyecta automáticamente una factory llamada <nombre>Limiter por cada limitador que definas en el yaml. No hay que registrar nada a mano.

El identificador ($identifier) es la clave del asunto: si limitas por IP, un atacante detrás de un proxy compartido puede bloquear a usuarios legítimos sin querer, y un atacante con IPs rotativas se salta el límite sin esfuerzo. Limitar por email en un login es mejor criterio, porque el objetivo real es proteger esa cuenta, no esa IP.

Combinar dos límites (lo que de verdad funciona en producción)

Un único límite casi nunca es suficiente. En un login real conviene combinar:

#[Route('/login', methods: ['POST'])]
public function login(
    Request $request,
    RateLimiterFactory $loginAttemptsLimiter,
    RateLimiterFactory $loginIpLimiter,
): Response {
    $email = $request->request->get('email');
    $ip = $request->getClientIp();

    $byEmail = $loginAttemptsLimiter->create($email)->consume(1);
    $byIp = $loginIpLimiter->create($ip)->consume(1);

    if (!$byEmail->isAccepted() || !$byIp->isAccepted()) {
        $retryAfter = max(
            $byEmail->getRetryAfter()?->getTimestamp() ?? 0,
            $byIp->getRetryAfter()?->getTimestamp() ?? 0,
        );

        return $this->json(
            ['error' => 'Demasiados intentos'],
            Response::HTTP_TOO_MANY_REQUESTS,
            ['Retry-After' => $retryAfter]
        );
    }

    // lógica normal de login
}

El límite por email protege una cuenta concreta de fuerza bruta. El límite por IP, más permisivo pero presente, frena a alguien que prueba muchos emails distintos desde el mismo sitio. Ninguno de los dos por separado cubre ambos escenarios.

Devolver la cabecera correcta

Cuando rechazas una petición por límite superado, el estándar HTTP para esto es 429 Too Many Requests con una cabecera Retry-After indicando cuántos segundos esperar. El objeto que devuelve consume() ya trae esa información:

$limit = $limiter->consume(1);

if (!$limit->isAccepted()) {
    $response = $this->json(['error' => 'Límite superado']);
    $response->headers->set('Retry-After', (string) $limit->getRetryAfter()->getTimestamp());
    $response->setStatusCode(Response::HTTP_TOO_MANY_REQUESTS);

    return $response;
}

Un cliente bien hecho (o tu propio frontend) puede leer esa cabecera y no volver a intentarlo hasta que pase el tiempo indicado, en vez de reintentar a lo loco y empeorar el problema.

Dónde se guarda el estado

Por defecto, el RateLimiter usa el componente Cache de Symfony, que a su vez usa el filesystem. Funciona, pero tiene un problema si tienes varios workers o instancias detrás de un balanceador: cada uno lleva su propia cuenta y el límite real acaba siendo límite × número de instancias.

La solución es apuntar el storage a algo compartido, normalmente Redis:

framework:
    rate_limiter:
        login_attempts:
            policy: 'sliding_window'
            limit: 5
            interval: '15 minutes'
            lock_factory: 'lock.redis.factory'
            cache_pool: 'cache.redis'

    cache:
        pools:
            cache.redis:
                adapter: cache.adapter.redis
                provider: 'redis://localhost'

Si solo tienes un servidor, el filesystem por defecto es suficiente y no merece la pena la complejidad extra de meter Redis solo para esto.

Dónde aplicarlo (y dónde no)

Tiene sentido en:

  • Login y recuperación de contraseña (fuerza bruta).
  • Formularios públicos sin captcha (contacto, newsletter, comentarios).
  • Endpoints de API pública, para que un cliente mal programado no tumbe el servicio a base de reintentos.
  • Envío de emails o SMS desde un endpoint (para que no te usen como pasarela gratuita de spam).

No hace falta en:

  • Endpoints internos ya protegidos por autenticación y con tráfico controlado.
  • Rutas de lectura pública de bajo coste, como un listado con caché delante.

Rate limiting no sustituye a las otras capas

Esto protege tu aplicación de abuso a nivel de lógica de negocio, pero no sustituye un firewall, un WAF o las reglas de Cloudflare que ya deberías tener delante para frenar ataques volumétricos antes de que lleguen siquiera a PHP. Son capas distintas: una frena el tráfico masivo antes de que toque tu servidor, la otra entiende el contexto de tu aplicación (quién es este usuario, qué está intentando hacer) que la capa de infraestructura no puede ver.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.