// 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. import { EDITOR_TAGS, inertFragment } 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 = new Set([ 'script', 'style', 'template', 'noscript', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'svg', 'math', 'title', 'head', '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) } } } } /** * 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. */ export function displayHtml(html: string): string { if (html === '') { return '' } const fragment = inertFragment(html) clean(fragment) const holder = document.createElement('div') holder.append(fragment) return holder.innerHTML }