Nei log di quasi tutti i siti WordPress che analizziamo compaiono, a intervalli regolari, richieste a /wp-json/wp/v2/users e sequenze come /?author=1, /?author=2, /?author=3. Sono gli scanner automatici che raccolgono i nomi utente, primo passo di molti attacchi alle credenziali. Chi gestisce il sito se ne accorge di solito leggendo un report di sicurezza che segnala l’«enumerazione degli utenti» come vulnerabilità, e la reazione istintiva è disattivare l’API REST. È la mossa sbagliata, per ragioni che vedremo.
Nascondere i nomi utente non protegge un account con una password debole, e un account ben protetto non diventa vulnerabile perché il suo nome è pubblico. L’enumerazione è rumore da ridurre; le rotte REST senza controlli dei permessi sono il rischio vero.
In questo articolo vediamo cosa espone l’API REST ai visitatori anonimi, quali altri canali rivelano i nomi utente, come mappare tutte le rotte registrate da core e plugin, come limitare l’elenco utenti senza rompere l’editor a blocchi e dove ha senso aggiungere regole nel web server.
Cosa vede un visitatore anonimo nell’API REST
L’API REST è parte del core dalla versione 4.7 ed è usata dalla bacheca stessa: l’editor a blocchi, la libreria media, molte schermate di impostazioni. La documentazione ufficiale è esplicita: non va disattivata, perché si romperebbero funzioni dell’amministrazione. Quello che si può fare è decidere cosa vede chi non ha effettuato l’accesso.
Per impostazione predefinita, l’endpoint degli utenti risponde anche alle richieste anonime, ma con dei limiti precisi: il controller degli utenti restituisce solo chi ha pubblicato almeno un contenuto di un tipo visibile nell’API, e solo i campi pubblici: identificativo, nome visualizzato, descrizione, URL, avatar e slug. Email, ruolo e data di registrazione restano riservati a chi ha il permesso list_users. Il campo che interessa agli scanner è lo slug, cioè il user_nicename, che WordPress ricava dal nome utente al momento della creazione e che quindi, nella maggior parte dei siti, coincide con il login.
# Cosa restituisce il sito a un visitatore anonimo
curl -s https://www.esempio.it/wp-json/wp/v2/users | jq '.[] | {id, name, slug}'
# La stessa rotta senza permalink «belli»: da non dimenticare nei filtri
curl -s "https://www.esempio.it/?rest_route=/wp/v2/users" | jq '.[].slug'
Gli utenti non sono l’unica informazione disponibile. Ogni articolo restituito da /wp/v2/posts contiene l’identificativo numerico del suo autore, e l’indice alla radice /wp-json/ elenca i namespace registrati, cioè di fatto i plugin che espongono rotte: un namespace come nome-plugin/v1 dice a uno scanner che quel plugin è installato, anche se non carica nessun file visibile nelle pagine. Per chi incrocia i siti con i bollettini delle vulnerabilità note, è un’informazione più utile dei nomi utente. Non ha senso nasconderla, perché la stessa informazione si ricava in altri modi, ma è un buon motivo per non lasciare installati plugin inutilizzati.
Gli altri canali: ?author=N, sitemap e oEmbed
Chiudere solo l’API REST lascia aperti almeno tre canali, tutti documentati nel codice del core. Il più vecchio è il parametro author: una richiesta a /?author=1 viene reindirizzata dalla funzione redirect_canonical() all’archivio /author/slug/, se l’utente ha pubblicato qualcosa, e l’intestazione Location contiene lo slug. Il secondo sono le sitemap native, introdotte in WordPress 5.5: /wp-sitemap-users-1.xml elenca gli archivi autore di tutti gli utenti con contenuti pubblicati. Il terzo è l’endpoint oEmbed, che per ogni articolo restituisce author_name e author_url, e quest’ultimo contiene di nuovo lo slug.
# Redirect dell'archivio autore
curl -sI "https://www.esempio.it/?author=1" | grep -i '^location'
# Sitemap degli utenti (WordPress 5.5 e successivi)
curl -s https://www.esempio.it/wp-sitemap-users-1.xml | grep -o '<loc>[^<]*'
# oEmbed di un articolo qualsiasi
curl -s "https://www.esempio.it/wp-json/oembed/1.0/embed?url=https://www.esempio.it/un-articolo/" \
| jq '{author_name, author_url}'
A questi si aggiungono i canali del tema: molti temi stampano il link all’archivio dell’autore sotto ogni articolo, e i feed RSS riportano il nome visualizzato. Nessuno di questi è un difetto, sono funzioni. Il punto è sapere che esistono, così da decidere con cognizione se e quali chiudere.
Cosa permette davvero l’enumerazione, e cosa no
Conoscere il nome utente dimezza il lavoro di un attacco a forza bruta mirato, perché resta da indovinare solo la password, e rende più credibile un messaggio di phishing indirizzato a quella persona. Questo è tutto. Non dà accesso a nulla, e da WordPress 4.5 il modulo di login accetta anche l’indirizzo email al posto del nome utente, che spesso è pubblico per altre vie: nascondere lo slug non impedisce il credential stuffing con coppie email e password trapelate altrove.
Nei log, l’enumerazione è quasi sempre seguita da una raffica di tentativi di accesso con i nomi appena raccolti, su wp-login.php o su xmlrpc.php. In passato xmlrpc.php era il bersaglio preferito perché il metodo system.multicall permetteva di provare molte password in una sola richiesta HTTP; oggi il core interrompe i tentativi dopo il primo fallimento nella stessa richiesta, ma resta un punto d’ingresso che un limite configurato solo su wp-login.php non copre. Se il sito non usa XML-RPC, per esempio per l’app mobile o per Jetpack, bloccarlo nel web server toglie di mezzo il problema; se lo usa, il limite ai tentativi deve valere anche lì.
Per questo trattiamo l’enumerazione come una misura di igiene, utile a ridurre il volume di tentativi automatici e la superficie di un attacco mirato, non come una difesa. Le difese sono altrove: password uniche, secondo fattore per chi ha privilegi elevati, un limite ai tentativi di accesso, un login non esposto a XML-RPC se non serve. Se un report di sicurezza segnala l’enumerazione come «vulnerabilità critica», va letto con questo metro. Se invece nell’elenco degli utenti compaiono nomi che nessuno riconosce, il problema è un altro, e ne abbiamo parlato nell’articolo sugli amministratori fantasma.
Mappare le rotte: /wp-json/ e WP-CLI
L’elenco degli utenti è un dettaglio. Il vero lavoro, quando valutiamo la superficie esposta di un sito, è l’inventario completo delle rotte: ogni plugin può registrarne di proprie, e ognuna è un punto d’ingresso raggiungibile da Internet. La radice /wp-json/ restituisce l’indice con tutti i namespace e le rotte; da riga di comando, WP-CLI permette di vedere anche chi controlla i permessi di ciascuna:
# Dall'esterno: namespace e rotte pubbliche
curl -s https://www.esempio.it/wp-json/ | jq -r '.namespaces[]'
curl -s https://www.esempio.it/wp-json/ | jq -r '.routes | keys[]' | grep -v '^/wp/v2'
<?php
// rotte.php — uso: wp eval-file rotte.php | grep -v '^/wp/v2'
foreach ( rest_get_server()->get_routes() as $route => $handlers ) {
foreach ( $handlers as $h ) {
$cb = $h['permission_callback'] ?? null;
if ( is_array( $cb ) ) {
$cb = ( is_object( $cb[0] ) ? get_class( $cb[0] ) : $cb[0] ) . '::' . $cb[1];
} elseif ( $cb instanceof Closure ) {
$cb = 'closure';
} elseif ( ! $cb ) {
$cb = 'NESSUNO';
}
printf( "%s\t%s\t%s\n", $route, implode( ',', array_keys( $h['methods'] ) ), $cb );
}
}
L’output è una riga per ogni combinazione di rotta e metodo HTTP, con il controllo dei permessi associato. Le righe da guardare per prime sono quelle con __return_true o NESSUNO su metodi diversi da GET: una rotta pubblica che accetta POST o DELETE deve avere un’ottima ragione per esserlo.
Il confronto tra le due viste è a sua volta istruttivo. L’indice pubblico mostra le rotte che il sito dichiara a chiunque; wp eval-file carica WordPress per intero e vede anche le rotte registrate solo in certe condizioni. Poiché esegue il codice di tutti i plugin, su un sito che si sospetta compromesso conviene lanciarlo su una copia isolata, non in produzione: un plugin malevolo può fare qualunque cosa quando viene caricato, anche da riga di comando.
Il rischio vero: permission_callback troppo permissivi
Ogni rotta registrata con register_rest_route() ha un permission_callback, la funzione che decide se la richiesta può procedere. Da WordPress 5.5 il core segnala con un avviso le rotte che non lo dichiarano, e il manuale dell’API REST indica di usare __return_true per quelle intenzionalmente pubbliche. Il problema nasce quando __return_true finisce su una rotta che modifica dati o restituisce informazioni riservate, o quando il callback verifica solo che l’utente sia autenticato, e non che abbia il permesso di fare quella specifica operazione.
È una delle famiglie di vulnerabilità più frequenti nei bollettini dei plugin, spesso classificata come missing authorization: un endpoint per importare impostazioni, aggiornare un’opzione o creare un utente, raggiungibile da chiunque. Nei casi peggiori è lo stesso vettore che porta a una compromissione tramite 0-day. Una ricerca nel codice dei plugin installati fa emergere i candidati in pochi secondi:
# Rotte dichiarate pubbliche nei plugin: da leggere una per una
grep -rn --include='*.php' -A1 -B8 "__return_true" wp-content/plugins/ | grep -E "register_rest_route|methods|__return_true"
La differenza tra una rotta sicura e una vulnerabile, spesso, sta in una riga:
// Vulnerabile: chiunque può modificare le impostazioni del plugin
register_rest_route( 'mio-plugin/v1', '/impostazioni', array(
'methods' => 'POST',
'callback' => 'mio_plugin_salva_impostazioni',
'permission_callback' => '__return_true',
) );
// Corretto: solo chi può gestire le opzioni del sito
register_rest_route( 'mio-plugin/v1', '/impostazioni', array(
'methods' => 'POST',
'callback' => 'mio_plugin_salva_impostazioni',
'permission_callback' => function () {
return current_user_can( 'manage_options' );
},
) );
Anche is_user_logged_in() come controllo è quasi sempre insufficiente: su un sito con registrazione aperta, o con clienti WooCommerce, «autenticato» vuol dire chiunque abbia creato un account. Una rotta pubblica in lettura, come quella che restituisce gli orari di un negozio, è normale. Una rotta pubblica che scrive nel database o chiama update_option() va segnalata allo sviluppatore e, finché non viene corretta, filtrata. Per restare aggiornati sulle vulnerabilità di questo tipo già note, rimandiamo all’articolo sui bollettini di sicurezza.
Limitare l’elenco utenti senza rompere l’editor
L’editor a blocchi interroga l’endpoint degli utenti, per esempio per il selettore dell’autore, ma lo fa sempre come utente autenticato. Si può quindi rimuovere la rotta solo per le richieste anonime, con un mu-plugin che agisce sul filtro rest_endpoints. Il filtro viene applicato dopo il controllo di autenticazione, per cui una richiesta con cookie ma senza nonce valido risulta già anonima a quel punto, e le password applicative funzionano normalmente:
<?php
// wp-content/mu-plugins/riduci-enumerazione.php
// 1. Elenco e dettaglio utenti via REST solo per chi ha effettuato l'accesso
add_filter( 'rest_endpoints', function ( $endpoints ) {
if ( ! is_user_logged_in() ) {
unset( $endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)'] );
}
return $endpoints;
} );
// 2. Niente redirect da ?author=N allo slug (prima di redirect_canonical)
add_action( 'template_redirect', function () {
if ( isset( $_GET['author'] ) && ! is_user_logged_in() ) {
wp_safe_redirect( home_url( '/' ), 301 );
exit;
}
}, 1 );
// 3. Nessuna sitemap degli utenti
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {
return 'users' === $name ? false : $provider;
}, 10, 2 );
// 4. oEmbed senza link all'archivio autore
add_filter( 'oembed_response_data', function ( $data ) {
unset( $data['author_url'] );
return $data;
} );
Un’alternativa spesso consigliata è il filtro rest_authentication_errors, che la documentazione ufficiale mostra come esempio per richiedere l’autenticazione su tutta l’API. Funziona, ma è una misura molto più ampia: blocca per gli anonimi anche le rotte che il sito usa legittimamente nelle pagine pubbliche, come quelle di molti moduli di contatto, dei plugin di ricerca o del carrello di WooCommerce. Va valutata sito per sito, verificando le funzioni pubbliche una per una; il filtro mirato sulla sola rotta degli utenti non ha questo problema.
Dopo averlo installato, i comandi curl di prima devono restituire un errore rest_no_route per gli utenti, nessun Location con lo slug e una sitemap senza la sezione utenti. Da loggati, la bacheca e l’editor devono funzionare come prima: è la verifica da fare subito, aprendo un articolo e cambiandone l’autore. Se il tema mostra i link all’archivio autore e il sito non ne ha bisogno, conviene toglierli dal tema; se invece gli archivi autore servono, come in una rivista con più firme, lo slug è pubblico per scelta editoriale, e va bene così.
Regole nel web server: un filtro in più, non l’unico
Bloccare le scansioni prima che arrivino a PHP risparmia risorse, soprattutto sui server con molti siti. Con nginx si possono fermare le richieste anonime più comuni; la presenza del cookie di login è un indizio, non una prova, perché un cookie si può inventare, e per questo la regola nel server affianca il filtro PHP senza sostituirlo:
# nginx, nel blocco server del sito
location ~ ^/wp-json/wp/v2/users {
if ($http_cookie !~* "wordpress_logged_in_") { return 403; }
try_files $uri $uri/ /index.php?$args;
}
location = / {
if ($arg_author ~ "^[0-9]") { return 403; }
if ($arg_rest_route ~* "^/wp/v2/users") { return 403; }
try_files $uri $uri/ /index.php?$args;
}
# Apache, in .htaccess prima delle regole di WordPress
RewriteEngine On
RewriteCond %{QUERY_STRING} (^|&)author=[0-9] [NC]
RewriteRule ^$ - [F]
Due avvertenze. Il parametro rest_route può arrivare codificato, come %2Fwp%2Fv2%2Fusers, e una regola basata sul testo non lo intercetta: ancora una ragione per cui il controllo che conta è quello in PHP. E le integrazioni che usano l’API con password applicativa non hanno cookie di login: se un servizio esterno legge gli utenti, la prima regola nginx va adattata. Le stesse regole, con un WAF, si possono spostare ancora più a monte, come descriviamo nell’articolo sui WAF per WordPress.
Per capire quanto il problema sia rilevante sul tuo sito, prima ancora di intervenire, basta contare nei log chi sta enumerando e quanto spesso. Gli stessi indirizzi, di solito, compaiono poco dopo nelle richieste a wp-login.php e xmlrpc.php:
# Chi enumera gli utenti, e con quale frequenza
grep -E 'wp-json/wp/v2/users|[?&]author=[0-9]|rest_route=(/|%2F)wp(/|%2F)v2(/|%2F)users|wp-sitemap-users' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
Difesa in profondità, non sicurezza per oscuramento
Ridurre l’enumerazione è un intervento piccolo, a basso rischio, che toglie un po’ di rumore dai log e un’informazione a chi prepara un attacco mirato. Non sostituisce nessuna delle difese che contano: credenziali uniche con secondo fattore, plugin aggiornati, rotte REST con controlli dei permessi corretti, monitoraggio dei log di accesso. L’inventario delle rotte, invece, è uno degli strumenti più utili che conosciamo per capire quanto un sito è esposto, ed è la prima cosa che facciamo quando cerchiamo il punto d’ingresso di un’infezione. È anche uno dei punti della checklist dopo una bonifica.
L’inventario non si fa una volta sola. Ogni aggiornamento di un plugin può aggiungere rotte nuove, e ogni plugin installato «per provare» ne porta con sé altre. Salvare l’output di rotte.php e confrontarlo con quello del mese precedente, con un semplice diff, fa emergere le novità: una rotta pubblica comparsa senza che nessuno abbia installato nulla è, di per sé, un campanello d’allarme che merita di essere approfondito.
Se nei log trovi richieste riuscite a rotte REST di un plugin che non dovrebbero accettare dati da utenti anonimi, o se il sito mostra già segni di compromissione, contattaci: analizziamo il sito a livello applicativo e di sistema, dai log alle rotte registrate fino ai processi sul server, con una bonifica garantita in 6, 12 o 24 ore per ogni installazione WordPress.