Il browser esegue tutto ciò che il tuo server gli consegna e allega i cookie a quasi tutto ciò che il tuo dominio riceve: da queste due abitudini nascono XSS e CSRF, e la CSP è la rete che prende ciò che le difese precedenti lasciano passare. Sono tre problemi diversi con tre difese diverse, e nessuna sostituisce le altre. Qui le riproduci su un'applicazione di prova, vedi che la correzione più ovvia (codificare l'HTML) ne ripara solo due casi su tre, e sai dire che cosa il browser fa per te e che cosa devi scrivere tu.
XSS: il dato non fidato diventa codice
Secondo la guida di OWASP, il cross-site scripting avviene quando l'applicazione mette dati non fidati in una pagina in modo che il browser li tratti come codice: un commento che doveva restare testo e invece esegue JavaScript. I dati non fidati sono tutto ciò che l'applicazione non ha creato lei: campi di un modulo, parametri dell'indirizzo, intestazioni, contenuto di un database riempito da altri. Le varianti si distinguono dal percorso: riflesso (il payload torna nella risposta alla stessa richiesta), memorizzato (sta nel database e colpisce chi apre la pagina) e basato sul DOM (lo script della pagina lo scrive nel documento senza passare dal server). Nella Top 10 2025 tutto questo sta in A05, insieme alla SQL injection.
La difesa principale è la codifica dell'output in base al contesto, e il contesto conta. Lo script seguente espone /cerca, che inserisce lo stesso parametro in tre posti: testo di un paragrafo, valore di un attributo e href di un collegamento. Lo stesso programma gira a tre livelli: nessuna difesa, solo la funzione incorpora (che sostituisce & < > " '), e incorpora più una lista ammessa di schemi per gli URL. L'applicazione è di prova, nessuna porta è aperta: inject() di Fastify consegna la richiesta direttamente all'applicazione.
import Fastify from 'fastify';
const incorpora = (testo) =>
String(testo).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
// Un link è sicuro solo se lo schema è in una lista ammessa.
function urlSicuro(valore) {
try {
const u = new URL(valore);
return ['http:', 'https:'].includes(u.protocol) ? u.href : '#';
} catch {
return '#';
}
}
function costruisci(livello) {
// livello 0: niente; 1: solo codifica HTML; 2: codifica più allowlist sugli schemi degli URL
const app = Fastify();
app.get('/cerca', async (req, reply) => {
const { q = '', sito = '' } = req.query;
const testo = livello >= 1 ? incorpora(q) : q;
const valoreAttr = livello >= 1 ? incorpora(q) : q;
const href = livello === 2 ? incorpora(urlSicuro(sito)) : livello === 1 ? incorpora(sito) : sito;
reply.type('text/html');
return `<p>Risultati per: ${testo}</p><input value="${valoreAttr}"><a href="${href}">sito</a>`;
});
return app;
}
const prove = [
['contesto testo', { q: '<img src=x onerror=alert(1)>' }, (b) => b.includes('<img src=x onerror=alert(1)>')],
['contesto attributo', { q: '" autofocus onfocus="alert(1)' }, (b) => b.includes('value="" autofocus onfocus="alert(1)"')],
['contesto URL', { sito: 'javascript:alert(1)' }, (b) => b.includes('href="javascript:alert(1)"')],
];
const etichette = ['senza difese', 'solo codifica HTML', 'codifica HTML e allowlist degli schemi'];
for (const livello of [0, 1, 2]) {
console.log('--- ' + etichette[livello]);
const app = costruisci(livello);
for (const [nome, query, iniettato] of prove) {
const r = await app.inject({ url: '/cerca', query });
console.log(`${nome}: payload attivo nel documento = ${iniettato(r.body)}`);
}
}
const r = await costruisci(2).inject({ url: '/cerca', query: { q: '<img src=x onerror=alert(1)>' } });
console.log('testo reso sicuro:', r.body.match(/<p>.*?<\/p>/)[0]);
// Output:
// --- senza difese
// contesto testo: payload attivo nel documento = true
// contesto attributo: payload attivo nel documento = true
// contesto URL: payload attivo nel documento = true
// --- solo codifica HTML
// contesto testo: payload attivo nel documento = false
// contesto attributo: payload attivo nel documento = false
// contesto URL: payload attivo nel documento = true
// --- codifica HTML e allowlist degli schemi
// contesto testo: payload attivo nel documento = false
// contesto attributo: payload attivo nel documento = false
// contesto URL: payload attivo nel documento = false
// testo reso sicuro: <p>Risultati per: <img src=x onerror=alert(1)></p>
Il risultato ha una sorpresa: la codifica HTML ripara il testo e l'attributo, ma href="javascript:alert(1)" passa intatto, perché dentro un attributo href non c'è nessun carattere speciale da codificare: il pericolo è lo schema dell'URL. La correzione è urlSicuro, che analizza il valore con new URL e accetta solo http: e https:; qualunque altra cosa, o un valore che non si analizza, diventa #. La funzione incorpora include le virgolette doppie e singole proprio per l'attributo: senza, il payload " autofocus onfocus="... chiude l'attributo e ne apre un altro. Un limite da dichiarare: queste prove guardano il testo HTML prodotto, non l'esecuzione in un browser, che qui non ho avviato; il criterio («il payload è attivo nel documento») è quello che OWASP indica come causa.
Che cosa fa Angular per te
Con un framework moderno parti avvantaggiato. In Angular l'interpolazione {{ valore }} è sempre codificata: i caratteri speciali compaiono come testo. Un valore legato a [innerHTML] viene sanificato, cioè ripulito di ciò che esegue codice, e la guida distingue quattro contesti di sicurezza (HTML, stile, URL, URL di risorsa), perché un valore innocuo in uno può essere pericoloso in un altro: è lo stesso ragionamento della prova qui sopra. Il pericolo ricompare quando spegni la sanificazione: le funzioni bypassSecurityTrust* dicono ad Angular «questo valore l'ho verificato io», e la guida stessa avverte che marcare come fidato un valore che può essere malevolo introduce una vulnerabilità; anche OWASP le elenca fra i punti in cui i framework si aggirano. La regola pratica: non applicarle mai a testo che arriva da un utente.
CSRF: il browser allega il cookie senza chiedertelo
Nel cross-site request forgery una pagina di un altro sito fa partire una richiesta verso la tua applicazione, e il browser della vittima allega i cookie di sessione: il server vede una richiesta autenticata e non sa che l'utente non l'ha voluta. Non serve rubare nulla né leggere la risposta, basta che l'azione sia un cambio di stato che si ottiene con una richiesta prevedibile. Per OWASP CSRF (CWE-352) sta in A01, perché è un accesso concesso senza sapere se è voluto.
Lo script costruisce una piccola banca con sessione a cookie e rotta POST /bonifico, e la prova con tre difese: nessuna, token anti-CSRF (modello synchronizer token: il server genera un valore per sessione e lo pretende nell'intestazione x-csrf, confrontato con timingSafeEqual), e controllo Fetch Metadata (il browser dichiara in Sec-Fetch-Site da dove parte la richiesta, e il server rifiuta tutto ciò che non è same-origin). La richiesta «forgiata» porta il cookie e l'origine evil.test, ma non il token: è ciò che la pagina dell'attaccante può ottenere, perché non può leggere il token né impostare intestazioni arbitrarie.
import Fastify from 'fastify';
import { randomBytes, timingSafeEqual } from 'node:crypto';
function costruisci(difesa) {
const sessioni = new Map(); // sid -> { utente, csrf, saldo }
const app = Fastify();
app.post('/login', async (req, reply) => {
const sid = randomBytes(16).toString('hex');
const csrf = randomBytes(16).toString('hex');
sessioni.set(sid, { utente: 'alice', csrf, saldo: 1000 });
reply.header('set-cookie', `sid=${sid}; HttpOnly; SameSite=Lax; Path=/`);
return { csrf };
});
app.post('/bonifico', async (req, reply) => {
const sid = /sid=([0-9a-f]+)/.exec(req.headers.cookie ?? '')?.[1];
const s = sessioni.get(sid);
if (!s) return reply.code(401).send({ errore: 'non autenticato' });
if (difesa === 'token') {
const dato = Buffer.from(String(req.headers['x-csrf'] ?? ''));
const atteso = Buffer.from(s.csrf);
if (dato.length !== atteso.length || !timingSafeEqual(dato, atteso)) {
return reply.code(403).send({ errore: 'token CSRF mancante o errato' });
}
}
if (difesa === 'fetch-metadata') {
const sito = req.headers['sec-fetch-site'];
if (sito !== undefined && sito !== 'same-origin') {
return reply.code(403).send({ errore: `richiesta ${sito} rifiutata` });
}
}
s.saldo -= req.body.importo;
return { saldo: s.saldo };
});
return app;
}
for (const difesa of ['nessuna', 'token', 'fetch-metadata']) {
const app = costruisci(difesa);
const login = await app.inject({ method: 'POST', url: '/login' });
const cookie = login.headers['set-cookie'].split(';')[0];
if (difesa === 'nessuna') {
console.log('Set-Cookie:', login.headers['set-cookie'].replace(/sid=[0-9a-f]+/, 'sid=<id>'));
}
const { csrf } = login.json();
// La richiesta forgiata: il browser della vittima allega il cookie, ma la pagina
// dell'attaccante non può leggere il token né impostare intestazioni a piacere.
const forgiata = await app.inject({
method: 'POST', url: '/bonifico', payload: { importo: 500 },
headers: { cookie, origin: 'https://evil.test', 'sec-fetch-site': 'cross-site' },
});
const legittima = await app.inject({
method: 'POST', url: '/bonifico', payload: { importo: 100 },
headers: { cookie, origin: 'https://banca.test', 'sec-fetch-site': 'same-origin', 'x-csrf': csrf },
});
console.log(`${difesa}: forgiata ${forgiata.statusCode} ${forgiata.body} | legittima ${legittima.statusCode} ${legittima.body}`);
}
// Output:
// Set-Cookie: sid=<id>; HttpOnly; SameSite=Lax; Path=/
// nessuna: forgiata 200 {"saldo":500} | legittima 200 {"saldo":400}
// token: forgiata 403 {"errore":"token CSRF mancante o errato"} | legittima 200 {"saldo":900}
// fetch-metadata: forgiata 403 {"errore":"richiesta cross-site rifiutata"} | legittima 200 {"saldo":900}
Senza difesa la richiesta forgiata passa e il saldo scende di 500; con una delle due difese viene rifiutata con 403 mentre la richiesta legittima, quella che porta il token o parte dalla stessa origine, riesce. Il cookie è emesso con HttpOnly (lo script della pagina non lo legge) e SameSite=Lax, che dice al browser di non allegarlo alle richieste cross-site tranne le navigazioni con metodo sicuro. Due avvertenze. Primo, i dati sul server non provano il comportamento del browser: questo script prova che cosa fa il server se il cookie arriva comunque, ed è il caso reale quando SameSite non c'è, vale None, o l'attacco parte da un sottodominio dello stesso sito. Secondo, non affidarti al valore predefinito: MDN spiega che, dove il browser applica Lax di default, usa una versione più permissiva che include le richieste POST arrivate entro due minuti dalla creazione del cookie. Scrivi l'attributo esplicitamente.
La guida OWASP raccomanda i token per il software con stato, il doppio invio del cookie per quello senza stato e Fetch Metadata per chi ha solo browser moderni, da affiancare alle opzioni di ripiego che la guida descrive; ricorda anche di non usare mai richieste GET per cambiare stato. Se usi un framework controlla prima se offre già la protezione: è quasi sempre preferibile a una tua.
Il lato Angular
Angular implementa il doppio invio nel suo HttpClient: un interceptor legge un token da un cookie (di default XSRF-TOKEN) e lo scrive nell'intestazione X-XSRF-TOKEN su tutte le richieste che modificano dati verso URL relativi o della stessa origine, non su GET e HEAD. Il server deve emettere il cookie e verificare l'intestazione: senza il controllo lato server, l'interceptor non protegge niente. Se i nomi sono diversi si configurano così (schema da adattare, tratto dalla guida di Angular, non eseguito: serve un progetto Angular):
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(
withXsrfConfiguration({
cookieName: 'CUSTOM_XSRF_TOKEN',
headerName: 'X-Custom-Xsrf-Header',
}),
),
],
};
La funzione withXsrfConfiguration accetta due proprietà, cookieName e headerName; se non la usi valgono i nomi predefiniti, XSRF-TOKEN e X-XSRF-TOKEN. Il rischio di cambiarli è il disallineamento con il backend: l'interceptor continua a funzionare e il server rifiuta ogni scrittura con 403, un guasto che compare solo sulle richieste di modifica. Come disattivare tutto, la guida offre withNoXsrfProtection(): se lo scegli, la difesa deve stare altrove.
CSP: la rete sotto il funambolo
La Content Security Policy è un'intestazione con cui dici al browser quali sorgenti di script (e di altre risorse) sono ammesse. L'approccio che regge è quello «stretto»: un nonce, un valore casuale generato a ogni risposta, che compare nell'intestazione e negli attributi nonce dei soli script tuoi; il browser esegue solo gli script con il nonce giusto. Anche se un attaccante riesce a inserire un tag <script>, non conosce il nonce di quella risposta. La parola chiave strict-dynamic estende la fiducia agli script caricati da uno script già fidato, utile con librerie di terzi che ne caricano altre.
Lo script seguente emette la politica con nonce, object-src 'none', base-uri 'none' e frame-ancestors 'none', e inserisce volutamente il testo dell'utente senza codificarlo: serve a vedere il ruolo della CSP come seconda linea. Una seconda rotta usa l'intestazione Content-Security-Policy-Report-Only, con cui il browser segnala le violazioni senza bloccare niente: è il modo di provare una politica su traffico reale prima di imporla.
import Fastify from 'fastify';
import { randomBytes } from 'node:crypto';
const app = Fastify();
app.get('/', async (req, reply) => {
const nonce = randomBytes(16).toString('base64');
reply.header('content-security-policy', [
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
].join('; '));
reply.type('text/html');
// Difesa in profondità: qui il testo dell'utente NON viene codificato, per vedere che cosa fa la CSP.
return `<script nonce="${nonce}">/* codice dell'applicazione */</script><p>${req.query.q ?? ''}</p>`;
});
app.get('/prova', async (req, reply) => {
reply.header('content-security-policy-report-only', "script-src 'self'; report-to csp-endpoint");
return { ok: true };
});
const a = await app.inject({ url: '/', query: { q: '<script>alert(1)</script>' } });
const b = await app.inject({ url: '/', query: { q: 'ciao' } });
const nonceDi = (r) => /'nonce-([^']+)'/.exec(r.headers['content-security-policy'])[1];
const scriptTotali = (a.body.match(/<script/g) ?? []).length;
const conNonce = a.body.split(`<script nonce="${nonceDi(a)}">`).length - 1;
console.log('nonce diverso a ogni risposta:', nonceDi(a) !== nonceDi(b));
console.log('nonce nell\'intestazione = nonce nel documento:', a.body.includes(`nonce="${nonceDi(a)}"`));
console.log('script nel documento:', scriptTotali, '| con il nonce giusto:', conNonce);
console.log('unsafe-inline presente:', a.headers['content-security-policy'].includes('unsafe-inline'));
console.log('direttive:', a.headers['content-security-policy'].split('; ').map((d) => d.split(' ')[0]).join(', '));
console.log('report-only:', Object.keys((await app.inject({ url: '/prova' })).headers).filter((h) => h.includes('report-only')).join());
// Output:
// nonce diverso a ogni risposta: true
// nonce nell'intestazione = nonce nel documento: true
// script nel documento: 2 | con il nonce giusto: 1
// unsafe-inline presente: false
// direttive: default-src, script-src, object-src, base-uri, frame-ancestors
// report-only: content-security-policy-report-only
Il nonce cambia a ogni risposta e coincide fra intestazione e documento, unsafe-inline non c'è, e nel documento ci sono due tag <script> ma uno solo porta il nonce giusto: quello iniettato dal payload verrebbe rifiutato dal browser. Il «verrebbe» è onesto: l'applicazione del blocco spetta al browser, e qui non ne ho usato uno; questo script prova che la politica è emessa correttamente, non che sia efficace in una pagina reale. La conseguenza pratica è che la CSP si collauda in un browser, partendo da Report-Only. E non è una scusa per smettere di codificare: la guida di Angular la indica, insieme ai Trusted Types, come strato in più che opera a livello del DOM, non come sostituto della sanificazione.
Come si combinano
| Attacco | Chi lo ferma davvero | Che cosa non ferma |
|---|---|---|
| XSS riflesso o memorizzato | Codifica dell'output per contesto, validazione degli URL | La sola codifica HTML lascia passare javascript: in un href |
| XSS basato sul DOM | Evitare API come innerHTML con dati non fidati; sanificazione del framework | La CSP con nonce limita l'esecuzione, ma non rimedia a un uso scorretto |
| CSRF | Token, Fetch Metadata, SameSite esplicito | Una CSP, che regola le risorse caricate e non verifica chi ha voluto la richiesta di modifica |
| Script iniettato che sfugge alla codifica | CSP con nonce e strict-dynamic | Un XSS che usa uno script già autorizzato |
La tabella dice una cosa semplice: le difese sono per strati e ognuna ha un punto cieco che un'altra copre. Nell'articolo 04 vedi come costruire il cookie di sessione che qui abbiamo solo usato; nell'articolo 03 l'altra metà di A05, l'iniezione lato server; e nell'articolo 01 la mappa complessiva.
Domande frequenti
Se uso Angular posso ignorare l'XSS?
No. Angular codifica e sanifica per impostazione predefinita, ma le funzioni bypassSecurityTrust* spengono la sanificazione e ogni pagina servita fuori da Angular, comprese le risposte HTML del backend, resta a carico tuo. Inoltre le sue difese stanno nel client: il server deve comunque codificare e validare.
SameSite sostituisce il token anti-CSRF?
Da solo no. Aiuta molto, ma dipende dal comportamento del browser e dal fatto che l'attacco parta da un altro sito: un'azione eseguita da un sottodominio dello stesso sito o un browser vecchio lo aggirano. OWASP lo colloca fra le difese in profondità, da affiancare a token o Fetch Metadata.
Una CSP con unsafe-inline protegge ancora?
Molto meno. Con unsafe-inline nella direttiva degli script il browser esegue anche gli script inseriti nel documento, che è proprio ciò che un attacco XSS fa. La politica con nonce serve a evitarlo: il browser esegue solo gli script con il nonce di quella risposta.
Che cosa sai fare adesso
Sai distinguere XSS riflesso, memorizzato e basato sul DOM e sai perché la codifica dipende dal contesto: hai visto una codifica HTML lasciar passare un URL javascript: e hai la correzione. Sai riprodurre un CSRF contro un'applicazione di prova e fermarlo con un token o con Fetch Metadata, scrivere gli attributi del cookie senza fidarti dei predefiniti, ed emettere una CSP con nonce da collaudare in modalità Report-Only prima di imporla. Sai anche che cosa non è provato qui: l'esecuzione in un browser vero. Continua con l'iniezione SQL.
Fonti
- OWASP Cross Site Scripting Prevention Cheat Sheet - definizione di XSS, codifica dell'output per contesto, funzioni che aggirano i framework, consultata il 2026-11-13.
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet - token, doppio invio, Fetch Metadata,
SameSitecome difesa in profondità, consultata il 2026-11-13. - MDN: Content Security Policy (CSP) - nonce,
strict-dynamic, intestazioneReport-Only, consultata il 2026-11-13. - MDN: Set-Cookie - valori di
SameSitee comportamento diLaxcome predefinito, consultata il 2026-11-13. - Angular: Security - interpolazione, sanificazione,
bypassSecurityTrust*, protezione XSRF diHttpClient,withXsrfConfiguration, consultata il 2026-11-13. - OWASP Top 10:2025, A05 Injection - XSS e SQL injection nella stessa categoria, consultata il 2026-11-13.
- Gli script sono stati eseguiti con
node24.11.0 e Fastify 5.12.8 (guida ai test di Fastify, metodoinject()), due volte, con uscita identica. Nessun browser avviato.

Commenti
Caricamento commenti...
Accedi per commentare