Il rate limiting è una di quelle misure che quasi tutti applicano e che quasi nessuno verifica. Si installa un middleware, si scrive max: 100, si passa oltre. Poi arriva il tentativo vero e si scopre che il limite era decorativo: contava la cosa sbagliata, o la contava in un posto solo.

Che cosa si sta difendendo#

Vale la pena distinguere tre problemi, perché richiedono tarature diverse:

  • Brute force e credential stuffing. Pochissime richieste al secondo, ma migliaia di account e migliaia di IP. Il volume totale è irrilevante: quello che conta è il numero di tentativi per account.
  • Esaurimento risorse. Un endpoint costoso — una ricerca, un'esportazione, un hash di password — invocato più in fretta di quanto il server riesca a smaltire. Qui non serve un attaccante: basta un client con un ciclo scritto male.
  • Uso improprio. Chi raschia il catalogo, chi invia moduli automatici, chi usa la vostra API come se fosse la propria.

Gli algoritmi, e perché la scelta conta#

il difetto della finestra fissa
// La finestra fissa: il conteggio azzera a orario. È l'algoritmo più// semplice e ha un difetto ben noto ai bordi.////   limite: 100 richieste ogni minuto////   12:00:59  ██████████████████ 100 richieste  (finestra delle 12:00)//   12:01:00  ██████████████████ 100 richieste  (finestra delle 12:01)//              └─── 200 richieste in due secondi, entrambe "a norma" ───┘//// Su un endpoint di login significa il doppio dei tentativi consentiti,// concentrati esattamente nel momento peggiore.
AlgoritmoMemoria per chiaveDifettoAdatto a
Finestra fissaun contatorefino al doppio del limite a cavallo del cambioniente di critico
Registro scorrevoleun timestamp per richiestacosto di memoria proporzionale al trafficolimiti bassi su pochi utenti
Contatore scorrevoledue contatoriapprossima, errore di pochi puntiAPI pubbliche ad alto volume
Token bucketdue numeridue parametri da tarare invece di unoquasi tutti i casi
Leaky bucketuna codanon ammette raffiche: penalizza il traffico normalecode di lavori, non richieste HTTP

Il token bucket vince quasi sempre perché descrive il traffico reale: una persona che apre una pagina genera dieci richieste in mezzo secondo e poi tace per un minuto. Un limite che rifiuta la raffica rompe l'uso legittimo mentre l'attaccante, che il ritmo può regolarlo, non lo tocca.

secchio.jsJavaScript
// Token bucket: il secchio si riempie a ritmo costante, ogni richiesta// consuma un gettone. Ammette una raffica iniziale (la capienza) e poi// impone il ritmo medio. È il comportamento che gli utenti veri hanno:// un burst quando aprono la pagina, poi silenzio.class Secchio {  constructor(capienza, ricaricaAlSecondo) {    this.capienza = capienza;    this.ritmo = ricaricaAlSecondo;    this.gettoni = capienza;    this.ultimo = Date.now();  }  // Nessun timer: i gettoni si ricalcolano dal tempo trascorso. Un timer  // per client sarebbe un timer per client — con 50 000 client, 50 000 timer.  preleva(costo = 1) {    const ora = Date.now();    this.gettoni = Math.min(      this.capienza,      this.gettoni + ((ora - this.ultimo) / 1000) * this.ritmo    );    this.ultimo = ora;    if (this.gettoni < costo) {      const attesa = (costo - this.gettoni) / this.ritmo;      return { ok: false, riprovaFra: Math.ceil(attesa) };    }    this.gettoni -= costo;    return { ok: true, rimasti: Math.floor(this.gettoni) };  }}

Il dettaglio implementativo che conta: niente timer. I gettoni si ricalcolano dal tempo trascorso al momento della richiesta. Un'implementazione con un timer per client, con cinquantamila client connessi, crea cinquantamila timer — e il costo del limitatore diventa il problema che doveva risolvere.

L'errore che rende inutile tutto il resto: la chiave#

L'algoritmo è la parte facile. Quello che decide se il limite funziona è che cosa viene contato.

IPv6: contare per indirizzo non conta niente#

chiave-rete.jsJavaScript
// ✗ Contare per singolo indirizzo IPv6 non limita niente.//   A un cliente domestico viene assegnato di norma un /64: sono//   18 446 744 073 709 551 616 indirizzi. Cambiarne uno costa zero.const chiave = req.ip;// ✓ Su IPv6 si conta per PREFISSO. /64 è il blocco tipico di un singolo//   abbonato; /56 o /48 se si vuole essere più severi con gli hosting.function chiaveRete(ip) {  if (!ip.includes(':')) return 'v4:' + ip;              // IPv4: l'indirizzo  const parti = ip.split(':');  // I primi quattro gruppi da 16 bit sono il /64.  return 'v6:' + parti.slice(0, 4).join(':');}

È il difetto più diffuso e il meno discusso. Su IPv4 un indirizzo è una risorsa scarsa e contare per indirizzo ha senso. Su IPv6 a un singolo abbonato domestico viene delegato di norma un /64: diciotto miliardi di miliardi di indirizzi, che il suo sistema operativo peraltro già ruota da solo per motivi di privacy. Un limitatore che conta per indirizzo, su una richiesta IPv6, sta contando fino a uno.

X-Forwarded-For: fidarsi del client#

proxy.jsJavaScript
// ✗ Dietro un proxy, req.ip è l'IP del proxy: tutti gli utenti finiscono//   nello stesso secchio e si bloccano a vicenda.//   Ma leggere X-Forwarded-For senza condizioni è peggio: è//   un'intestazione che il client scrive, quindi l'attaccante ne mette//   una diversa a ogni richiesta e il limite non lo tocca mai.// ✓ Si dichiara di quanti proxy fidarsi. Express toglie ESATTAMENTE quel//   numero di salti dalla coda della catena: il resto è dato non fidato.app.set('trust proxy', 1);          // un solo proxy davanti (Caddy, nginx)// Il numero va contato: se davanti c'è una CDN più un ingress controller// più un load balancer, è 3. Metterlo troppo alto significa fidarsi di// valori scritti dal client; "true" si fida dell'intera catena ed è la// configurazione con cui si aggira il limite in una riga di curl.

Le due configurazioni sbagliate sono simmetriche e producono danni opposti. Non leggere l'intestazione mette tutti gli utenti nello stesso secchio — dietro un proxy, req.ip è l'indirizzo del proxy. Leggerla senza condizioni significa accettare un valore che il client scrive: chi vuole aggirare il limite mette un indirizzo diverso a ogni richiesta.

Una chiave sola non basta mai#

login.jsJavaScript
// Tre limiti sullo stesso endpoint, che fermano tre attacchi diversi.// Nessuno dei tre da solo è sufficiente.app.post('/login',  // 1. Per rete: ferma il rumore di fondo e le scansioni banali.  limita({ chiave: r => chiaveRete(r.ip), capienza: 20, ritmo: 1 / 30 }),  // 2. Per account: ferma il brute force distribuito su migliaia di IP.  //    L'attaccante che cambia rete a ogni tentativo scavalca il primo  //    limite, non questo.  limita({ chiave: r => 'acct:' + normalizza(r.body.email), capienza: 5, ritmo: 1 / 120 }),  // 3. Globale sull'endpoint: la valvola di sicurezza. Se qualcosa che non  //    avete previsto genera 10 000 login al secondo, il server sopravvive.  limita({ chiave: () => 'globale:login', capienza: 200, ritmo: 50 }),  gestisciLogin);

Il secondo limite è quello che ferma il credential stuffing, ed è quello che manca più spesso. Un attaccante che prova una password su centomila account, da centomila IP diversi, non supera mai il limite per rete. Lo supera immediatamente su quello per account — se esiste.

Il terzo, quello globale, non serve contro gli attacchi: serve contro voi stessi. È la valvola che tiene in piedi il server quando un client interno entra in un ciclo, o quando un'integrazione riprova all'infinito senza attesa esponenziale.

Con più di un'istanza, la memoria non basta#

ratelimit.luaSQL
-- In memoria funziona con UNA istanza. Con tre repliche dietro un-- bilanciatore, ogni replica conta per conto proprio: il limite reale-- diventa il triplo di quello configurato.---- Questo script Lua gira su Redis in modo atomico: lettura, calcolo e-- scrittura non possono essere interlacciati da un'altra richiesta.local chiave    = KEYS[1]local capienza  = tonumber(ARGV[1])local ritmo     = tonumber(ARGV[2])   -- gettoni al secondolocal ora       = tonumber(ARGV[3])   -- millisecondi, dal serverlocal costo     = tonumber(ARGV[4])local s = redis.call('HMGET', chiave, 'gettoni', 'ultimo')local gettoni = tonumber(s[1]) or capienzalocal ultimo  = tonumber(s[2]) or oragettoni = math.min(capienza, gettoni + ((ora - ultimo) / 1000) * ritmo)local ok = 0if gettoni >= costo then  gettoni = gettoni - costo  ok = 1endredis.call('HMSET', chiave, 'gettoni', gettoni, 'ultimo', ora)-- La scadenza evita che le chiavi si accumulino all'infinito: dopo il-- tempo necessario a riempire il secchio, lo stato non serve più.redis.call('PEXPIRE', chiave, math.ceil((capienza / ritmo) * 1000) + 1000)return { ok, math.floor(gettoni) }

Il punto non è «usare Redis»: è che il conteggio deve essere atomico. Leggere il contatore, calcolare e riscriverlo con tre comandi separati lascia fra il primo e il terzo una finestra in cui un'altra richiesta legge lo stesso valore. Sotto carico — cioè esattamente quando il limitatore serve — quella finestra si apre di continuo. Uno script Lua viene eseguito da Redis come una singola operazione indivisibile.

Rispondere in modo utile#

risposta.jsJavaScript
// La risposta a un limite superato non è solo un codice: è un contratto.// Un client ben scritto la legge e si regola; senza queste intestazioni// può solo riprovare a caso, peggiorando la situazione.function nega(res, riprovaFra, limite, rimasti) {  res.set({    'Retry-After': String(riprovaFra),                  // secondi (RFC 9110)    'RateLimit-Limit': String(limite),    'RateLimit-Remaining': String(rimasti),    'RateLimit-Reset': String(riprovaFra)  });  res.status(429).json({    errore: 'Troppe richieste',    riprovaFra  });}// 429, non 403: "non ora" è un'informazione diversa da "non tu".// I client, le librerie e i crawler distinguono i due casi.

429 e non 403: sono due informazioni diverse, e i client le trattano diversamente. 403 dice «non tu, mai»; 429 dice «non ora». Una libreria HTTP ben scritta, ricevendo un 429 con Retry-After, aspetta il tempo indicato invece di ritentare subito — trasformando un client che peggiora il sovraccarico in un client che collabora.

Limitare il lavoro, non le richieste#

costi.jsJavaScript
// Non tutte le richieste costano uguale. Un limite espresso in "richieste"// tratta allo stesso modo una lettura da cache e una ricerca full-text.// Esprimerlo in gettoni permette di far pagare quello che pesa.const COSTI = {  'GET /api/articoli':        1,  'GET /api/cerca':          10,    // query full-text  'POST /api/esporta':      100,    // genera un PDF: secondi di CPU  'POST /login':             25     // un hash Argon2: ~250 ms di CPU};// Con capienza 300 e ricarica 5/s: 300 letture in raffica, oppure// 3 esportazioni, oppure 12 tentativi di login. Il tetto è sul LAVORO// che potete assorbire, non sul numero di pacchetti.

Un tetto espresso in «richieste al minuto» tratta allo stesso modo una lettura da cache da 2 ms e un'esportazione PDF da 4 secondi. Esprimerlo in gettoni, con un costo per endpoint, sposta il limite su ciò che effettivamente vi mette in difficoltà: il tempo di CPU, non il numero di pacchetti.

È anche il modo corretto di gestire il rovescio dell'hashing lento delle password: come discusso nell'articolo su bcrypt e Argon2id, un hash da 250 ms rende il login un amplificatore di carico, e il costo in gettoni lo dichiara esplicitamente.

Quando il limitatore stesso si guasta#

degrado.jsJavaScript
// Domanda che arriva sempre a incidente in corso: se Redis non risponde,// si lascia passare o si blocca tutto?async function verifica(chiave, opzioni) {  try {    return await redis.eval(SCRIPT, ...);  } catch (e) {    metriche.incrementa('ratelimit.degradato');    // La risposta dipende dall'endpoint, non dal gusto personale:    // - login, reset password, pagamenti → fail-closed. Meglio un servizio    //   indisponibile che una finestra aperta sul brute force.    // - lettura di contenuti pubblici → fail-open. Un guasto al limitatore    //   non deve trasformarsi in un disservizio per tutti.    return opzioni.criticoPerSicurezza      ? { ok: false, riprovaFra: 30 }      : { ok: true, rimasti: 0, degradato: true };  }}

La domanda arriva sempre durante un incidente, che è il momento peggiore per rispondere. Vale la pena deciderlo prima, e la risposta dipende dall'endpoint: sui percorsi critici per la sicurezza si chiude (meglio indisponibile che aperto), sulle letture pubbliche si apre (un guasto al limitatore non deve diventare un disservizio generale).

Accorgersi che sta succedendo#

analisi log
# Come si riconosce una scansione nei log di accesso: molti percorsi# diversi, tutti 404, dalla stessa rete, in pochi secondi.awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -5  14203 203.0.113.44    892 198.51.100.7    311 192.0.2.19awk '$9 == 404 {print $1}' access.log | sort | uniq -c | sort -rn | head -3  14180 203.0.113.44# 14 180 richieste su 14 203 hanno risposto 404: non e' un utente# che sbaglia indirizzo, e' un dizionario che gira.
Il rapporto fra richieste totali e risposte 404 dalla stessa rete distingue in un secondo un utente che sbaglia indirizzo da un dizionario che gira — vedi anche l'articolo sul forced browsing.

Un limitatore che non produce metriche è un limitatore di cui non saprete mai se la taratura è giusta. I due numeri da esporre sono il tasso di rifiuti per endpoint e la distribuzione dei consumi: se nessuno viene mai rifiutato il limite è troppo largo, se vengono rifiutati utenti veri è troppo stretto, e senza misurarlo si procede a intuito.