Vai al contenuto
OneKitly

Togliere l'HTML come si deve: che cosa può e non può fare un rimuovitore di tag

Pubblicato il 14/07/2026 · 15 min di lettura · Strumenti testo e lingua

Daniel Okonkwo

Daniel OkonkwoSviluppatore front-end e redattore Tech presso OneKitly

Performance web · Formati di file

Verificato su 5 fonti

Vedi il profilo
In breve

Togliere i tag e sanificare l'HTML sono lavori diversi. Un rimuovitore di tag produce testo semplice da mostrare, salvare o contare; un sanificatore produce HTML sicuro da inserire in una pagina. Confondere i due è il modo in cui nascono i bug di injection, e una regex non farà mai il secondo lavoro perché non modella il parser: dai al modello che riconosce un segno di minore, una sequenza di caratteri che non sono maggiore e un segno di maggiore il frammento <a title="fast > cheap" href="/x">our guide</a>, e si ferma al primo maggiore che incontra, producendo cheap" href="/x">our guide. Ovunque regex e tokenizzatore del browser non concordino, è la regex a sbagliare, e quelle divergenze non si possono enumerare: per questo le liste nere falliscono come classe e le difese reali sono l'escape contestuale nel punto di inserimento o un sanificatore a lista bianca basato su un parser. Come strumento di formattazione, invece, un rimuovitore ha lavoro vero. Su un frammento di 390 caratteri il modello ingenuo restituisce 183 caratteri che contengono il corpo del foglio di stile, una riga di JavaScript rovinata, la coda di un commento, entità non decodificate e due elementi di lista fusi in StandardExpress. Elimina i contenuti degli elementi a testo grezzo, trasforma le fini di blocco in righe vuote e br in un a capo, decodifica le entità una volta: 118 caratteri puliti.

Togliere i tag e sanificare l'HTML sono due lavori diversi. Un frammento reale passato attraverso una regex ingenua e attraverso un rimuovitore consapevole della formattazione, con i contenuti di script e style, le interruzioni di blocco, i commenti, i CDATA e l'ordine delle entità mostrati in output.

Due lavori che sembrano uno

Togliere i tag significa produrre testo semplice: qualcosa da mostrare in un risultato di ricerca, di cui contare le parole, da mettere in un'email in solo testo, da passare a un riassuntore o da salvare in una colonna che non viene mai resa come markup. Sanificare significa produrre HTML: markup che verrà inserito in una pagina viva e che quindi lì deve essere sicuro. I due si somigliano perché entrambi prendono HTML e restituiscono qualcosa di più corto. Non sono lo stesso lavoro, non hanno lo stesso criterio di successo, e solo uno dei due è un controllo di sicurezza.

Questa guida parla del primo lavoro, fatto bene. La questione della sicurezza trova risposta nell'ultima sezione, brevemente e senza ambiguità, perché la risposta onesta è corta: un rimuovitore di tag non è un confine di sicurezza, e nessuna quantità di modelli aggiuntivi lo trasforma in tale. Tutto ciò che sta in mezzo è il lavoro di formattazione che un rimuovitore deve davvero fare e che la maggior parte delle implementazioni sbaglia: eliminare i contenuti di script e style, trasformare la struttura in a capo, gestire commenti e CDATA, e decodificare le entità nel punto giusto.

Che cosa fa una regex di tag ingenua a un frammento reale

Il frammento di prova è composto da 390 caratteri di markup di pagina ordinario: un contenitore article, un foglio di stile in linea, un'intestazione con una e commerciale codificata, un paragrafo spezzato da un br e con un'entità di lineetta breve, un commento HTML il cui testo contiene un segno di maggiore, una lista di due elementi, un collegamento il cui attributo title contiene un maggiore codificato, e uno script in linea. Nulla di esotico, nulla di ostile: è così che si presenta un export da un CMS.

Esegui il classico one-liner — riconoscere un segno di minore, una sequenza qualsiasi di caratteri che non siano maggiore, poi un maggiore, in modalità globale — e tornano 183 caratteri. Fra questi: il testo letterale .lead{font-weight:700}, cioè il corpo del foglio di stile diventato prosa. La stringa var ok = a d;, cioè lo script amputato nel mezzo perché la regex ha letto gli operatori di confronto come un tag. Il frammento 0 --> , cioè la coda del commento, sopravvissuta perché la regex si è fermata al maggiore che conteneva. Due entità non decodificate. E le parole Standard ed Express fuse in StandardExpress, insieme a una frase che finisce con Monday.Delivery perché il br non ha contribuito nulla.

Lo stesso frammento attraverso un rimuovitore che sa che cosa significano gli elementi restituisce 118 caratteri, in cinque blocchi: l'intestazione, il paragrafo su due righe con l'interruzione del br rispettata, i due elementi di lista sulle proprie righe e la frase finale, con la e commerciale e la lineetta breve decodificate, e nessuna traccia del foglio di stile, dello script o del commento. La differenza fra 183 e 118 caratteri non è compressione. È la rimozione di cose che non erano mai state testo.

I contenuti di script e style non sono tag

Il difetto di formattazione più grosso dei rimuovitori ingenui è che tolgono i tag di apertura e chiusura di script e style e lasciano tutto ciò che sta in mezzo. Non è un problemino estetico: le regole CSS di una pagina o il codice di uno script in linea atterrano in mezzo a quello che doveva essere testo leggibile, e verranno indicizzati, contati, riassunti e mostrati a qualcuno. Nel frammento di prova il blocco di dichiarazioni CSS compare come seconda riga di output, subito sopra l'intestazione.

Lo standard HTML li chiama elementi a testo grezzo: dentro script e style il parser smette di cercare tag e legge fino al tag di chiusura corrispondente. Ecco perché la regex ingenua rovina anche ciò che lascia trapelare. Nello script di prova l'espressione che contiene un minore e un maggiore viene letta dalla regex come un tag e cancellata, così il JavaScript che trapela non è nemmeno il JavaScript originale: è una versione accorciata che per caso sembra una frase. Due modalità di guasto in un solo costrutto.

La correzione è una regola messa prima di tutte le altre: riconoscere il tag di apertura, il suo contenuto e il tag di chiusura come un'unica unità per script, style, template e noscript, e cancellare il tutto. Mettila per prima nella pipeline, perché una volta spariti i tag non puoi più sapere quale testo stesse al loro interno.

I blocchi hanno bisogno di interruzioni, e br e p non sono la stessa interruzione

Cancellare un tag cancella lo spazio che esso implicava. Due paragrafi consecutivi senza spazio fra loro nel sorgente diventano OneTwo. Due elementi di lista diventano AB. Un'intestazione seguita da un paragrafo diventa TB. È il difetto che rende illeggibile il testo ripulito e sbagliati i conteggi di parole, ed è invisibile nei test ogni volta che l'HTML sorgente è formattato con a capo fra i tag: ed è proprio per questo che finisce in produzione.

La mappatura che produce un output leggibile è breve. Il tag di chiusura di un elemento di blocco — p, div, li, tr, da h1 a h6, blockquote, section, article e gli altri — diventa una riga vuota. Un br diventa esattamente un a capo, perché un br è un'interruzione dentro un blocco, non un blocco nuovo. Un hr diventa una riga vuota. Ogni altro tag diventa nulla. Poi riduci le serie di tre o più a capo a una sola riga vuota, perché un blocco annidato emette due tag di chiusura e quindi due righe vuote.

Un'avvertenza onesta: questa mappatura mette una riga vuota fra gli elementi di lista, perché li è un blocco. In un elenco compatto si legge male, e la versione più curata tratta li come un singolo a capo e riserva la riga vuota solo al ul o ol che lo contiene. Volerlo è una preferenza di formattazione e non una questione di correttezza, ma è una preferenza da esprimere esplicitamente: un rimuovitore che non ha mai pensato agli elenchi li attaccherà semplicemente uno all'altro, e quella non è una preferenza, è un bug.

Commenti, CDATA ed entità: tre grammatiche a parte

Un commento HTML non è un tag e non segue la grammatica dei tag: va da un'apertura di quattro caratteri a una chiusura di tre, e in mezzo può comparire assolutamente qualsiasi cosa, segni di maggiore compresi. Una regex di tag che sembra togliere i commenti se la cava solo perché la maggior parte dei commenti non contiene alcun maggiore. Mettine uno — una nota del tipo tenere se le scorte sono maggiori di zero — e la regex si ferma lì, lasciando il resto del testo del commento nel tuo output. Nel frammento di prova il residuo visibile è la stringa 0 seguita dal terminatore di commento.

Le sezioni CDATA sono un caso vicino che sopravvive nella pratica dentro l'SVG e negli export in stile XHTML. Il loro contenuto per definizione non è markup, e il loro terminatore non è nemmeno un segno di maggiore. Passa nella regex ingenua una sezione CDATA che contenga testo dall'aria di markup e ottieni il testo interno privato dei suoi tag interni, con la sequenza di parentesi di chiusura penzolante: un risultato sbagliato in tre modi contemporaneamente. Riconosci e cancella le sezioni CDATA come un'unità, prima del passaggio generale sui tag, esattamente come per i commenti.

Le entità vengono per ultime, ed esattamente una volta. Decodifica dopo che i tag sono spariti, mai prima: un segno di minore con escape nel sorgente è testo che l'autore voleva visibile, e decodificarlo per primo lo promuove a tag che il rimuovitore poi cancella. Su un frammento che dice di usare un elemento em per l'enfasi, con il nome dell'elemento sotto escape, togliere e poi decodificare conserva il nome visibile dell'elemento, mentre decodificare e poi togliere lo cancella e lascia un buco. Decodifica due volte e fabbrichi markup vivo da qualcosa messo sotto escape di proposito, il che è un bug di formattazione avviato a diventarne uno peggiore.

Dove si trova davvero il confine di sicurezza

Un rimuovitore di tag è uno strumento di formattazione. Non è un confine di sicurezza, e non lo si trasforma in tale aggiungendo modelli. Il motivo è strutturale ed è già visibile nella tabella qui sopra: il modello ingenuo e il tokenizzatore del browser non concordano su dove finisca un tag non appena un valore di attributo contiene un segno di maggiore, e la versione consapevole della formattazione non concorda nello stesso punto. Qualunque filtro costruito con modelli è una lista nera delle forme a cui il suo autore ha pensato, e una lista nera vale quanto l'immaginazione dell'autore, mentre il parser è una specifica fissa, pubblicata e indipendente dall'avversario. Non si possono enumerare i modi in cui due grammatiche differiscono.

Le difese corrette sono due, e nessuna è una rimozione di tag. La prima e più importante è l'escape contestuale in uscita: metti sotto escape il valore nel punto in cui lo inserisci, con la regola di escape di quel contesto, perché le regole differiscono per testo HTML, valori di attributo, URL, script e stile. Un motore di template che mette sotto escape per impostazione predefinita lo fa per te, e i bug nascono dove qualcuno se n'è tirato fuori. La seconda, necessaria solo quando gli utenti sono davvero autorizzati a inviare markup che deve essere reso come markup, è un sanificatore basato su parser: analizza l'input in un albero e lo ricostruisce da una lista bianca di elementi e attributi — il modello della specifica HTML Sanitizer e delle librerie di sanificazione consolidate. Lista bianca, parser, non regex.

C'è una semplificazione pulita che vale la pena enunciare. Se il testo ripulito è testo semplice e tale resta — mostrato in un nodo di testo, scritto in una colonna di testo, contato, inviato come email in solo testo — allora l'output del rimuovitore non ha alcun ruolo di sicurezza, perché quel testo non viene mai più analizzato come markup. Il pericolo compare solo quando qualcuno prende il testo ripulito e lo rimette dentro l'HTML. In quel momento il controllo rilevante è l'escape in quel punto di inserimento, e non qualcosa che il rimuovitore abbia fatto prima. Tenere quei due momenti separati in testa è quasi tutto ciò che questa sezione cerca di insegnare.

Otto costrutti HTML attraverso una regex di tag ingenua e attraverso un rimuovitore consapevole della formattazione. Ogni colonna di output è la stringa letterale restituita dal codice in Node 22.
InputLa regex ingenua restituisceIl rimuovitore consapevole della formattazione restituisce
<p>One</p><p>Two</p>OneTwoOne, riga vuota, Two
<p>One<br>Two</p>OneTwoOne, singolo a capo, Two
<li>A</li><li>B</li>ABA e B su righe separate
<h2>T</h2><p>B</p>TBT, riga vuota, B
Un elemento style che contiene .lead{font-weight:700}il testo CSS .lead{font-weight:700} come output visibilenulla: l'elemento e il suo contenuto sono eliminati insieme
Un elemento script che contiene var ok = a < b && c > d;var ok = a d; — la parte centrale ingoiata come se fosse un tagnulla
<p>A<!-- keep if stock > 0 -->B</p>A 0 -->BAB
<a title="fast > cheap" href="/x">our guide</a>cheap" href="/x">our guidesbagliato anche qui: nessuna regex lo risolve, solo un parser
Rimuovi tag HTMLRimuovi tutti i tag HTML da uno snippet e mantieni solo il testo.Prova lo strumento

Domande frequenti

Posso semplicemente usare il browser e leggere textContent?
Analizzare con il parser della piattaforma è l'istinto giusto, e risolve gratis i problemi di attributi e commenti. Ma nota che cosa danno le due proprietà. textContent restituisce il testo concatenato di tutti i discendenti senza alcuna formattazione, contenuti di script e style compresi, e senza a capo per i blocchi: riproduce quindi due dei difetti di questo articolo. innerText approssima il testo reso, inserisce gli a capo e salta gli elementi nascosti, ma dipende dal layout e quindi dal CSS. In un browser, analizza l'input in un documento inerte, rimuovi esplicitamente i nodi script, style e template, poi percorri l'albero emettendo le tue interruzioni. Su un server senza DOM, una vera libreria di parsing HTML ti dà lo stesso albero.
Devo troncare prima o dopo aver tolto i tag?
Dopo, sempre. Troncare l'HTML taglia un tag a metà o lascia un elemento non chiuso, e il frammento risultante non è né markup valido né testo sensato: un estratto che finisce a metà di un valore di attributo è una classica fonte di layout rotti quando più tardi viene inserito da qualche parte. Togli prima, poi tronca il testo semplice a un confine di parola, poi aggiungi i puntini di sospensione. Lo stesso argomento vale per il conteggio: un conteggio di caratteri o parole fatto sul markup conta nomi di tag e valori di attributo come contenuto, quindi è sbagliato nella proporzione di markup della sorgente, che in un tipico export da CMS è una frazione grande.
Se il testo ripulito torna in una pagina, decodifico le entità?
Decodificalo una volta per l'archiviazione, poi lascia che il livello di output lo rimetta sotto escape quando lo inserisce. Sembra lavoro in più ed è in realtà l'unico assetto che resta corretto: il valore salvato è il testo effettivo, e ogni consumatore lo mette sotto escape per il proprio contesto — testo HTML, un attributo, un URL, una cella CSV, un'email in solo testo. Se invece salvi la forma codificata e salti l'escape in uscita perché «è già sotto escape», il primo consumatore che non è HTML riceve sequenze di e commerciale letterali, e il primo che mette comunque sotto escape la codifica due volte. Un valore decodificato canonico in archiviazione, escape a ogni confine.
C'è un modo per rendere sicuro un rimuovitore a regex aggiungendo altri modelli?
No, e il motivo vale la pena interiorizzarlo perché si generalizza. Un elenco di modelli codifica le forme che il suo autore ha previsto; il parser accetta ogni forma che la specifica definisce, incluso il comportamento di recupero su input malformato, che nessuno scrive sotto forma di regola. La sicurezza segue solo se il modello del linguaggio che ha il filtro è almeno completo quanto quello del consumatore, e un elenco di modelli è per costruzione meno completo. È tutto qui l'argomento a favore delle liste bianche contro le liste nere e dei parser contro i modelli, ed è per questo che questo articolo ti dà un rimuovitore per la formattazione e ti indirizza a un sanificatore per la sicurezza, invece di venderti un solo strumento per entrambe le cose.
Che cosa deve fare il rimuovitore con immagini, collegamenti e tabelle?
Decidi di proposito e documenta la decisione, perché tutti e tre portano informazione che vive fuori dal testo. Un'immagine contribuisce con il suo testo alternativo, che è la descrizione accessibile e spesso l'unica frase che vale la pena conservare; cancellare l'elemento in silenzio la perde. Un collegamento contribuisce con il suo testo di ancoraggio per impostazione predefinita, e se la destinazione vada aggiunta fra parentesi dipende dal fatto che il tuo output venga mai letto senza un modo per seguire i collegamenti. Una tabella ha bisogno di un separatore di celle e di uno di righe, altrimenti ogni riga diventa una sequenza illeggibile di valori concatenati: una tabulazione fra le celle e un a capo fra le righe è il minimo abituale. Nulla di tutto questo è deciso per te dai nomi dei tag; è una scelta editoriale su che cosa il testo semplice debba preservare.

Articoli che potrebbero interessarti

Tutte le guide
GuidaTogliere il Markdown: che cosa perde il testo semplice, e che cosa sbaglia una regexUn link diventa testo con la destinazione cancellata, un elenco annidato perde la gerarchia, una tabella diventa una fila di parole. Poi la metà tecnica: il markdown non ha una sola specifica, e un ripulitore a base di regex rovina un nome di file, un segno di moltiplicazione e l'interno di un blocco di codice — tutto messo a confronto con un vero parser.TutorialPulire un testo disordinato: l'ordine delle operazioni che conta davveroTogliere i tag prima di decodificare le entità, ripulire i bordi prima di deduplicare, comprimere gli spazi per ultimo. Tre ordini eseguiti in Node, una pipeline di nove passaggi nella sequenza giusta e i caratteri invisibili — U+00A0, U+200B, U+FEFF — che sopravvivono a ogni pulizia ingenua.GuidaCostruire un URL con parametri che sopravvive a un copia-incollaTre codifiche, una differenza visibile: %20 o +. La modalità modulo del generatore riproduce URLSearchParams byte per byte su diciassette valori — ma dagli un URL di base con un frammento e ogni parametro finisce dentro l'hash, dove nessun server lo vede.SpiegazioneMaiuscolo di frase e maiuscolo di titolo: le regole cambiano con la linguaIl maiuscolo di titolo inglese ha tre soglie diverse a seconda del manuale di stile. Francese, spagnolo, portoghese e italiano non ne hanno nessuna. Il tedesco scrive maiuscolo ogni sostantivo. Lo strumento non sa nulla di tutto ciò: ecco esattamente cosa fa.GuidaI limiti di caratteri che mordono davvero: unità di codice, code point e grafemiUn carattere è tre cose insieme. Un'emoji con tonalità di pelle è 1 grafema, 2 code point e 4 unità UTF-16. Ogni conteggio di questa guida è stato misurato in Node, più il motivo per cui un SMS scende da 160 a 70 e perché VARCHAR(255) non sono 255 di niente in particolare.SpiegazioneContare le parole è ambiguo, e ogni strumento risponde in modo diversoUn conteggio di parole è una definizione, non una misura. Abbiamo contato lo stesso paragrafo in quattro modi ottenendo 25, 28, 33 e 38; poi abbiamo contato 50.000 caratteri di prosa ordinaria ottenendo accordo entro il 4,5 %. Lo scarto dipende interamente da composti, cifre e URL.

Strumenti correlati

Fonti

Hai notato un errore in questo articolo?

Togliere l'HTML come si deve: che cosa può e non può fare un rimuovitore di tag — OneKitly