fix(admin): sanitize the richeditor read-only display with DOMPurify (T-10-16)

- displayHtml uses a dedicated DOMPurify 3.4.16 instance: EDITOR_TAGS, href
  only on <a> through isAllowedLink, DROPPED elements go with their content
- string in, string out: parsed into DOMPurify's inert document, nothing
  adopted into the live document before binding
- fails closed (empty string) unless isSupported and a fixed canary hold
- Vitest browser mode in Playwright Chromium (npm run test:browser) covers
  the clobbering vectors, off-origin form, iframe, meta refresh, links and
  the inert parse; happy-dom keeps engine-independent assertions
This commit is contained in:
2026-10-12 20:35:57 +02:00
parent fba15c9378
commit c1d767f0f8
9 changed files with 522 additions and 45 deletions

View File

@@ -2,7 +2,13 @@
// The server sanitizer (seaglass) is the gate on every save; these client
// checks keep the editor from offering what the gate would remove and keep a
// stored value that predates the gate from running in the admin.
import { EDITOR_TAGS, inertFragment } from './lossy'
//
// The read-only display is sanitized by DOMPurify (T-10-16, T-17-10), not by
// a hand-built walker: form controls named like DOM properties (nodeType,
// localName) clobber what a walker reads and let it skip a subtree. Decision
// note: .planning/notes/2026-10-12-admin-display-sanitizer-dompurify.md
import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'
import { EDITOR_TAGS } from './lossy'
/** Link schemes the editor applies; anything else must be a relative URL. */
export const LINK_SCHEMES: readonly string[] = ['http', 'https', 'mailto', 'tel']
@@ -37,7 +43,7 @@ export function isAllowedLink(url: string | null | undefined): boolean {
}
/** Elements dropped with their content when a value is displayed. */
const DROPPED = new Set([
const DROPPED: readonly string[] = [
'script',
'style',
'template',
@@ -55,50 +61,64 @@ const DROPPED = new Set([
'meta',
'link',
'base',
])
]
function clean(parent: ParentNode): void {
for (const node of Array.from(parent.childNodes)) {
if (node.nodeType === Node.COMMENT_NODE) {
node.remove()
continue
}
if (node.nodeType !== Node.ELEMENT_NODE) {
continue
}
const element = node as Element
const name = element.localName.toLowerCase()
if (DROPPED.has(name)) {
element.remove()
continue
}
clean(element)
if (!EDITOR_TAGS.has(name)) {
// Another element keeps its text: its children take its place.
element.replaceWith(...Array.from(element.childNodes))
continue
}
for (const attribute of Array.from(element.attributes)) {
const keep = name === 'a' && attribute.name === 'href' && isAllowedLink(attribute.value)
if (!keep) {
element.removeAttribute(attribute.name)
// Only the editor's elements and a link's href. DOMPurify parses the string
// with DOMParser into an inert document and returns a string: no node is
// adopted into the live document and nothing loads while sanitizing. Never
// RETURN_DOM, RETURN_DOM_FRAGMENT or IN_PLACE, and never a Node as input.
const CONFIG: Readonly<Config> = Object.freeze({
ALLOWED_TAGS: [...EDITOR_TAGS],
ALLOWED_ATTR: ['href'],
ALLOW_DATA_ATTR: false,
ALLOW_ARIA_ATTR: false,
ADD_FORBID_CONTENTS: [...DROPPED],
KEEP_CONTENT: true,
RETURN_TRUSTED_TYPE: false,
})
// What the canary must sanitize to. DOMPurify returns its input unchanged
// where it cannot run, and under happy-dom it visits only top-level nodes, so
// displayHtml binds nothing unless this exact answer comes back.
const CANARY = '<p onclick="x"><b>a</b><script>b</script><a href="javascript:c">d</a></p>'
const CANARY_CLEAN = '<p><b>a</b><a>d</a></p>'
let purifier: Purifier | null = null
let usable: boolean | null = null
/** The dedicated DOMPurify instance; its hook is not registered globally. */
function instance(): Purifier | null {
if (purifier === null && typeof window !== 'undefined') {
purifier = DOMPurify(window)
purifier.addHook('uponSanitizeAttribute', (node, data) => {
// Clobbered forms are removed before attributes are visited, and only
// forms expose named controls, so nodeName is the element's own.
if (data.attrName === 'href' && (node.nodeName !== 'A' || !isAllowedLink(data.attrValue))) {
data.keepAttr = false
}
}
})
}
return purifier
}
/** Whether DOMPurify runs faithfully here (isSupported and the canary). */
function sanitizerUsable(): boolean {
if (usable === null) {
const current = instance()
usable = current !== null && current.isSupported && current.sanitize(CANARY, CONFIG) === CANARY_CLEAN
}
return usable
}
/**
* The read-only rendering of a stored value: only the editor's elements,
* only allowed link hrefs, no other attribute, no comment. It is what may be
* bound as HTML in the admin; the stored value itself is never bound.
* bound as HTML in the admin; the stored value itself is never bound. Fails
* closed: where the sanitizer cannot run faithfully it returns ''.
*/
export function displayHtml(html: string): string {
if (html === '') {
if (html === '' || !sanitizerUsable()) {
return ''
}
const fragment = inertFragment(html)
clean(fragment)
const holder = document.createElement('div')
holder.append(fragment)
return holder.innerHTML
return instance()!.sanitize(html, CONFIG)
}