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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user