Gli skimmer JavaScript che rubano i dati delle carte al checkout e le pagine di falsi aggiornamenti e falsi CAPTCHA che colpiscono i visitatori hanno un punto in comune: per funzionare devono convincere il browser dell’utente a eseguire uno script che il proprietario del sito non ha mai scelto. La Content Security Policy, o CSP, è il meccanismo con cui il sito dice al browser quali script sono legittimi. Tutto il resto non viene eseguito.
Una CSP non impedisce che un attaccante inietti codice nel sito. Impedisce che quel codice venga eseguito nel browser dei visitatori, e fa arrivare una segnalazione a chi gestisce il sito.
Su WordPress, però, una CSP rigorosa è notoriamente difficile: script in linea ovunque, plugin che caricano risorse da decine di domini, un pannello di amministrazione che ne usa ancora di più. In questo articolo vediamo come introdurla per gradi, partendo dalla modalità di sola segnalazione, quali approcci funzionano con WordPress e quali no, come configurarla su NGINX e Apache e cosa si rompe più spesso.
Cosa fa una CSP contro gli script iniettati
La CSP è un’intestazione HTTP, Content-Security-Policy, che elenca per ogni tipo di risorsa le origini consentite. La direttiva più importante per la sicurezza è script-src, che stabilisce quali script il browser può eseguire. Un’iniezione tipica in un sito WordPress compromesso aggiunge un <script src="https://dominio-dell-attaccante/..."> nel tema o nel database, oppure un blocco di codice in linea offuscato: se la policy non consente quel dominio né gli script in linea non autorizzati, il browser si rifiuta di eseguirli.
La seconda linea di difesa è connect-src, che limita le destinazioni delle richieste fatte dagli script con fetch o XMLHttpRequest. Uno skimmer che riesce a girare, per esempio perché è stato inserito in un file JavaScript legittimo del sito, deve comunque spedire i dati raccolti da qualche parte: con connect-src ristretto ai domini necessari, e form-action che limita la destinazione dei moduli, l’invio fallisce. E in entrambi i casi, se la policy prevede un indirizzo di segnalazione, il browser del visitatore invia un report con la risorsa bloccata: spesso è il primo segnale che il sito è stato compromesso, prima ancora di una segnalazione di Google.
I limiti vanno detti subito. Una CSP non protegge il server, non rimuove il codice iniettato e non vede nulla di quello che succede in PHP o nel database. Non ferma nemmeno uno script malevolo servito da un dominio che la policy consente: se si autorizza un intero servizio di hosting di file JavaScript, un attaccante può pubblicare lì il suo codice. È un livello di difesa per i visitatori, che affianca il WAF e il monitoraggio dell’integrità dei file senza sostituirli, e funziona tanto meglio quanto più la policy è precisa.
Perché con WordPress è difficile
La difficoltà principale sono gli script in linea. WordPress e i plugin inseriscono nella pagina moltissimi piccoli blocchi <script> senza attributo src: dati di configurazione passati con wp_localize_script, codice aggiunto con wp_add_inline_script, frammenti di tracciamento incollati nel tema, codice generato dai costruttori di pagine. Il modo più semplice per non rompere nulla è aggiungere 'unsafe-inline' a script-src, ma così si consente qualunque script in linea, compreso quello iniettato: contro gli attacchi che ci interessano la policy perde gran parte del suo valore.
Poi ci sono le dipendenze esterne. Un sito aziendale medio carica font, statistiche, mappe, video incorporati, un widget di chat, un sistema di gestione del consenso ai cookie; un e-commerce aggiunge gateway di pagamento, antifrode, recensioni. Ciascuno richiede i suoi domini in direttive diverse, e alcuni caricano a loro volta script da altri domini. Infine c’è il pannello di amministrazione, che ha esigenze molto diverse dal sito pubblico e che conviene trattare a parte, come vedremo.
Partire in sola segnalazione e raccogliere i report
Il modo sicuro per introdurre una CSP su un sito esistente è l’intestazione Content-Security-Policy-Report-Only. Secondo MDN, il browser valuta la policy e segnala le violazioni senza bloccare nulla; non può essere inserita con un tag <meta>, va inviata come intestazione HTTP. Per ricevere i report servono due direttive: report-to, che rimanda a un endpoint dichiarato nell’intestazione Reporting-Endpoints, e la più vecchia report-uri. La pagina di MDN su report-to consiglia di indicarle entrambe finché il supporto non è universale: i browser che capiscono report-to ignorano l’altra.
Per raccogliere i report bastano poche righe di PHP, separate da WordPress, che salvano il corpo della richiesta in un file fuori dalla docroot. Con un po’ di traffico si accumulano in fretta, quindi il file va ruotato; chi preferisce può usare un servizio esterno di raccolta.
<?php
// /var/www/esempio.it/htdocs/csp-report.php — salva i report CSP fuori dalla docroot
$corpo = file_get_contents( 'php://input', false, null, 0, 16384 );
if ( is_string( $corpo ) && '' !== $corpo ) {
$riga = gmdate( 'c' ) . ' ' . str_replace( "\n", ' ', $corpo ) . "\n";
file_put_contents( '/var/log/csp/esempio.it.log', $riga, FILE_APPEND | LOCK_EX );
}
http_response_code( 204 );
# Quali risorse verrebbero bloccate più spesso (formato report-uri e report-to)
grep -oE '"(blocked-uri|blockedURL)":"[^"]*"' /var/log/csp/esempio.it.log | sort | uniq -c | sort -rn | head -20
# Per quale direttiva
grep -oE '"(effective-directive|effectiveDirective)":"[^"]*"' /var/log/csp/esempio.it.log | sort | uniq -c | sort -rn
I report vanno letti con spirito critico. Molte violazioni non dipendono dal sito ma dalle estensioni installate nei browser dei visitatori, che iniettano script propri: si riconoscono dagli schemi chrome-extension o moz-extension e si ignorano. Quelle che contano sono le risorse del sito e dei servizi usati davvero, da autorizzare, e i domini che nessuno riconosce, da indagare subito. Dopo alcune settimane senza violazioni nuove e legittime, la policy è pronta per passare in modalità attiva.
Allowlist, hash e nonce
Ci sono tre modi per dire al browser quali script sono legittimi. Il più intuitivo è l’allowlist: si elencano i domini da cui caricare script, per esempio 'self' più il dominio del gateway di pagamento. È semplice, ma la guida di MDN alla CSP ricorda che le policy basate su allowlist sono difficili da scrivere bene, finiscono spesso per autorizzare domini che permettono di aggirarle e diventano enormi quando si integrano servizi di terze parti. E soprattutto non risolvono il problema degli script in linea.
Gli hash autorizzano un singolo script in linea tramite l’impronta SHA-256 del suo contenuto: se il codice cambia di un carattere, l’impronta non corrisponde più. Funzionano bene per script statici e non richiedono di modificare l’HTML, ma si rompono a ogni aggiornamento di un plugin che cambia il proprio frammento. I nonce sono un valore casuale generato a ogni risposta, inserito nell’intestazione e come attributo nonce in ogni tag <script> legittimo: uno script iniettato non conosce il valore e non viene eseguito. La documentazione di script-src descrive anche 'strict-dynamic': la fiducia data a uno script con nonce o hash si estende agli script che quello carica, e le allowlist vengono ignorate. È la base della cosiddetta strict CSP che MDN raccomanda.
Nonce e strict-dynamic su WordPress
Da WordPress 6.4 i nonce sono praticabili anche sul front end. Come spiega l’annuncio sulle modifiche al caricamento degli script in WordPress 6.4, tutti gli script stampati sul sito pubblico e nella pagina di login, compresi quelli accodati con wp_enqueue_script, wp_add_inline_script e wp_localize_script, passano per wp_get_script_tag() e wp_get_inline_script_tag(), i cui attributi si modificano con i filtri wp_script_attributes e wp_inline_script_attributes. Un mu-plugin di poche righe genera il nonce, lo aggiunge a ogni tag e invia l’intestazione:
<?php
// wp-content/mu-plugins/csp-nonce.php — nonce per gli script del sito pubblico
function esempio_csp_nonce() {
static $nonce = null;
if ( null === $nonce ) {
$nonce = base64_encode( random_bytes( 18 ) );
}
return $nonce;
}
function esempio_csp_attributi( $attributi ) {
$attributi['nonce'] = esempio_csp_nonce();
return $attributi;
}
add_filter( 'wp_script_attributes', 'esempio_csp_attributi' );
add_filter( 'wp_inline_script_attributes', 'esempio_csp_attributi' );
// send_headers scatta solo per le richieste del sito pubblico
add_action( 'send_headers', function () {
header( "Content-Security-Policy-Report-Only: script-src 'nonce-" . esempio_csp_nonce() . "' 'strict-dynamic'; "
. "object-src 'none'; base-uri 'none'; report-uri /csp-report.php; report-to csp" );
header( 'Reporting-Endpoints: csp="https://www.esempio.it/csp-report.php"' );
} );
Due problemi pratici restano. Il primo sono i plugin e i temi che stampano i propri tag <script> direttamente nell’HTML invece di usare le API di WordPress: non ricevono il nonce e vengono bloccati. I report li mostrano uno per uno, e la correzione va fatta nel codice o chiedendola all’autore. Il secondo è la cache di pagina: un nonce deve cambiare a ogni risposta, ma una pagina servita dalla cache contiene sempre lo stesso valore, e un’intestazione che non corrisponde, o un nonce fisso e prevedibile, annulla la protezione. Con una cache di pagina completa servono un sistema che sostituisca il nonce a ogni risposta, come alcune CDN consentono, oppure gli hash al posto dei nonce.
Una policy separata per wp-admin
Il pannello di amministrazione è il posto in cui una CSP rigorosa è più difficile e, per gli attacchi di cui parliamo qui, meno urgente: gli skimmer e i falsi aggiornamenti colpiscono i visitatori del sito pubblico. L’annuncio di WordPress 6.4 stesso indica che l’adozione delle nuove funzioni nel pannello era un lavoro ancora in corso, e i plugin aggiungono in amministrazione moltissimo codice in linea. Una strategia ragionevole è applicare al sito pubblico, e soprattutto alle pagine di carrello e checkout, la policy più severa, e al pannello una policy distinta che non tocca script-src ma blocca comunque ciò che non serve mai: object-src 'none', base-uri 'self', frame-ancestors 'self' contro l’incorniciamento del pannello in siti altrui, form-action 'self'. È meno di quanto si vorrebbe, ma è meglio di niente, e i report dicono quando si può fare di più.
Configurazione su NGINX e Apache
Una policy a base di allowlist e hash, senza nonce, si imposta direttamente nel web server, senza toccare WordPress. Un punto di partenza per il sito pubblico, da adattare ai servizi realmente usati; i domini del gateway di pagamento sono indicativi, e l’elenco completo va preso dalla documentazione di ciascun fornitore:
# NGINX, blocco server: policy del sito pubblico (prima in Report-Only)
set $csp "default-src 'self'; script-src 'self' https://js.stripe.com; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri /csp-report.php; report-to csp";
add_header Content-Security-Policy-Report-Only $csp always;
add_header Reporting-Endpoints 'csp="https://www.esempio.it/csp-report.php"' always;
# wp-admin: policy distinta e più permissiva
location /wp-admin/ {
add_header Content-Security-Policy "object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'" always;
try_files $uri $uri/ /index.php?$args;
}
Attenzione a un comportamento di NGINX già citato nella checklist di hardening: una location che definisce anche un solo add_header non eredita quelli del blocco server, quindi le altre intestazioni di sicurezza vanno ripetute al suo interno. Inoltre le richieste ai file PHP di /wp-admin/ sono gestite dalla location per PHP, non da questa: la policy per il pannello va aggiunta anche lì, con una condizione sul percorso, o inviata da PHP. Su Apache 2.4 le espressioni rendono la distinzione più semplice:
# Apache 2.4 (mod_headers), nel virtual host
<If "%{REQUEST_URI} !~ m#^/wp-admin/#">
Header always set Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://js.stripe.com; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; report-uri /csp-report.php"
</If>
<Else>
Header always set Content-Security-Policy "object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'"
</Else>
Nella policy di esempio style-src contiene 'unsafe-inline': su WordPress gli stili in linea sono ovunque, e il rischio che rappresentano è molto inferiore a quello degli script. È un compromesso accettabile, mentre 'unsafe-inline' in script-src non lo è.
Dopo ogni modifica conviene controllare da riga di comando che l’intestazione arrivi davvero, e che sia quella giusta per ciascun percorso. Una policy che non compare nella risposta, perché sovrascritta da un plugin, persa in una location o rimossa da una CDN, è un errore frequente e silenzioso:
# Quale policy riceve il sito pubblico, e quale il pannello
curl -sI https://www.esempio.it/ | grep -i '^content-security-policy'
curl -sI https://www.esempio.it/wp-admin/ | grep -i '^content-security-policy'
Cosa si rompe, e come accorgersene
Le rotture più comuni sono sempre le stesse: i gateway di pagamento, che usano iframe e chiamate verso i propri domini, e quindi richiedono voci in frame-src e connect-src oltre che in script-src; gli strumenti di statistica e i gestori di tag, che caricano a loro volta altri script; i video incorporati e le mappe; i sistemi di consenso ai cookie; i moduli che inviano a servizi esterni, bloccati da form-action. Il sintomo, nel browser, è un errore nella console degli strumenti per sviluppatori che nomina la direttiva violata. Il rimedio è non attivare mai una modifica senza passare prima dalla modalità di sola segnalazione, e fare un acquisto di prova completo dopo ogni cambio.
Una volta attiva, la policy diventa anche un sistema di allarme: un report che segnala un dominio mai visto, su una pagina del sito, è un indizio da prendere sul serio, perché spesso è il primo segno di un’iniezione nel tema o nel database. Se un report ti ha mostrato uno script che non sai spiegare, o se il sito è già stato segnalato per codice malevolo, scrivici: rimuoviamo il codice dai file, dal database e dal sistema, cron e processi compresi, con una pulizia garantita in 6, 12 o 24 ore per ogni installazione WordPress, e ti aiutiamo a mettere a punto la policy sul sito ripulito.