/** * HTML sanitization pre-pass. The ProseMirror schema is the real whitelist — * anything it can't parse is dropped — but this pass removes actively * dangerous content (scripts, event handlers, javascript: URLs) before the * string ever touches a live DOM, and strips style declarations down to the * set the schema understands. */ import { isAllowedEmbedSrc, EMBED_SANDBOX, EMBED_REFERRER_POLICY } from './plugins/embeds'; /** Elements removed together with their content. */ const DROP_WITH_CONTENT = new Set([ 'script', 'frame', 'frameset', 'object', 'embed', 'applet', 'form', 'input', 'textarea', 'select', 'button', 'meta', 'link', 'base', 'title', 'noscript', 'template', 'svg', 'math', 'canvas', 'audio', 'video', 'dialog', 'slot', ]); /** Elements kept as-is (with filtered attributes). Extended in later phases. */ const ALLOWED_TAGS = new Set([ 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'li', 'br', 'hr', 'a', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'span', 'code', 'sub', 'sup', 'div', 'font', 'pre', 'figure', 'figcaption', 'img', 'iframe', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th', 'colgroup', 'col', ]); /** Attributes allowed per tag (on top of GLOBAL_ATTRS). */ const TAG_ATTRS: Record> = { a: new Set(['href', 'title', 'target', 'rel', 'id', 'name', 'data-bookmark']), table: new Set(['role', 'data-layout']), ol: new Set(['start', 'type', 'data-list-style']), ul: new Set(['data-task-list']), figure: new Set(['data-align']), li: new Set(['data-task-item', 'data-checked']), span: new Set([ 'data-mention-trigger', 'data-mention-key', 'data-mention-label', 'data-merge-field', 'data-merge-label', 'data-math', 'data-math-display', ]), pre: new Set(['data-language']), img: new Set(['src', 'alt', 'title', 'width', 'height']), td: new Set(['colspan', 'rowspan', 'data-colwidth']), th: new Set(['colspan', 'rowspan', 'data-colwidth']), col: new Set(['span', 'width']), div: new Set(['data-embed-provider', 'data-width', 'data-align']), iframe: new Set([ 'src', 'title', 'frameborder', 'allowfullscreen', 'allow', 'sandbox', 'referrerpolicy', ]), }; const GLOBAL_ATTRS = new Set(['style', 'align', 'class', 'id']); /** Author class/id names starting with these are the editor's own; drop them so * content can't impersonate or restyle the chrome. */ const RESERVED_NAME = /^(?:nile-wysiwyg|ProseMirror)/; /** Filtered `class`: reserved-prefixed tokens removed. Empty → drop attribute. */ function cleanClassAttr(value: string): string { return value .split(/\s+/) .filter(token => token && !RESERVED_NAME.test(token)) .join(' '); } /** Style declarations the schema can represent. Everything else is noise. */ const ALLOWED_STYLES = new Set([ 'color', 'background-color', 'font-family', 'font-size', 'font-style', 'font-weight', 'text-align', 'text-decoration', 'text-decoration-line', 'vertical-align', 'margin-left', 'margin-inline-start', 'list-style-type', 'line-height', ]); const SAFE_URL_PROTOCOLS = ['http:', 'https:', 'mailto:', 'tel:', 'ftp:']; export function isSafeUrl(url: string, allowDataImage = false): boolean { const trimmed = url.trim(); if (trimmed === '') return false; // Relative URLs and fragments have no protocol — safe. if (/^(#|\/|\.\/|\.\.\/)/.test(trimmed)) return true; const match = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed); if (!match) return true; const protocol = `${match[1].toLowerCase()}:`; if (SAFE_URL_PROTOCOLS.includes(protocol)) return true; if (allowDataImage && protocol === 'data:') { // Raster images only — data:image/svg+xml can carry scripts. return /^data:image\/(png|gif|jpe?g|webp|avif|bmp);/i.test(trimmed); } return false; } /** Size styles allowed only where they are part of the document model. */ const SIZED_TAGS: Record> = { img: new Set(['width', 'height']), figure: new Set(['width', 'margin-right']), div: new Set(['width', 'max-width']), }; function filterStyle(value: string, tag: string): string { const extra = SIZED_TAGS[tag]; return value .split(';') .map(decl => decl.trim()) .filter(decl => { const name = decl.slice(0, decl.indexOf(':')).trim().toLowerCase(); if (!ALLOWED_STYLES.has(name) && !(extra && extra.has(name))) return false; // Defense in depth: CSS url()/expression() smuggling. return !/url\s*\(|expression\s*\(/i.test(decl); }) .join('; '); } /** * Sanitizes the CSS inside a