Stored XSS via Sanitizer Bypass: Where Allowlists Break Down
Your sanitizer accepts a clean allowlist: p, a, strong, em, ul, li. You strip everything else. You ship to production confident that XSS is handled. Then a security researcher files a report with a payload that runs alert(1) anyway.
This is not a hypothetical. Allowlist-based sanitizers have been bypassed in DOMPurify, html-sanitizer, and dozens of custom implementations. The problem is not that allowlists are wrong — it is that they operate on a representation of the HTML that does not always match what the browser actually parses and renders.
What You'll Learn
- Why allowlists can still permit executable content through mutation and namespace tricks
- How mXSS works and why it defeats sanitizers that run in a browser context
- The specific attribute patterns that allowlists routinely miss
- How SVG and MathML create parser ambiguity an attacker can exploit
- Practical steps to harden your sanitizer against each class of bypass
How Stored XSS Works — A Quick Refresher
Stored XSS happens when an attacker writes malicious input to a database and that input is later rendered as HTML in another user's browser. Unlike reflected XSS, the payload does not need to be in a URL — it sits in your database waiting for a victim to load the page.
The attack surface is anything that stores user-controlled markup: blog comments, rich text editors, user bios, support tickets, or any field where you have decided to accept a subset of HTML. If you sanitize on write, you have one shot to get it right. If you sanitize on read, every render is a potential miss.
Why Allowlists Fail: The Core Problem
An allowlist sanitizer works on a parsed DOM tree. It walks the nodes, removes anything not on the list, and serializes the result back to an HTML string. The assumption is that parsing and serializing are deterministic — that what you put in is what the browser will render later.
That assumption breaks down in at least four distinct ways:
- The browser's HTML parser is context-sensitive. The same string produces different DOM trees depending on where it appears in the document.
- Serialization is lossy. An attribute value that the sanitizer sees as harmless becomes executable after the browser deserializes it.
- Namespaces change parsing rules. SVG and MathML elements follow different parsing contexts than HTML5.
- Some attributes accept URIs, event handlers, or CSS values that can contain script even without a
scripttag.
Mutation XSS (mXSS): When the Browser Rewrites Your Input
Mutation XSS is the most counterintuitive class of sanitizer bypass. The payload you sanitize is clean. The payload the browser renders is not. The gap exists because your sanitizer serializes cleaned HTML, and then the browser's parser transforms that serialized string into a DOM that differs from what the sanitizer intended.
Consider this simplified example. Your sanitizer receives:
<p><img src=x></p>
It checks: p is allowed, img is allowed, src is allowed. It serializes the cleaned string and stores it. Later, when a different rendering context deserializes that string — say, by assigning it to innerHTML inside a table element — the browser's parser applies table parsing rules. Certain elements are not valid inside a table, so the parser ejects them or re-parents them. That re-parenting can cause an attribute that was harmless in one context to become executable in another.
A real-world mXSS vector that affected older DOMPurify versions looked roughly like this:
<svg><p><style></p>alert(1)</style></svg>
Inside the SVG namespace, style is a foreign element. The browser's HTML5 parser treats its content differently than it would in the HTML namespace. DOMPurify would serialize the cleaned tree one way; the browser would parse the serialized string differently on the next read, producing script execution. The fix was to re-parse the sanitized output a second time and check for differences — a technique called double-parsing or serialize-and-reparse.
Fix: After sanitizing, serialize the output to a string, then parse it again and compare the resulting DOM. If there is any structural difference, reject the input. DOMPurify has done this since it introduced mXSS mitigations. If you are using a custom sanitizer, add a second-pass parse explicitly.
Namespace Confusion: SVG, MathML, and the HTML Parser
The HTML5 parser has three namespace contexts: HTML, SVG, and MathML. Switching between them changes how the parser handles everything inside. This is a feature for embedding vector graphics and equations, and an attack surface for sanitizer bypass.
When the parser encounters <svg>, it switches to SVG parsing mode. Inside SVG, many element names are case-sensitive. Attributes that are forbidden in HTML may be valid SVG presentation attributes. More importantly, some elements that contain raw text in HTML (like <style> and <script>) behave differently when nested inside a foreign content element.
A sanitizer that allows svg, foreignObject, and div may be bypassed like this:
<svg>
<foreignObject>
<div><!-- HTML namespace resumes here -->
<script>alert(1)</script>
</div>
</foreignObject>
</svg>
Inside foreignObject, the parser switches back to the HTML namespace. If your sanitizer walks the DOM and removes script nodes, it may not enter the foreignObject subtree correctly — depending on whether your sanitizer was built with namespace-awareness. Some sanitizers that work correctly on flat HTML documents fail inside SVG trees because they use a simplified traversal that does not account for namespace re-entry.
Fix: Either prohibit svg and mathml root elements entirely (which is correct for most apps), or use a sanitizer that is explicitly tested for namespace boundary handling. If you need SVG support, keep a very tight allowlist and run your test suite against namespace-boundary payloads specifically.
Attribute-Level Bypasses That Allowlists Miss
Element-level allowlists are only half the job. Attributes are where most real-world bypasses happen in production apps that have already locked down tag names.
Event handler attributes
Any attribute beginning with on is an event handler: onclick, onmouseover, onerror, onload, and dozens more. If your sanitizer allowlists attributes by name, you need an explicit deny of all on* attributes — not just a list of the obvious ones. Browsers periodically add new event attributes. ontoggle, onpointerenter, and others have all been used in the wild.
href and src with javascript: URIs
Allowlisting the href attribute on a tags does not make it safe. The value javascript:alert(1) is a valid href. You must validate the scheme. Accept only http:, https:, and mailto: at most. The same applies to src on img, iframe, and audio. Strip or reject any value that starts with javascript:, data:, or vbscript:.
Here is a minimal scheme validation approach in JavaScript:
function isSafeUrl(value) {
try {
const url = new URL(value, window.location.href);
return ['http:', 'https:', 'mailto:'].includes(url.protocol);
} catch {
return false;
}
}
style attributes and CSS expressions
Allowing style as an attribute is almost always a mistake. Older browsers supported expression() in CSS, which was pure CSS-based XSS. Modern browsers do not, but allowing style still lets an attacker inject position:fixed overlays, invisible click-jacking layers, or SVG-based payload loaders. If you need user-controlled styling, accept a limited set of CSS properties through a CSS sanitizer — not the raw style attribute.
data- attributes used by frameworks
If your frontend framework reads data- attributes and evaluates them, an attacker who can write arbitrary data-* attributes has a pathway to code execution through your framework's own machinery. Angular's ng-* attributes, older Knockout bindings, and similar patterns have all been exploited this way. If your sanitizer allows all data-* attributes by default, tighten it to an explicit allowlist.
For more on how CSP can fail to catch what your sanitizer misses at the attribute level, see the deeper breakdown in CSP bypasses that render your Content Security Policy useless.
Polyglots and Context Confusion in Rich Text Editors
Rich text editors like Quill, TipTap, ProseMirror, and TinyMCE generate HTML from a user's formatted input. They ship with their own sanitization and serialization logic. If you run a second sanitizer over their output, you now have two parsers operating in sequence — and the intersection of their allowlists is not necessarily safe.
XSS polyglots are payloads crafted to survive multiple parsing passes. They exploit the fact that each parser makes slightly different decisions about what is valid and what to discard. A payload that looks like a harmless image to Parser A may look like an executable script to Parser B when Parser B receives Parser A's serialized output.
A concrete risk: you sanitize on the server with a Python library (BeautifulSoup, bleach, or nh3), store the result, then on the client you assign it to innerHTML inside a JavaScript component. The browser's HTML parser is now your second parser. If your server-side sanitizer serialized attributes with single quotes and the browser parser resolves them differently, you may have a mismatch.
Fix: Sanitize in only one place, as close to render time as possible, using the same parsing engine the browser uses. For JavaScript applications, this means client-side sanitization with DOMPurify or the emerging native Sanitizer API. For server-rendered applications, sanitize on the server and ensure your template engine auto-escapes by default so the sanitized string is not re-interpreted. This connects directly to understanding how prototype pollution in Node.js libraries can silently alter the behavior of sanitization utilities at runtime.
Testing Your Sanitizer Before Attackers Do
A sanitizer you cannot test is a sanitizer you cannot trust. Build a dedicated test suite that runs known bypass payloads through your sanitizer and asserts the output contains no executable content.
Resources to pull test vectors from:
- PortSwigger XSS cheat sheet — maintained and updated with new browser quirks regularly.
- mXSS payload list from the Cure53 research team — specifically targets serialization differences.
- html5lib tests — cover namespace boundary transitions exhaustively.
At minimum, your test suite should assert the following after sanitization:
const DANGEROUS_PATTERNS = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i, // event handlers
/data:\s*text\/html/i,
/<svg[^>]*onload/i,
];
function assertSafe(sanitizedOutput) {
for (const pattern of DANGEROUS_PATTERNS) {
if (pattern.test(sanitizedOutput)) {
throw new Error(`Unsafe pattern found: ${pattern}`);
}
}
}
This is a last-resort check, not a replacement for a proper sanitizer. But it catches regressions when a dependency update changes serialization behavior in a way that reintroduces a bypass.
Also consider adding Trusted Types to your Content Security Policy. Trusted Types forces all DOM sink assignments (like innerHTML and document.write) to go through a registered policy function. Any string that was not explicitly marked as trusted by your sanitizer policy causes a TypeError at runtime. This creates a programmatic enforcement layer that your tests can validate deterministically.
Common Pitfalls When Implementing a Sanitizer
Sanitizing on write instead of on read
Sanitizing when user input arrives feels intuitive. The problem is that your sanitization logic will change over time. Any data sanitized with an older, less strict configuration is permanently stored. Sanitizing at render time means a policy update applies to all stored content immediately. The tradeoff is performance — but for most apps, the security posture improvement is worth it.
Using a regex to strip tags
Regex-based tag stripping is not a sanitizer. It is a filter that attackers can evade with malformed input, null bytes, unusual whitespace, or encoding tricks. Use a proper parser-based sanitizer. If your language does not have one, port a known-good implementation or find a binding to one.
Forgetting server-side enforcement
Client-side sanitization can be bypassed by posting directly to your API, skipping the browser entirely. Any content that ends up stored must be sanitized on the server, regardless of what happens on the client. Both layers can exist; only the server layer is mandatory.
This mirrors the same pattern seen with insecure file upload handling — where client-side type checks are trivially bypassed by crafting a raw HTTP request.
Trusting a library without pinning its version
DOMPurify has had multiple bypass disclosures over its lifetime. Each was patched quickly, but if you are running an unpinned dependency, you may be running an older vulnerable version. Pin your sanitizer library version, subscribe to its security advisories, and treat sanitizer updates as security patches.
No CSP as a second layer
Your sanitizer is your first line. Content Security Policy is your second. A strict CSP with script-src 'none' or a nonce-based policy means that even if a payload slips through your sanitizer, the browser refuses to execute it. These layers compound: neither alone is sufficient, but together they make exploitation significantly harder. The full picture of how CSP interacts with sanitization is worth understanding — especially the ways CSP itself can be configured to be useless.
Wrapping Up: Concrete Next Steps
Allowlists are the right approach — but they require active maintenance and a clear-eyed understanding of their failure modes. Here is what to do starting today:
- Audit your attribute handling. Check every attribute your sanitizer allows. Confirm
on*attributes are fully blocked,hrefandsrcvalues are scheme-validated, andstyleis either blocked or passed through a CSS sanitizer. - Add a namespace test suite. Run SVG and MathML namespace-boundary payloads from PortSwigger's XSS cheat sheet through your sanitizer right now. If any survive, you have work to do.
- Enable Trusted Types in report-only mode. Deploy
Content-Security-Policy-Report-Only: require-trusted-types-for 'script'and review the violation reports. This surfaces DOM sinks you did not know existed. - Pin and monitor your sanitizer dependency. Subscribe to DOMPurify or your library's GitHub releases. Treat any security patch as a P0 deployment.
- Add a serialize-and-reparse check. For any sanitizer running in a browser context, serialize the cleaned DOM to a string, parse it again, and compare. If the tree differs, reject the input.
Frequently Asked Questions
Can DOMPurify be bypassed to achieve stored XSS?
DOMPurify has had documented bypass vulnerabilities in past versions, particularly through mXSS and namespace confusion in SVG. Keeping DOMPurify pinned to its latest version and combining it with a strict Content Security Policy significantly reduces the risk, but no sanitizer should be treated as a single point of trust.
What is the difference between stored XSS and reflected XSS when it comes to sanitizer bypass?
Stored XSS is more dangerous because the payload is written to a database and served to every subsequent visitor, not just the attacker. Sanitizer bypasses in stored XSS mean every user who loads the affected page is a victim, multiplying the impact compared to a reflected attack that requires tricking one person into clicking a crafted URL.
Should I sanitize HTML on the server or the client side?
You must sanitize on the server side — it is the only enforcement point you control. Client-side sanitization is useful as a defense-in-depth layer, but an attacker can post directly to your API and bypass any browser-based sanitizer entirely. Sanitize on the server at minimum, and optionally also on the client.
Why is allowing the style attribute in HTML sanitizers risky?
The style attribute can be used to inject CSS that creates invisible overlay elements for clickjacking, loads external resources, or in legacy browsers executes CSS expressions. Even in modern browsers, unrestricted style attributes let attackers manipulate page layout in ways that enable phishing and UI redressing attacks.
What is mXSS and how does it bypass an allowlist sanitizer?
Mutation XSS (mXSS) occurs when a sanitizer cleans HTML and serializes the result to a string, but the browser's parser transforms that string into a different DOM tree when it is rendered — one that contains executable content the sanitizer never saw. The fix is to serialize the sanitized DOM, parse it again, and compare the two trees before accepting the output.
📤 Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!