Siti web e accessibilità

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.

Elena Durante

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.

Il browser invia cookie, nonce e JSON. Il core verifica il contesto di autenticazione e i parametri; il plugin controlla il record e il permesso edit_post prima di salvare un campo autorizzato.
Responsabilità separate: il core gestisce il contesto REST; il plugin applica il perimetro del record. Parametri malformati possono essere rifiutati dal dispatcher prima del permission_callback.

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.

Un cookie senza nonce REST porta alla richiesta anonima nel flusso cookie di WordPress. Un nonce non valido dà 403. Con nonce valido resta da verificare il permesso sul singolo record.
Il nonce protegge il flusso cookie da richieste involontarie; non conferisce permessi e non è un token monouso. Il test HTTP distingue questo percorso dal dispatch interno CLI.

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.

Nel laboratorio, con ruoli WordPress standard: anonimo 401; sottoscrittore e altro autore 403; proprietario ed editor 200. L’identità dell’oggetto proviene sempre dall’URL.
Matrice verificata sui ruoli standard e record draft del laboratorio. La regola è la capability sull’oggetto: ruoli personalizzati o altri stati richiedono nuove prove.

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.

La label entra dal JSON, viene validata e normalizzata, salvata tramite Metadata API e poi codificata secondo il contesto di uscita. Anche i dati già nel database restano non fidati.
Validazione, normalizzazione e codifica dell’output risolvono compiti distinti. Il test del valore HTML legacy bypassa intenzionalmente la scrittura del plugin per verificare l’output.

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.

Esempi di richieste verificate su dati fittizi
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

  1. 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.
  2. 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.
  3. 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.
  4. 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.