// Link and display policy of the richeditor field (D-10, T-17-10, T-17-11). // 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. // // 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'] // Browsers ignore ASCII control characters, spaces and DEL inside a scheme, // so "java\tscript:" is read with them removed. const URL_IGNORED = /[\x00-\x20\x7f]+/g const URL_SCHEME = /^([a-z][a-z0-9+.-]*):/i /** * Whether url may be a link href: an http, https, mailto or tel URL, or a * relative one (a path starting with /, #, ? or ./, or a bare relative path). */ export function isAllowedLink(url: string | null | undefined): boolean { if (url == null) { return false } const cleaned = url.replace(URL_IGNORED, '') if (cleaned === '') { return false } const scheme = URL_SCHEME.exec(cleaned) if (scheme) { return LINK_SCHEMES.includes(scheme[1]!.toLowerCase()) } try { new URL(cleaned, 'https://admin.invalid/') return true } catch { return false } } /** Elements dropped with their content when a value is displayed. */ const DROPPED: readonly string[] = [ 'script', 'style', 'template', 'noscript', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'svg', 'math', 'title', 'head', 'meta', 'link', 'base', ] // 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 = 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 = '

ad

' const CANARY_CLEAN = '

ad

' 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. Fails * closed: where the sanitizer cannot run faithfully it returns ''. */ export function displayHtml(html: string): string { if (html === '' || !sanitizerUsable()) { return '' } return instance()!.sanitize(html, CONFIG) }