nginx: limit_req per fermare il brute force su wp-login.php

Guardi gli access log di un sito WordPress qualsiasi e li trovi: centinaia di POST /wp-login.php in pochi minuti, da uno o due indirizzi, con user agent finti. Non è un attacco mirato, è rumore di fondo — ma costa.
Il punto è dove costa. Un plugin che blocca l’utente dopo cinque tentativi fa il suo lavoro, ma per decidere che il tentativo numero seicento va rifiutato deve prima far partire PHP-FPM, caricare WordPress, leggere il database e far girare il plugin. Seicento volte. Su un VPS con quattro worker PHP, un bot da solo li tiene occupati tutti e il sito diventa lento per i visitatori veri, senza che nessuno abbia indovinato una password.
La difesa giusta sta un livello più in basso: nginx, che quella richiesta la può buttare via prima di passarla a PHP. Il modulo si chiama ngx_http_limit_req_module ed è compilato in nginx di default.
Come ragiona limit_req
Il modulo usa il metodo del leaky bucket: definisci una velocità di elaborazione, e quando le richieste arrivano più in fretta di così quelle in eccesso vengono ritardate in modo che il flusso resti alla velocità stabilita. Se le richieste in attesa superano la dimensione del burst, la richiesta viene terminata con un errore.
Servono due direttive. La prima dichiara la zona di memoria condivisa dove nginx tiene lo stato, e va in http:
limit_req_zone $binary_remote_addr zone=wplogin:10m rate=20r/m;
$binary_remote_addr è l’indirizzo del client in forma binaria: 4 byte per IPv4, 16 per IPv6. Si usa quello e non $remote_addr perché lo stato salvato occupa 64 byte sulle piattaforme a 32 bit e 128 byte su quelle a 64 bit, e un megabyte di zona tiene circa 16.000 stati da 64 byte o circa 8.000 da 128. Con 10m stai parlando di ottantamila indirizzi in memoria su un sistema a 64 bit: per un sito vetrina è sovrabbondante, ed è comunque un decimo di quello che consuma un worker PHP. Quando la zona si riempie nginx butta via lo stato usato meno di recente; se nemmeno così riesce a crearne uno nuovo, la richiesta viene terminata con errore.
Il rate si esprime in richieste al secondo (r/s) o al minuto (r/m). Per una pagina di login 20r/m è già generoso: una persona che sbaglia la password e riprova non ci arriva mai vicino.
Dove applicarla
La seconda direttiva attiva il limite, e va messa esattamente dove serve:
location = /wp-login.php {
limit_req zone=wplogin burst=5 nodelay;
limit_req_status 429;
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.3-fpm.sock;
}
Due cose da notare.
Il prefisso = è una corrispondenza esatta: quando nginx la trova interrompe subito la ricerca, senza valutare le espressioni regolari. È quello che vuoi — il limite deve valere per il login e solo per il login — ma ha una conseguenza che fa perdere mezz’ora a tutti la prima volta: siccome questo location vince su quello generico ~ \.php$, la configurazione FastCGI lì dentro va ripetuta. Se la dimentichi, nginx non passa più la richiesta a PHP e ti ritrovi il browser che scarica il file wp-login.php invece di mostrarti il form.
Poi limit_req_status 429. Il default è 503, che significa “servizio non disponibile” ed è la risposta sbagliata: fa pensare a un guasto, e i crawler la trattano come tale. 429 Too Many Requests dice quello che è successo davvero. La direttiva esiste dalla 1.3.15.
Su burst=5 nodelay vale la pena fermarsi. Senza burst (il default è 0) qualunque richiesta oltre il ritmo viene rifiutata all’istante, e basta un doppio clic per prendersi un 429. Con burst=5 nginx accetta fino a cinque richieste in coda oltre la velocità nominale; nodelay dice di servirle subito invece di distribuirle nel tempo, mantenendo però il conteggio. In pratica: raffica breve permessa, martellamento continuo bloccato. Se preferisci una via di mezzo, dalla 1.15.7 c’è delay=N, che serve subito le prime N e ritarda le successive fino al burst.
Provare prima di accendere
Questa è la parte che di solito si salta, e non andrebbe. Dalla 1.17.1 c’è la modalità a vuoto:
limit_req_dry_run on;
Con questa attiva il ritmo non viene limitato, ma le richieste in eccesso vengono contate nella zona come se lo fosse. Abbinala alla variabile $limit_req_status (dalla 1.17.6), che vale PASSED, DELAYED, REJECTED, DELAYED_DRY_RUN o REJECTED_DRY_RUN, e mettila nel formato di log: per un paio di giorni vedi esattamente chi avresti bloccato, prima di bloccarlo davvero. Se fra i REJECTED_DRY_RUN compare l’IP dell’ufficio, hai appena evitato una telefonata.
Nel frattempo i rifiuti finiscono già nell’error log: limit_req_log_level ha come default error, e i ritardi vengono registrati un livello più in basso dei rifiuti — con notice impostato, i delay escono come info.
Le due trappole
Sei dietro un proxy. Se il sito sta dietro Cloudflare, un load balancer o un altro nginx, $binary_remote_addr è l’indirizzo del proxy, non del visitatore: stai limitando tutti i tuoi utenti insieme in un unico contatore. Serve il modulo ngx_http_realip_module — che non è compilato di default, va abilitato con --with-http_realip_module — con set_real_ip_from per gli indirizzi fidati e real_ip_header (default X-Real-IP) per l’intestazione da leggere. Se la catena di proxy è più lunga di uno, guarda anche real_ip_recursive, che di default è off. Senza questo pezzo, la configurazione qui sopra fa più danni che altro.
L’ereditarietà. Le direttive limit_req vengono ereditate dal livello di configurazione precedente se e solo se al livello corrente non ce n’è nessuna. Basta un limit_req dentro un location per far sparire lì dentro tutti quelli dichiarati nel server. Se hai un limite generale sul sito e ne aggiungi uno specifico sul login, nel location del login vanno ripetuti entrambi: limit_req si può indicare più volte nello stesso blocco, ed è il modo documentato per combinare un limite per IP con uno per virtual host.
Cosa non è
limit_req è una misura di capacità, non un’autenticazione. Non distingue un bot da una persona, non guarda le credenziali e non protegge da chi ci prova una volta al minuto per un mese. Rimane tutto il resto: password lunghe, due fattori, xmlrpc.php chiuso se non lo usi, WordPress aggiornato.
Quello che fa è togliere il carico a PHP e restituire al sito le risorse che stava spendendo per dire di no. Su un VPS piccolo è la differenza fra un log rumoroso e un sito lento.
Fonti: ngx_http_limit_req_module, ngx_http_realip_module e ngx_http_core_module, documentazione ufficiale nginx.
