Vai al contenuto
New

DeepSeek V4.1 Flash free for 7 days: 10M tokens a day after a top-up of $10 or more. Until Nov 30.

How it works

Cross-site scripting (XSS) spiegato: tipi e difese

Impara in un laboratorio locale i tipi reflected, stored e DOM-based di XSS, perché l'output encoding dipende dal contesto e come CSP aggiunge difesa in profondità.

Intermedio11 min di letturaDi NotHoly

Cos'è il cross-site scripting

Il cross-site scripting (XSS) si verifica quando un'applicazione web prende dati che un attaccante può influenzare e li inserisce in una pagina in un modo che il browser legge come codice. Il browser esegue allora quel codice con i privilegi dell'origine della pagina. Il codice può leggere la pagina, inviare richieste a nome dell'utente e cambiare ciò che l'utente vede.

Il problema di fondo non è che uno script arrivi da un altro sito. L'applicazione mescola dati e codice, e il browser non può capire quale parte viene dallo sviluppatore e quale da un utente. MITRE lo classifica come CWE-79, "Improper Neutralization of Input During Web Page Generation." La formulazione è precisa: la debolezza è una mancata neutralizzazione dell'input nel punto in cui la pagina viene costruita.

Perché conta

L'XSS è una delle vulnerabilità web più comuni, e resta comune perché si nasconde in funzioni ordinarie: una casella di ricerca che ripete la query, un campo commenti, un nome di profilo, un frammento di URL che una single-page app legge al caricamento. Un attacco riuscito gira dentro la sessione autenticata della vittima. Può leggere tutto ciò che la pagina può leggere, avviare azioni che l'utente ha il permesso di fare o mostrare un finto form di accesso. I browser ne limitano una parte con la same-origin policy, ma l'XSS funziona proprio perché gira dentro l'origine di cui l'utente si fida già.

Per uno sviluppatore la lezione pratica è ristretta e utile. Non devi filtrare ogni stringa pericolosa. Devi fare in modo che il testo non fidato non venga mai interpretato dal browser come markup o codice.

I tre tipi

OWASP descrive tre famiglie. Si distinguono per il percorso che i dati non fidati fanno prima di arrivare al browser.

XSS reflected. Il server prende un valore dalla richiesta corrente, di solito un parametro di query o un campo di form, e lo restituisce nella risposta senza un encoding sicuro. L'attaccante costruisce un link che contiene il payload. L'attacco richiede che la vittima apra quel link. Il payload non viene salvato da nessuna parte sul server.

XSS stored. L'applicazione salva un input controllato dall'attaccante, come un commento, un nome utente o un ticket di assistenza, e in seguito lo mostra ad altri utenti. Alla vittima non serve un link speciale. Basta guardare la pagina normale, ed è per questo che l'XSS stored di solito ha l'impatto maggiore.

XSS DOM-based. Il server può non vedere mai il payload. Il JavaScript lato client legge una sorgente che l'attaccante può controllare, come location.hash, location.search, document.referrer o un messaggio di postMessage, e la scrive in un sink pericoloso come innerHTML, document.write o eval. Il bug sta nel codice del browser. I filtri lato server non vedono mai l'attacco, e per questo sorprende i team che rivedono solo il codice backend.

I tre tipi hanno una causa comune. Dati non fidati arrivano in un contesto che li interpreta come codice.

Perché il contesto decide la difesa

L'output encoding è la difesa principale, e non è un'operazione unica. L'encoding corretto dipende dal punto in cui finisce il valore. L'OWASP XSS Prevention Cheat Sheet elenca le regole per ogni contesto. In breve:

Contesto in cui finisce il valore Cosa fare Errore comune
Corpo HTML Encoding in entità HTML di &, <, >, ", ' Fare l'encoding due volte, così sulla pagina compare &amp;lt;
Attributo HTML Fai l'encoding e metti sempre tra virgolette il valore dell'attributo Lasciare il valore senza virgolette o credere che questo protegga href o gli event handler
Stringa JavaScript Usa un encoder di libreria per quel contesto esatto Fare l'escape per HTML e poi inserire il valore in un blocco <script>
Parametro URL Applica il percent-encoding al valore con encodeURIComponent() e, per gli URL interi, ammetti solo gli schemi http e https Considerare sufficiente l'encoding per un URL completo preso da un utente: uno schema javascript: si esegue comunque quando finisce in href
CSS Non mettere dati non fidati nel CSS; imposta proprietà fisse tramite il CSSOM Costruire una stringa per l'attributo style a partire dall'input utente

La tabella mostra perché "sanifica l'input" è una regola incompleta. Una stringa innocua in un paragrafo può essere pericolosa dentro un attributo href, perché lì javascript: è uno schema URL valido. La stessa stringa è innocua quando viene scritta con textContent. L'encoding deve corrispondere al sink in cui arriva il valore nel momento in cui viene scritto.

L'OWASP cheat sheet avverte anche che gli escape esadecimali o Unicode da soli non sostituiscono un encoder JavaScript consapevole del contesto, e che eval(), setTimeout() e setInterval() non dovrebbero mai ricevere stringhe non fidate. Fai l'encoding in output e tieni quelle funzioni lontane dai percorsi non fidati.

Sink sicuri ed escaping dei framework

Puoi ridurre il numero di decisioni scegliendo sink che trattano l'input come testo. Impostare element.textContent o element.value scrive caratteri, non markup. Impostare element.innerHTML avvia un parser HTML. L'OWASP cheat sheet indica innerHTML come sink da evitare per i dati non fidati e raccomanda al suo posto textContent o value.

I framework moderni come React, Angular, Vue e Svelte fanno l'escape dei valori nei binding predefiniti dei template. Un valore scritto come {userName} in JSX o come interpolazione in un template diventa testo. Il rischio torna quando gli sviluppatori usano una via di fuga: dangerouslySetInnerHTML in React, bypassSecurityTrustHtml in Angular, v-html in Vue o una scrittura diretta nel DOM da una libreria. Di solito l'XSS entra in un'app con framework proprio da queste vie di fuga. Tratta ogni uso come un punto da rivedere e tienine un elenco.

Content Security Policy come secondo livello

Una Content Security Policy (CSP) è un header di risposta, o un elemento <meta http-equiv>, che dice al browser da quali sorgenti una pagina può usare script, stili, immagini e altre risorse. MDN ne descrive lo scopo principale come controllare quali risorse un documento può caricare, e questo difende dall'XSS. La direttiva default-src imposta il fallback per ogni tipo di risorsa che non ha una direttiva propria.

Con default-src 'self', il browser rifiuta gli script inline e gli event handler inline come onerror="...", e rifiuta gli URL javascript: nei punti in cui eseguirebbero codice. Una CSP non corregge un bug nel tuo HTML. Limita ciò che può fare un payload iniettato. Per questo è difesa in profondità: l'encoding blocca l'iniezione e CSP rende meno utile un'iniezione sfuggita.

Ci sono due limiti da tenere presenti. Una policy in <meta> non gestisce tutte le funzioni di CSP, e MDN precisa che le policy report-only vanno inviate come header HTTP. Prima di farci affidamento, controlla sulla pagina di MDN quali direttive i tuoi browser rispettano in <meta>. Una policy che ammette 'unsafe-inline' o una lunga lista di host protegge molto meno.

Laboratorio pratico

Il laboratorio ha due parti. Prima risolvi la challenge DOM XSS in OWASP Juice Shop, un'applicazione volutamente vulnerabile, sulla tua macchina. Poi costruisci una piccola pagina che scrive lo stesso testo non fidato in due sink diversi e guardi cosa fa il browser.

Esegui ogni passo su una macchina che controlli. Tieni i servizi legati a 127.0.0.1. Non puntare questi payload verso un sito che non è tuo o che non hai il permesso scritto di testare.

Parte 1: avvia Juice Shop in locale

Verifica che Docker risponda:

bash
docker --version
docker info --format '{{.ServerVersion}}'

Sulla macchina usata per questo articolo, il primo comando ha stampato Docker version 29.8.2, build 7fc2dff. La tua versione può essere diversa.

Avvia il container con una mappatura delle porte solo su loopback:

bash
docker run -d -p 127.0.0.1:3000:3000 bkimminich/juice-shop

Il prefisso 127.0.0.1: pubblica la porta solo sull'interfaccia di loopback. Se la porta 3000 è già occupata, scegli un'altra porta host, come 127.0.0.1:3001:3000, e cambia di conseguenza gli URL qui sotto.

Controlla che l'applicazione risponda e leggi la sua versione:

bash
curl -s http://127.0.0.1:3000/rest/admin/application-version

Sulla macchina usata per questo articolo, la risposta è stata {"version":"20.3.0"}. Il primo avvio può richiedere un po' di tempo, quindi ripeti la richiesta se non ricevi risposta.

Parte 2: risolvi la challenge DOM XSS

La guida ufficiale di Juice Shop ha una sezione intitolata "Perform a DOM XSS attack." Il suo suggerimento è cercare un campo di input il cui contenuto compare nell'HTML quando il form viene inviato. La guida riporta questo payload:

html
<iframe src="javascript:alert(`xss`)">

Segui la guida nel tuo browser. Apri la barra di ricerca di Juice Shop, incolla il payload e invialo. Funziona anche un URL di ricerca con la query nel frammento. Sulla macchina usata per questo articolo, quell'URL è stato caricato in Chrome headless e lo scoreboard ha poi segnato la challenge come risolta:

text
http://127.0.0.1:3000/#/search?q=%3Ciframe%20src%3D%22javascript%3Aalert(%60xss%60)%22%3E

Interroga la challenge tramite l'API dell'applicazione per controllarne lo stato:

bash
curl -s "http://127.0.0.1:3000/api/Challenges/?name=DOM%20XSS" | grep -o '"solved":[a-z]*'

Prima di caricare l'URL di ricerca, l'output era "solved":false. Dopo il caricamento, era "solved":true. Dovresti ottenere lo stesso risultato una volta attivata la challenge in un browser.

Nota cosa rende questo bug DOM-based. Il payload sta nel frammento, che il browser non invia al server, e il codice lato client lo scrive nella pagina. Un log del server mostrerebbe solo le richieste per l'HTML e gli script dell'applicazione.

Parte 3: costruisci il laboratorio dei sink

Crea una cartella ed entraci:

bash
mkdir xsslab && cd xsslab

Crea index.html. Questo file legge il testo dopo #, lo scrive una volta con innerHTML e una con textContent, e aggiunge l'elemento meta CSP indicato nel brief del laboratorio:

html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'">
  <title>XSS sink lab</title>
</head>
<body>
  <h1>Sink comparison</h1>
  <p>Put text after the # in the URL, for example <code>#hello</code>.</p>
  <h2>innerHTML</h2>
  <div id="html-sink"></div>
  <h2>textContent</h2>
  <div id="text-sink"></div>
  <script src="app.js"></script>
</body>
</html>

Crea app.js nella stessa cartella. Lo script sta in un file separato di proposito. Anche un blocco <script> inline verrebbe bloccato dalla policy, e questo nasconderebbe il confronto che vuoi fare.

javascript
// Read the fragment once, then write it to two different sinks.
const raw = decodeURIComponent(location.hash.slice(1));
document.getElementById('html-sink').innerHTML = raw;
document.getElementById('text-sink').textContent = raw;

Servi la cartella solo su loopback. Su Windows il launcher di Python di solito è python; su Linux e macOS spesso è python3:

bash
python -m http.server 8000 --bind 127.0.0.1

Tieni aperto quel terminale mentre lavori. Apri http://127.0.0.1:8000/index.html nel browser. Ferma il server con Ctrl+C quando hai finito.

Parte 4: confronta i due sink

Apri gli strumenti per sviluppatori del browser e la scheda Console. Carica prima la pagina con un valore innocuo:

text
http://127.0.0.1:8000/index.html#hello

Poi esegui queste due righe nella console:

javascript
document.getElementById('html-sink').innerHTML
document.getElementById('text-sink').textContent

Entrambe mostrano hello. Ora carica un frammento che mescola markup e un event handler:

text
http://127.0.0.1:8000/index.html#hello%20%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E

Ho caricato esattamente questo URL in Chrome 156.0.8078.80 headless e ho fatto il dump del DOM risultante. L'output rilevante era:

html
<div id="html-sink">hello <img src="x" onerror="alert(1)"></div>
<div id="text-sink">hello &lt;img src=x onerror=alert(1)&gt;</div>

Il sink innerHTML ha creato un elemento img. Il sink textContent ha conservato gli stessi caratteri come testo semplice. Esegui di nuovo le due righe nella console su questo URL e controlla che la prima restituisca markup con un elemento dentro e la seconda la stringa con l'escape.

Parte 5: aggiungi la policy e guardala bloccare l'handler

La pagina qui sopra include già default-src 'self'. La stessa esecuzione headless ha registrato questo messaggio:

text
Executing inline event handler violates the following Content Security Policy directive 'default-src 'self''.

L'elemento img è stato creato, ma il suo handler onerror non ha eseguito alert(1). La policy ha fermato l'handler inline. La tua console dovrebbe mostrare un messaggio simile. Il testo esatto può variare tra le versioni del browser.

Per vedere la differenza, togli l'elemento meta, ricarica lo stesso URL e aspetta l'alert. Poi rimetti l'elemento meta. Questo confronto è l'unico passo del laboratorio in cui il payload deve essere eseguito.

Tieni presente una cosa. La policy non ha reso sicuro il sink innerHTML. Ha bloccato un tipo di esecuzione. L'elemento img esiste ancora nel DOM, e un attaccante che trova un payload ammesso dalla policy potrebbe comunque usarlo. La correzione giusta è scrivere il testo non fidato con textContent.

Errori comuni

Fare l'escape una volta per il contesto sbagliato. L'encoding HTML protegge il testo in un paragrafo. Non protegge un URL, una stringa JavaScript o un valore CSS. Abbina l'encoder al punto in cui finisce il valore.

Filtrare le stringhe pericolose note. Le blocklist che tolgono <script> non fermano <img onerror>, <svg onload> e gli URL javascript:. Encoding e sink sicuri funzionano perché non devono indovinare la prossima stringa dell'attaccante.

Fidarsi ciecamente del framework. I binding predefiniti fanno l'escape dei valori, ma dangerouslySetInnerHTML, v-html e bypassSecurityTrustHtml saltano quella protezione. Cercali nel tuo codice.

Leggere solo il codice del server. L'XSS DOM-based non arriva mai al server. Rivedi il codice client che legge location, document.referrer e i dati di postMessage.

Usare CSP come unico controllo. Una policy rigorosa aiuta, ma non sostituisce l'encoding, e una policy debole con 'unsafe-inline' protegge poco.

Dare per scontato che la policy in meta abbia tutte le funzioni. Una policy in <meta> non può usare la modalità report-only né ogni direttiva. Se ti servono i report, invia la policy come header di risposta.

Per andare oltre

Leggi per intero l'OWASP Cross Site Scripting Prevention Cheat Sheet. Per un riferimento di sicurezza è breve e copre tutti i contesti della tabella qui sopra. Poi leggi la guida CSP di MDN e prova sulla pagina del laboratorio una policy più rigorosa con nonce o hash.

Affronta le altre challenge di Juice Shop che riguardano l'XSS e confronta le varianti stored e reflected con il caso DOM che hai appena risolto. Tieni il laboratorio su loopback e ferma il container quando hai finito:

bash
docker ps --filter ancestor=bkimminich/juice-shop
docker stop <container-id>

Il prossimo articolo di questa serie parla di cross-site request forgery e di come i cookie SameSite riducono uno dei suoi percorsi. XSS e CSRF si confondono spesso, e prima di progettare un modello di sessione conviene conoscere la differenza.

Fonti

Fonti

  1. OWASP Cross Site Scripting Prevention Cheat Sheet
  2. MITRE CWE-79: Improper Neutralization of Input During Web Page Generation
  3. MDN Web Docs: Content Security Policy (CSP)
  4. Pwning OWASP Juice Shop (companion guide)

Altro su Sicurezza web e API

Sicurezza web e API

Testare i bug web comuni con l'aiuto dell'IA

Metodo per IDOR, XSS, SSRF e auth rotta con un modello di IA come lettore e secondo parere, più come verificare un finding ed evitare i falsi positivi.

Base3 min di lettura

Sicurezza web e API

Test delle API con l'IA: OpenAPI e GraphQL

Le API si descrivono tramite le spec OpenAPI e l'introspection GraphQL. Usa l'IA per leggere lo schema e pianificare i test di autorizzazione.

Intermedio3 min di lettura