+39 3662317539/+34 620899163
info@simonecosci.com

Laravel: rate limiting con RateLimiter::for e il middleware throttle

Sviluppo Siti Web a Tenerife

Laravel: rate limiting con RateLimiter::for e il middleware throttle

Un form di login senza limite accetta mille tentativi al minuto. Un endpoint che manda email ne
manda quante gliene chiedono. Un’API pubblica regge finché il primo script scritto male non la
chiama in un ciclo. In tutti e tre i casi il problema è lo stesso: nessuno ha deciso quante
richieste, da chi, in quanto tempo
.

Laravel ha già tutto quello che serve per deciderlo. Quello che segue è basato sulla
documentazione ufficiale di Laravel 13.x, pagine
Routing → Rate Limiting e
Rate Limiting.

Un limiter è un nome e una regola

I limiter si definiscono nel metodo boot di App\Providers\AppServiceProvider, con
RateLimiter::for. Il primo argomento è un nome, il secondo una closure che riceve la richiesta e
restituisce un Limit:

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

public function boot(): void
{
    RateLimiter::for('api', function (Request $request) {
        return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
    });
}

Poi lo si aggancia alle rotte con il middleware throttle, passando il nome:

Route::middleware(['throttle:uploads'])->group(function () {
    Route::post('/audio', function () {
        // ...
    });

    Route::post('/video', function () {
        // ...
    });
});

Il vantaggio di avere un nome è che la regola sta in un punto solo. Le rotte dicono quale
limite usano, non quanto vale: per cambiare 60 in 30 si tocca una riga.

La chiave decide chi viene contato

La parte che conta davvero è by(). Senza, il limite è uno per tutti: Limit::perMinute(1000)
vuol dire mille richieste al minuto in totale, e basta un client troppo zelante per bloccare
tutti gli altri. Con by() il contatore si divide per il valore che gli passi.

L’esempio della documentazione distingue utenti autenticati e ospiti:

RateLimiter::for('uploads', function (Request $request) {
    return $request->user()
        ? Limit::perMinute(100)->by($request->user()->id)
        : Limit::perMinute(10)->by($request->ip());
});

Cento al minuto per utente, dieci al minuto per IP chi non ha fatto login. È la scelta sensata
nella maggior parte dei casi: l’utente lo conosci e puoi essere generoso, l’IP no.

Siccome la closure riceve la richiesta, il limite può anche dipendere da chi la fa.
Limit::none() toglie il limite del tutto, ad esempio per un cliente con un piano diverso.

Più limiti sullo stesso endpoint

Si può restituire un array di limiti, valutati nell’ordine in cui compaiono. Il caso tipico è il
login: un tetto globale e uno per singolo account.

RateLimiter::for('login', function (Request $request) {
    return [
        Limit::perMinute(500),
        Limit::perMinute(3)->by($request->input('email')),
    ];
});

C’è un dettaglio che la documentazione segnala esplicitamente: se due limiti usano la stessa
chiave in by(), vanno resi distinti con un prefisso, altrimenti finiscono sullo stesso
contatore.

RateLimiter::for('uploads', function (Request $request) {
    return [
        Limit::perMinute(10)->by('minute:'.$request->user()->id),
        Limit::perDay(1000)->by('day:'.$request->user()->id),
    ];
});

Cosa succede quando il limite è superato

Laravel risponde da solo con HTTP 429. Se vuoi una risposta tua, c’è response(), che riceve
la richiesta e un array di header:

RateLimiter::for('global', function (Request $request) {
    return Limit::perMinute(1000)->response(function (Request $request, array $headers) {
        return response('Custom response...', 429, $headers);
    });
});

Nota il terzo argomento: gli header vanno passati avanti. Se li butti via, il client riceve il
429 senza le informazioni che Laravel aveva preparato per lui.

Contare solo le risposte che contano

La documentazione descrive anche after(): una closure che riceve la risposta e
decide se conteggiarla. Serve quando il limite non deve scattare su ogni richiesta, ma solo su
certe risposte. L’esempio ufficiale conta solo i 404, per frenare chi prova URL uno dopo l’altro:

use Symfony\Component\HttpFoundation\Response;

RateLimiter::for('resource-not-found', function (Request $request) {
    return Limit::perMinute(10)
        ->by($request->user()?->id ?: $request->ip())
        ->after(function (Response $response) {
            return $response->status() === 404;
        });
});

Lo stesso meccanismo permette l’inverso: non punire chi sbaglia la validazione di un form e
conteggiare solo le operazioni andate a buon fine.

Fuori dalle rotte

Il rate limiter non è legato all’HTTP. Con RateLimiter::attempt si limita qualsiasi azione,
con una chiave scelta da te:

$executed = RateLimiter::attempt(
    'send-message:'.$user->id,
    $perMinute = 5,
    function() {
        // Send message...
    }
);

if (! $executed) {
    return 'Too many messages sent!';
}

Il quarto argomento, opzionale, è la finestra in secondi. Se due richieste possono arrivare nello
stesso istante, la documentazione suggerisce di controllare il valore restituito da increment()
invece di chiamare tooManyAttempts() e increment() separatamente: con i cache store redis,
memcached o database l’incremento è atomico.

Gli errori da evitare

1. Il contatore vive nella cache. Il rate limiter usa la cache di default dell’applicazione,
oppure quella indicata dalla chiave limiter in config/cache.php. Se la cache è locale a ogni
server e i server sono due, i contatori sono due e il limite reale raddoppia. Con Redis come cache,
la documentazione indica throttleWithRedis() in bootstrap/app.php per far gestire il throttle
direttamente a Redis.

2. L’IP che vedi potrebbe non essere quello del client. Dietro un load balancer o un reverse
proxy, $request->ip() vale solo quanto la configurazione dei proxy fidati (trustProxies in
bootstrap/app.php). Prima di fidarti di un limite per IP, scrivi nei log $request->ip() in
produzione e guarda cosa c’è: se è sempre lo stesso indirizzo, stai limitando il proxy, cioè tutti.
E non risolvere con trustProxies(at: '*') se l’applicazione è raggiungibile anche senza
passare dal proxy. La documentazione è netta: “IP addresses should be considered untrusted,
user-controlled input”
.

3. La chiave arriva dall’utente. Nell’esempio del login la chiave è $request->input('email'):
la sceglie chi fa la richiesta. È il comportamento voluto (tre tentativi per account), ma ha una
conseguenza da conoscere: chiunque conosca l’email di un utente può esaurire i suoi tentativi.
Per questo il limite per account va tenuto breve, una finestra di un minuto, non di un’ora.

4. $request->user() senza ?->. In uno degli esempi della documentazione il limiter chiama
$request->user()->vipCustomer(): su una rotta aperta agli ospiti user() restituisce null e
la closure va in errore. Se la rotta non è protetta da autenticazione, usa ?->, come nel primo
esempio.

In sintesi

Un limiter per ogni tipo di endpoint (login, API, invio di email), una chiave per utente quando
c’è e per IP quando non c’è, i limiti multipli con prefissi distinti, e un controllo in produzione
che l’IP sia davvero quello del client. È mezz’ora di lavoro che evita il giorno in cui qualcuno
scopre che il tuo form accetta tentativi senza fine.

Se hai un’applicazione Laravel esposta e vuoi capire dove mettere i limiti,
scrivimi.