
CSP avanzata: nonce, strict-dynamic e Trusted Types
Perché unsafe-inline non basta, come funzionano nonce e hash CSP per un sito statico, cos'è strict-dynamic e a cosa serve Trusted Types.
Nell’articolo su come funziona la Content Security Policy ho lasciato una cosa detta a metà: script-src 'self' blocca gli script esterni, ma se il tuo <head> è pieno di <script> inline — cosa che capita a chiunque usi Google Analytics, un tag manager, o semplicemente un framework che idrata componenti — finisci quasi sempre per aggiungere 'unsafe-inline' alla policy. E 'unsafe-inline' da solo vanifica buona parte del motivo per cui hai messo su una CSP: un attaccante che riesce a iniettare uno <script> nel tuo HTML (via un campo commento non sanificato, per dire) lo eseguirà comunque, perché la policy dice esplicitamente “va bene qualunque script inline”.
Questo articolo copre gli strumenti che risolvono davvero il problema: nonce, hash, strict-dynamic e Trusted Types.
Nonce: un lasciapassare diverso per ogni richiesta
Un nonce (“number used once”) è una stringa casuale generata dal server a ogni singola risposta HTTP, inserita sia nell’header CSP sia come attributo sui tag <script> che vuoi autorizzare:
Content-Security-Policy: script-src 'nonce-8f3jK2mZ9pQ'
<script nonce="8f3jK2mZ9pQ">
console.log('autorizzato');
</script>
Il browser esegue solo gli script il cui attributo nonce corrisponde a quello dichiarato nell’header di quella specifica risposta. Uno script iniettato da un attaccante non conosce il nonce corrente (è generato lato server, per richiesta, e non è predicibile), quindi anche con 'unsafe-inline' rimosso dalla policy, gli script legittimi continuano a funzionare e quelli iniettati vengono bloccati.
⚠ Attenzione
Il nonce richiede una risposta generata dinamicamente. Un nonce nuovo per ogni richiesta significa, per definizione, che serve un server che genera quella richiesta al momento — SSR, o comunque un backend che compone header e HTML insieme. Su un sito interamente statico (file HTML già pronti su un CDN, senza un processo server per ogni visita) non esiste un “per richiesta” a cui agganciare un nonce: l’HTML è lo stesso, identico, per tutti i visitatori finché non fai un nuovo deploy.
Hash: l’alternativa che funziona anche per un sito statico
Se il contenuto esatto di uno script inline è noto in anticipo e non cambia (non dipende dalla richiesta, solo dal build), puoi autorizzarlo con il suo hash SHA invece che con un nonce:
Content-Security-Policy: script-src 'sha256-qznLcsxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/Tng='
Il browser calcola l’hash del contenuto di ogni <script> inline e lo confronta con quelli autorizzati nella policy. Un attaccante può iniettare qualunque script vuole, ma il suo hash non corrisponderà a nessuno di quelli in whitelist, quindi il browser lo blocca — stessa protezione del nonce, senza bisogno di generare nulla per richiesta, perché l’hash si calcola una volta, a build time.
Il costo si sposta altrove: ogni script inline nel sito deve avere il suo hash calcolato e iniettato nella policy ad ogni build, e se anche un solo carattere del contenuto dello script cambia — inclusi dati serializzati dentro lo script, non solo il codice — l’hash cambia e va ricalcolato. Per un sito con pochi script inline statici (un tema salvato in localStorage, un piccolo bootstrap) è del tutto gestibile con una pipeline di build che calcola gli hash e li scrive nella configurazione degli header. Per un sito che idrata componenti con props serializzate diverse per ogni pagina — dove il contenuto dello script inline, quindi il suo hash, cambia letteralmente ad ogni pagina generata — quella pipeline diventa più complessa, ed è per questo che molti siti statici con isole interattive finiscono comunque per tenere 'unsafe-inline' su script-src: la scelta consapevole tra “sito statico semplice” e “CSP the-purist” a volte va davvero in questa direzione, purché resti una scelta e non una dimenticanza — e non l’ultima parola: vale la pena rivalutarla ogni volta che la superficie del sito cambia.
strict-dynamic: propagare la fiducia, non l’allowlist
Molti siti caricano script che a loro volta caricano altri script — un tag manager che inietta uno script di analytics, che ne inietta un altro ancora. Con un approccio ad allowlist di domini classico, dovresti elencare ogni singolo dominio nella catena, e la lista si rompe silenziosamente ogni volta che uno di quei servizi cambia provider o aggiunge un nuovo sotto-script.
strict-dynamic risolve il problema in modo diverso: uno script autorizzato via nonce o hash può caricare altri script dinamicamente, e quelli vengono considerati fidati automaticamente, senza bisogno di elencare i loro domini.
Content-Security-Policy: script-src 'nonce-8f3jK2mZ9pQ' 'strict-dynamic'
Con strict-dynamic presente, i browser che lo supportano ignorano eventuali allowlist di domini nella stessa direttiva (è un comportamento voluto, per compatibilità con browser più vecchi che non lo conoscono ancora e che quindi devono continuare a rispettare l’allowlist esplicita) — la fiducia si propaga dalla catena di script già verificata, non da un elenco di host mantenuto a mano.
Trusted Types: chiudere la porta del DOM XSS
Nonce, hash e strict-dynamic proteggono da script iniettati nell’HTML. Ma esiste un’altra classe di XSS che li aggira completamente: il DOM XSS, dove codice JavaScript legittimo del tuo sito passa dati non sanificati a un sink pericoloso come innerHTML, document.write o eval.
// Nessuno script "iniettato" qui — è il TUO codice a essere il problema
element.innerHTML = new URLSearchParams(location.search).get('nome');
Se un utente visita ?nome=<img src=x onerror=alert(1)>, quel markup finisce dentro innerHTML ed esegue, senza che nessuna policy su script-src possa impedirlo — non stiamo caricando uno script esterno né eseguendo un <script> inline, stiamo semplicemente scrivendo HTML nel DOM.
I Trusted Types chiudono questa porta a livello di browser: con la direttiva attiva, i sink pericolosi accettano solo oggetti TrustedHTML/TrustedScript creati tramite una policy che tu stesso definisci e controlli, non stringhe qualunque.
Content-Security-Policy: require-trusted-types-for 'script'
const policy = trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input),
});
element.innerHTML = policy.createHTML(userInput); // passa dalla sanitizzazione
element.innerHTML = userInput; // TypeError: bloccato dal browser
Con require-trusted-types-for 'script' attivo, qualunque assegnazione diretta a un sink pericoloso lancia un errore a runtime se non passa prima da una policy dichiarata — il browser stesso ti impedisce di scrivere quel bug, non solo un linter o una code review.
ℹ️ Nota
Supporto browser ancora disomogeneo: Trusted Types ha buon supporto sui browser basati su Chromium; su Firefox e Safari il supporto storicamente è arrivato più tardi o parziale. Prima di affidarti a require-trusted-types-for come unica difesa, verifica lo stato aggiornato su caniuse — sui browser che non lo supportano la direttiva viene semplicemente ignorata, quindi resta comunque prudente sanificare l’input a prescindere.
Da dove iniziare, in pratica
Se stai partendo da zero: uno stack con SSR (Next.js, Remix, o qualunque framework con un vero server per ogni richiesta) può generare un nonce per richiesta senza troppa fatica — è la soluzione più pulita, e vale la pena implementarla subito. Su un sito statico, valuta prima se hai davvero bisogno di script inline dinamici (spesso si può spostare la logica in un file .js esterno, autorizzabile con un semplice dominio in allowlist, niente hash da ricalcolare); se non puoi evitarli, gli hash calcolati a build time restano l’opzione più solida. strict-dynamic conviene appena la tua catena di script di terze parti cresce oltre 2-3 domini fissi. Trusted Types, infine, è la difesa più profonda ma anche quella con il costo di adozione più alto — richiede di passare in rassegna ogni punto del codice che scrive nel DOM — quindi ha senso soprattutto su applicazioni che gestiscono molto contenuto generato dagli utenti.
Per il resto — cosa sono default-src, come si testa una policy con Content-Security-Policy-Report-Only, come si legge un report di violazione — resta valido tutto quello che ho scritto nell’articolo base sulla CSP.