/** * sanitizeHtml — the ONE door every piece of markup a preview draws passes * through before it reaches the page. * * A preview turns somebody's file into HTML inside filex's own page: a * Markdown document (markdown-it with inline HTML, the GitHub/GitLab * contract), a notebook's cells and its `text/html` outputs, highlighted code, * a Mermaid diagram, a KaTeX formula. That markup lands in `v-html` or * `innerHTML` of a page that holds the person's session, so it goes through * DOMPurify here, with an allow-list narrow enough to keep a README looking * like a README (headings, tables, code blocks, pictures, links,
) * and nothing that runs or submits: no script, no event attribute, no * `javascript:`/`data:` link, no form, frame, object or embed, no `url()` in a * style. The explorer on the web, the desktop app and every embed (the web * component, the React adapter) draw previews from this package, so they all * sanitize with this one function — never a local copy. * * DOMPurify: https://github.com/cure53/DOMPurify, dual-licensed Apache-2.0 OR * MPL-2.0, bundled unmodified in @brftech/filex-core. * * ⚠ Kinds, not options: a caller says WHAT it is drawing and this file owns * the policy for it. A new preview surface picks one of the kinds; it does * not pass DOMPurify flags of its own. */ import DOMPurify, { type Config, type DOMPurify as Purifier } from 'dompurify'; /** * What is being drawn: * - `document`: a Markdown rendering or a notebook's HTML output — the * narrow document allow-list below; * - `code`: syntax-highlighted source (spans with classes, nothing else); * - `diagram`: a Mermaid SVG (SVG with its own style sheet and HTML labels); * - `math`: a KaTeX rendering (HTML spans plus the MathML twin for screen * readers). */ export type SanitizeKind = 'document' | 'code' | 'diagram' | 'math'; const DOCUMENT_TAGS = [ 'a', 'abbr', 'b', 'bdi', 'bdo', 'blockquote', 'br', 'caption', 'cite', 'code', 'col', 'colgroup', 'dd', 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'ins', 'kbd', 'li', 'mark', 'ol', 'p', 'picture', 'pre', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'small', 'source', 'span', 'strike', 'strong', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'time', 'tr', 'tt', 'u', 'ul', 'var', 'wbr', ]; const DOCUMENT_ATTRS = [ 'abbr', 'align', 'alt', 'cite', 'class', 'colspan', 'datetime', 'dir', 'height', 'href', 'id', 'lang', 'loading', 'media', 'open', 'rel', 'reversed', 'rowspan', 'scope', 'span', 'src', 'srcset', 'start', 'style', 'target', 'title', 'valign', 'width', ]; /** Never, in any kind — whatever a profile would otherwise allow. */ const FORBID_TAGS = [ 'script', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'form', 'input', 'button', 'select', 'option', 'textarea', 'base', 'meta', 'link', 'noscript', 'template', 'portal', 'dialog', ]; const FORBID_ATTR = ['action', 'formaction', 'srcdoc', 'ping', 'background', 'poster']; const POLICY: Record = { document: { ALLOWED_TAGS: DOCUMENT_TAGS, ALLOWED_ATTR: DOCUMENT_ATTRS, // Ids a document brings are prefixed (`user-content-…`) so they can never // shadow a global the page reads (DOM clobbering). SANITIZE_NAMED_PROPS: true, }, code: { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class'], }, diagram: { USE_PROFILES: { svg: true, svgFilters: true, html: true }, // What Mermaid itself keeps when it sanitizes its output: HTML labels // live in (an HTML integration point for them), and text // anchoring needs dominant-baseline. ADD_TAGS: ['foreignobject'], ADD_ATTR: ['dominant-baseline'], HTML_INTEGRATION_POINTS: { foreignobject: true }, }, math: { USE_PROFILES: { html: true, mathMl: true }, }, }; let purifier: Purifier | null = null; /** A private DOMPurify instance with filex's hooks; null outside a browser. */ function instance(): Purifier | null { if (purifier) return purifier; if (typeof window === 'undefined') return null; const p = DOMPurify(window); if (!p.isSupported) return null; p.addHook('uponSanitizeAttribute', (_node, data) => { if (data.attrName === 'style') { const clean = cleanStyle(data.attrValue); if (clean === '') data.keepAttr = false; else data.attrValue = clean; } }); p.addHook('uponSanitizeElement', (node, data) => { if (data.tagName === 'style' && node.textContent) { node.textContent = cleanStyleSheet(node.textContent); } }); p.addHook('afterSanitizeAttributes', (node) => { if (node.tagName === 'A') { const target = node.getAttribute('target'); if (target && target !== '_blank') node.removeAttribute('target'); if (node.getAttribute('target') === '_blank') node.setAttribute('rel', 'noopener noreferrer'); } }); purifier = p; return p; } /** * A declaration list with every declaration that could fetch or run * something removed: `url()` other than a same-document `#fragment`, * `image-set()`, `expression()`, `@import`, legacy `behavior`/`-moz-binding`. * CSS escapes (`\75 rl(`) can spell any of those, so a declaration with a * backslash is dropped as well. */ export function cleanStyle(style: string): string { return style .split(';') .map((d) => d.trim()) .filter((d) => d !== '' && !unsafeCss(d)) .join('; '); } function cleanStyleSheet(css: string): string { if (/\\/.test(css)) return ''; return css .replace(/@import[^;]*;?/gi, '') .replace(/url\s*\(\s*(['"]?)(?!#)[^)]*\)/gi, 'none') .replace(/image-set\s*\([^)]*\)/gi, 'none') .replace(/expression\s*\(/gi, 'x('); } function unsafeCss(declaration: string): boolean { if (/\\/.test(declaration)) return true; if (/expression\s*\(|@import|behavior\s*:|-moz-binding|image-set\s*\(|javascript:/i.test(declaration)) return true; const urls = declaration.match(/url\s*\(\s*(['"]?)([^)'"]*)/gi) ?? []; return urls.some((u) => !/url\s*\(\s*['"]?#/i.test(u)); } function escapeText(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /** * Markup safe to hand to `v-html` / `innerHTML`, for `kind`. Outside a * browser (no DOM to parse with) the input comes back as escaped TEXT — * never as the markup it was. */ export function sanitizeHtml(html: string, kind: SanitizeKind = 'document'): string { const p = instance(); if (!p) return escapeText(html); const cfg: Config = { ...POLICY[kind], FORBID_TAGS, FORBID_ATTR, ALLOW_UNKNOWN_PROTOCOLS: false, RETURN_TRUSTED_TYPE: false, }; return String(p.sanitize(html, cfg)); }