Sicurezza nei plugin WordPress: permessi, nonce, dati e REST API
Un laboratorio WordPress con codice originale, quattro diagrammi e test riproducibili: permessi per oggetto, nonce, dati e output.
Un plugin può salvare il dato giusto durante una dimostrazione e accettare comunque una richiesta che avrebbe dovuto rifiutare. Per valutare il suo comportamento dobbiamo provare anche chi non ha accesso, chi modifica l’identificativo del record, chi invia un campo inatteso e chi incontra un valore già presente nel database.
In questo approfondimento seguiamo un laboratorio originale Econgiunzione: un piccolo plugin che aggiorna un’etichetta attraverso le REST API di WordPress. Il codice completo, i diagrammi e i test sono scaricabili. Ogni scelta ha un perimetro dichiarato e una prova associata.
Ambiente verificato il 16 ottobre 2026: WordPress 7.1.3, PHP CLI 8.2.14, plugin Security Lab 1.0.0. Le prove usano utenti e record fittizi in locale. Non documentano dati di clienti né costituiscono una certificazione del sito. Per il funzionamento del core puoi partire dalla guida all’architettura WordPress, database e ciclo della richiesta.
Il laboratorio: una funzione piccola, un contratto preciso
Security Lab registra il tipo di contenuto egsl_item, senza pagina pubblica né esposizione automatica nelle REST API del core. I record dimostrativi sono bozze con un autore; il dato modificabile è un solo metadato, _egsl_label. La pagina Strumenti → Security Lab permette di creare un record e aggiornare la propria etichetta.
Il percorso REST è /wp-json/eg-security-lab/v1/items/{id}. GET legge il record autorizzato; POST aggiorna soltanto label. Non sono implementati elenco pubblico, cancellazione, upload, pagamenti o collegamenti a servizi esterni. Questa riduzione rende leggibile il rapporto fra operazione, dato e permesso.
Il contratto accetta una stringa UTF-8 di 1–80 caratteri, dopo la rimozione degli spazi esterni. Contiamo i punti di codice Unicode, non i grafemi: un carattere visivo composto può occuparne più di uno. HTML, caratteri di controllo e valori che la sanitizzazione altererebbe sono rifiutati. Il limite aggiuntivo di 1.024 byte è un controllo locale sull’argomento: non sostituisce i limiti alla dimensione delle richieste del server.
Puoi scaricare il plugin installabile oppure il laboratorio completo con test e diagrammi. Sono esempi GPL-2.0-or-later pensati per un ambiente di sviluppo.
Quali richieste vogliamo rifiutare
Partiamo dal comportamento atteso. Un visitatore anonimo non deve leggere o cambiare questi record. Un sottoscrittore non acquisisce quel diritto entrando nel sito. Un autore può lavorare sul proprio record, ma non su quello di un altro autore. Un editor può intervenire sui record altrui perché, nella configurazione standard verificata, possiede la capability necessaria.
La parte da proteggere non coincide con il menu amministrativo. Nascondere un pulsante può aiutare l’interfaccia; la richiesta diretta all’endpoint resta possibile. Il controllo decisivo viene quindi eseguito sul server, per la singola operazione. La guida alla dashboard WordPress personalizzata approfondisce invece organizzazione delle schermate e autonomia editoriale.
Confini e controlli della richiesta. Su schermi piccoli scorri lo schema, oppure apri lo SVG ingrandibile.
Nel modello distinguiamo il contesto di autenticazione del core, il dispatcher REST e la logica del plugin. Non è una sequenza universale da copiare in ogni applicazione: per esempio, i parametri malformati possono essere respinti dal dispatcher prima di arrivare al permission_callback. Il test deve controllare sia la risposta sia l’assenza della modifica.
Autenticazione, autorizzazione e nonce sono controlli diversi
L’autenticazione stabilisce quale identità sta effettuando la richiesta. L’autorizzazione decide se quell’identità può eseguire l’operazione sul record indicato. Nel nostro flusso dal browser, il nonce contribuisce a proteggere l’uso della sessione cookie da richieste involontarie provenienti da altri contesti.
La documentazione dell’autenticazione REST descrive un comportamento da verificare esplicitamente: nel flusso cookie, senza nonce REST WordPress imposta l’utente corrente a zero. Nel laboratorio la richiesta viene poi rifiutata con 401. Un nonce errato viene invece respinto dal core con 403 e codice rest_cookie_invalid_nonce.
Il nonce viene creato con l’azione wp_rest e inviato nell’header X-WP-Nonce. Non lo inseriamo nell’articolo con un valore reale né lo conserviamo nel repository. Un nonce valido non attribuisce il permesso di modificare i dati di un’altra persona.
I nonce WordPress non sono token monouso e non sono una difesa antireplay: non vanno usati come credenziali o come sostituti dell’autorizzazione. Le Application Passwords appartengono a un altro contesto di autenticazione, utile alle integrazioni; il laboratorio non implementa quel percorso.
Cookie, nonce e autorizzazione. Su schermi piccoli scorri lo schema, oppure apri lo SVG ingrandibile.
Il permesso va verificato sul record richiesto
Il CPT dimostrativo usa il mapping delle capability dei post. Il plugin chiede current_user_can('edit_post', $id) e lascia a WordPress la risoluzione del permesso. Non confronta il nome del ruolo con una stringa come “administrator”. La documentazione di ruoli e capability è il riferimento per comprendere questa distinzione.
Prima del controllo verifichiamo che il record esista, appartenga a egsl_item e non sia nel cestino. Anche un editor autorizzato a modificare altri contenuti non deve usare questo endpoint per aggiornare un articolo normale. La stessa funzione di autorizzazione viene richiamata dal callback come controllo difensivo.
function permission(\WP_REST_Request $request) {
if (!is_user_logged_in()) {
return new \WP_Error('egsl_auth_required', 'Accesso richiesto.', ['status' => 401]);
}
// URL identity must not be overridden by a JSON or query-string parameter.
$id = (int) ($request->get_url_params()['id'] ?? 0);
$post = get_post($id);
if (!$post || $post->post_type !== TYPE || $post->post_status === 'trash') {
return new \WP_Error('egsl_not_found', 'Record non disponibile.', ['status' => 404]);
}
if (!current_user_can('edit_post', $id)) {
return new \WP_Error('egsl_forbidden', 'Operazione non consentita.', ['status' => 403]);
}
return true;
}
Questo è un estratto del codice scaricabile, nel namespace del plugin. Il corpo completo può essere confrontato con il pacchetto verificato. La matrice seguente riguarda i ruoli standard e i record draft della prova: un’installazione con ruoli modificati o stati diversi richiede test propri.
La matrice dei permessi per oggetto. Su schermi piccoli scorri lo schema, oppure apri lo SVG ingrandibile.
L’identità nell’URL non deve cambiare a metà richiesta
Una richiesta può contenere parametri nel percorso, nella query e nel corpo JSON. Nel laboratorio scegliamo una regola esplicita: l’identità del record arriva sempre da get_url_params(). Sia l’autorizzazione sia il salvataggio leggono quella stessa origine.
La prova usa due autori fittizi. Il secondo invia una richiesta verso il record del primo, aggiungendo nella query l’ID del proprio record. La risposta rimane 403. Un id aggiunto al JSON viene invece rifiutato come campo non ammesso. Verifichiamo che il tentativo non sposti l’operazione su un altro oggetto.
È una scelta verificabile del nostro contratto, non un’affermazione secondo cui ogni uso di get_param() sarebbe insicuro. In un progetto diverso occorre stabilire da dove proviene ogni identificativo e mantenerne coerente l’uso fra controllo e operazione.
Validare, normalizzare e limitare i campi in scrittura
La validazione risponde alla domanda “questo valore soddisfa il contratto?”. La sanitizzazione può modificarlo. Nel nostro caso vogliamo evitare che un’etichetta contenente markup venga trasformata silenziosamente in un testo diverso: la richiesta deve fallire.
La funzione condivisa verifica tipo, UTF-8, lunghezza e caratteri ammessi. Rimuove soltanto gli spazi esterni; confronta poi il risultato con sanitize_text_field(). Se quest’ultima cambierebbe il valore, il laboratorio lo rifiuta. La stessa politica esclude anche alcune sequenze percentuali che la funzione eliminerebbe: è una limitazione intenzionale di questa etichetta, da rivalutare per altri dati.
function normalize_label($value) {
if (!is_string($value) || strlen($value) > 1024 || wp_check_invalid_utf8($value, true) !== $value) {
return new \WP_Error('egsl_invalid_label', 'Usa una stringa UTF-8 di 1–80 caratteri.', ['status' => 400]);
}
$label = trim($value);
$length = preg_match_all('/./us', $label);
if (!$length || $length > 80 || preg_match('/[\x00-\x1F\x7F]/u', $value)
|| sanitize_text_field($label) !== $label) {
return new \WP_Error('egsl_invalid_label', 'Usa testo semplice, senza HTML o caratteri di controllo, massimo 80 caratteri.', ['status' => 400]);
}
return $label;
}
La route dichiara l’argomento richiesto e i callback di validazione e normalizzazione. Il callback di aggiornamento controlla inoltre che il JSON contenga esclusivamente label. Un campo come post_author non viene passato a una funzione generica di aggiornamento e non viene ignorato fingendo che la richiesta sia interamente accettata.
Questa allowlist protegge il perimetro della scrittura. Per un prezzo, una data, un indirizzo o un documento HTML servirebbe un contratto differente. Copiare la funzione senza ridefinire il significato del dato farebbe perdere la parte più utile dell’esempio.
Salvare con le API e controllare il valore persistito
Il laboratorio usa le Metadata API: non costruisce query SQL concatenando dati della richiesta. Il campo e il record aggiornabili sono scelti dal codice, dopo i controlli. Non permettiamo al chiamante di scegliere il nome della chiave meta.
update_post_meta() applica l’unslashing al valore ricevuto. Per conservare apostrofi e backslash letterali passiamo wp_slash($label), poi rileggiamo il dato. Il test usa una stringa con apostrofo, simbolo & e percorso contenente backslash: deve tornare identica.
function update_item(\WP_REST_Request $request) {
$allowed = permission($request);
if (is_wp_error($allowed)) return $allowed;
$data = $request->get_json_params();
if (!is_array($data)) {
return new \WP_Error('egsl_json_required', 'Invia un oggetto JSON.', ['status' => 415]);
}
if (array_keys($data) !== ['label']) {
return new \WP_Error('egsl_unexpected_fields', 'È ammesso soltanto il campo label.', ['status' => 400]);
}
$label = normalize_label($data['label']);
if (is_wp_error($label)) return $label;
$id = (int) $request->get_url_params()['id'];
// Metadata API unslashes its input. Preserve literal apostrophes/backslashes.
update_post_meta($id, META, wp_slash($label));
if ((string) get_post_meta($id, META, true) !== $label) {
return new \WP_Error('egsl_write_failed', 'Salvataggio non riuscito.', ['status' => 500]);
}
return read_item($request);
}
La verifica dopo il salvataggio gestisce anche un dettaglio dell’API: un valore già uguale può produrre un ritorno false senza indicare un nuovo errore di scrittura. L’esempio confronta quindi il dato persistito. Non offre però controllo di concorrenza: se due utenti ammessi aggiornano insieme il campo, non è prevista una versione del record o una transazione applicativa.
Il dato nel database resta non fidato quando viene mostrato
La protezione dell’output dipende dal contesto. Nell’HTML del pannello usiamo esc_html() per il testo e esc_attr() per il valore dell’input. La documentazione sull’escaping descrive le funzioni per i diversi contesti; una stringa preparata per il testo HTML non è automaticamente adatta a un URL o a JavaScript.
La risposta REST è serializzata come JSON. Il JavaScript del plugin assegna il messaggio e il dato a textContent, senza interpretarli come markup. Il server fornisce URL e nonce tramite JSON incorporato con le opzioni di codifica appropriate; il nonce è prodotto per la sessione corrente.
Per verificare l’output senza affidarci al filtro d’ingresso, il test inserisce direttamente nel metadato un valore HTML fittizio con un gestore di evento. Poi apre il pannello: il valore deve essere leggibile come testo, non deve comparire un nuovo elemento immagine e il codice non deve essere eseguito. La verifica riguarda quel percorso di rendering, senza pretendere di coprire tutte le forme di XSS.
Il percorso di un valore non fidato. Su schermi piccoli scorri lo schema, oppure apri lo SVG ingrandibile.
Le prove eseguite: risultati e limiti di ciascun livello
Il pacchetto contiene 24 controlli di integrazione PHP e 22 controlli HTTP/browser. Gli esiti sono registrati nel report dei test, con data, versioni e casi verificati. I conteggi descrivono questa esecuzione, non una percentuale di sicurezza.
| Caso | Esito atteso e rilevato | Cosa controlliamo |
|---|---|---|
| Anonimo, richiesta valida | 401 | Nessuna scrittura |
| Cookie senza nonce REST | 401 | Identità azzerata dal flusso cookie |
| Nonce errato o di un’altra sessione | 403 | Rifiuto del core REST |
| Sottoscrittore o autore di un altro record | 403 | Permesso sul singolo oggetto |
| Record WordPress estraneo al demo | 404 | Perimetro del tipo di contenuto |
| HTML, array, campo extra o JSON malformato | 400 | Contratto e dato precedente invariato |
| Autore proprietario, etichetta valida | 200 | Valore salvato e riletto |
| Valore HTML legacy nel database | Test superato | Testo visibile, nessun elemento o script iniettato |
La suite PHP seleziona l’utente corrente ed esegue rest_do_request(): è utile per autorizzazione, argomenti e persistenza. Da sola non dimostra il comportamento dell’autenticazione cookie. Per questo la seconda suite invia richieste HTTP con sessioni reali del laboratorio e apre la pagina amministrativa in Chrome tramite Playwright.
Le fixture sono quattro utenti temporanei e tre record fittizi, compreso un normale articolo draft usato per la prova di esclusione. Il cleanup controlla la loro identità prima di eliminarli. Cookie e nonce del test restano fuori dai pacchetti pubblici. L’esecuzione ha incontrato un timeout dello stack locale; il controllo HTTP è stato completato dopo la verifica del percorso, senza registrare il timeout come test di sicurezza riuscito.
Come riprodurre il laboratorio
- Prepara una copia di sviluppo WordPress su un dominio
.local, con dati fittizi. Il plugin dichiara WordPress minimo 6.8 e PHP minimo 8.2; la verifica qui documentata copre soltanto le versioni indicate sopra. - Installa lo ZIP del plugin e attivalo. In Strumenti → Security Lab crea un record dimostrativo. Prova il salvataggio e controlla nella rete del browser l’endpoint, l’header nonce e la risposta.
- Estrai il laboratorio completo. Esegui la suite PHP indicando il percorso assoluto al tuo
wp-load.php. Per i test HTTP segui il README: serve una cartella privata per le sessioni temporanee e Playwright con Chrome. - Esegui il cleanup delle fixture e disattiva il plugin al termine. Le eventuali schede create manualmente si eliminano separatamente: la disinstallazione non cancella automaticamente contenuti.
Il README riporta i comandi completi e le dipendenze. Gli script di prova rifiutano host diversi da .local; non sono strumenti da lanciare su siti di terzi o sulla produzione. Il codice del plugin non aggiunge ruoli, utenti o record all’attivazione e non altera i permessi dei ruoli esistenti.
Dal laboratorio a un plugin per il lavoro reale
Un’applicazione che gestisce clienti, documenti o ordini richiede altre decisioni: proprietà dei dati, ruoli effettivi, stati del processo, conservazione, log, concorrenza, gestione degli errori e limiti delle richieste. L’esempio non implementa rate limiting, audit log, protezione dagli aggiornamenti simultanei, autenticazione esterna o supporto multisito verificato.
La lezione trasferibile è il metodo: descrivere l’operazione, identificare il record, verificare il permesso, accettare un insieme limitato di dati e provare anche i rifiuti. Gli aggiornamenti e le dipendenze rimangono parte del lavoro, come descritto nella guida agli aggiornamenti WordPress prima e dopo.
Se stai valutando una funzione per il tuo sito, il servizio di sviluppo di plugin WordPress personalizzati parte dal processo e dagli accessi necessari. Raccontaci chi deve fare cosa e su quali dati: è una base concreta per definire perimetro e verifiche.
Codice, diagrammi e riferimenti ufficiali
Plugin e test sono originali del laboratorio Econgiunzione, distribuiti con licenza GPL-2.0-or-later. I quattro SVG sono impaginati da un generatore Python; i sorgenti Mermaid e PlantUML descrivono gli stessi modelli e possono avere un layout diverso nei rispettivi renderer. La hero è una metafora editoriale generata con AI, separata dai diagrammi tecnici.
- Plugin installabile 1.0.0; sorgenti, test, diagrammi e istruzioni; hash SHA-256 dei pacchetti.
- WordPress Common APIs: Security e registrazione di endpoint REST personalizzati.
- register_post_type(), capability, nonce e autenticazione REST.
- Le fonti spiegano le API; gli esiti del laboratorio derivano dai test allegati. Il report va rieseguito se cambiano codice, versione WordPress, ruoli o configurazione di autenticazione.