Deleting <script> from Markdown with a regular expression is not an HTML security boundary. The browser eventually consumes a DOM, not the original Markdown string. A regex that matches one attribute spelling can miss another spelling the HTML parser accepts, while an aggressive replacement can damage text that was supposed to remain inside a code span.
The reliable order is:
Markdown source
-> pinned parser
-> HTML sanitizer for the real output policy
-> controlled DOM sink
-> CSP / Trusted Types as defense in depth
A regex that passes the easy test
I tested this deliberately narrow cleaner:
function naiveRegexClean(html) {
return html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/javascript:/gi, '');
}
It removes a plain <script> and a double-quoted onerror. It does not remove the same event attribute when it is unquoted or single-quoted:
| Input | Result |
|---|---|
<img src=x onerror="alert(1)"> |
event attribute removed |
<img src=x onerror=alert(1)> |
event attribute remains |
<img src=x onerror='alert(1)'> |
event attribute remains |
Replacing javascript: also turns the URL into a different string. It does not prove that the resulting URL matches the application's protocol or navigation policy.
The point is not that every regex is short. The point is that string matching and browser HTML parsing operate at different abstraction levels.
The fixed-version test
I used Node.js 25.3.0, Marked 18.0.7, DOMPurify 3.4.12, and MDFold's current browser-side Markdown-to-HTML path. The fixture covered ordinary formatting, raw scripts, quoted and unquoted event handlers, Markdown and raw-HTML javascript: links, inline code, a remote image, and a fixed-position style.
Marked preserved the raw HTML in its generated output. That is expected: the Marked documentation explicitly warns that it does not sanitize output HTML.
The real browser preview then sanitized the parsed HTML. It:
- preserved bold text and an HTTPS link;
- removed the script element and event handler;
- removed unsafe
hrefvalues while preserving link text; - preserved the attack-shaped string inside inline code as escaped text;
- preserved the remote image URL;
- preserved the tested
styleattribute.
This is more informative than checking whether an alert happened to appear. The test inspected the resulting elements, attributes, URLs, and text nodes.
Parsing, sanitizing, and resource policy are separate
CommonMark defines how raw HTML participates in Markdown parsing. It does not define a web application's XSS policy. OWASP recommends output encoding when data should remain text and HTML sanitization when authors are allowed to provide HTML. DOMPurify works on a parsed, inert DOM and applies an allow-list to elements and attributes.
An application that does not need raw HTML can reject HTML nodes earlier. That reduces the surface but does not answer every downstream question. Images can still cause network requests. Links can still leave the site. Plugins can still generate HTML. The final sink still owns the final policy.
The two preserved values in my test show the remaining boundaries:
- A sanitized remote image can still make a request. XSS prevention and privacy are different controls.
- A permitted style can still obscure content. Script safety and visual-integrity policy are different controls.
Do not modify sanitized HTML afterward
This sequence destroys the boundary:
const clean = DOMPurify.sanitize(dirty);
target.innerHTML = clean.replace('USER', untrustedValue);
Every untrusted value needs the defense for the context where it is inserted. Prefer safe sinks such as textContent for plain text. If a CMS or template engine reparses or mutates the HTML later, validate at that boundary too.
A practical review checklist
- Pin parser and sanitizer versions separately.
- Disable raw HTML when the product does not need it.
- When HTML is required, start with a narrow allow-list.
- Define protocol rules for
hrefand resource rules forsrc. - Decide whether inline styles belong in the content model.
- Avoid post-sanitization string concatenation.
- Inspect the final DOM and network behavior, not only the source string.
- Retest in the destination CMS, browser, and mobile layout.
I reproduced the browser portion with the MDFold Markdown-to-HTML converter. At a 390-pixel viewport the tested preview stayed within the page width, but that result covers this fixture and current version only.
The engineering question is not "which regex catches every payload?" It is "which component owns the policy for this exact output sink?"
Top comments (0)