/** * 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'; import { isExtensionValueValid, NormalizedAllowedStyles } from './email-css'; /** 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('; '); } /* --- email-fidelity: container styles become node attrs, not per-run marks (flag-gated) --- */ /** Containers whose inline styles must not become per-run marks. */ const CONTAINER_TAGS = new Set([ 'table', 'td', 'th', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'li', 'blockquote', 'tr', 'tbody', ]); /** Mark-triggering declarations dropped on any container. */ const CONTAINER_DROP = new Set([ 'color', 'background-color', 'font-family', 'font-size', 'font-weight', 'font-style', 'text-decoration', 'text-decoration-line', 'text-decoration-style', 'text-decoration-color', ]); /** Layout declarations allowed on cells with fidelity. */ const CELL_LAYOUT = new Set([ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'width', 'vertical-align', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-radius', ]); /** `border-width`, `border-top-color`, … — resolved per side through CSSOM instead. */ const CELL_BORDER_LONGHAND = /^border-(?:(?:top|right|bottom|left)-)?(?:width|style|color)$/; /** Layout declarations allowed on tables with fidelity. */ const TABLE_LAYOUT = new Set(['width', 'max-width', 'border', 'margin-left', 'margin-right']); /** Block margins allowed on paragraphs/headings with fidelity. */ const BLOCK_LAYOUT = new Set(['margin', 'margin-top', 'margin-bottom']); const BORDER_STYLES = new Set(['solid', 'dashed', 'dotted', 'double', 'none']); const VALIGN_KEYWORDS = new Set([ 'top', 'middle', 'bottom', 'baseline', 'sub', 'super', 'text-top', 'text-bottom', ]); const LENGTH_TOKEN = /^-?(?:\d+\.?\d*|\.\d+)(?:px|%|em|rem|pt)$/; function isAscii(value: string): boolean { // eslint-disable-next-line no-control-regex return !/[^\x00-\x7f]/.test(value); } /** Forbidden function-ish / escape tokens shared by the length & border grammars. */ function hasUnsafeToken(value: string): boolean { return /[\\/(]|url|var|calc|expression/i.test(value); } function isLengthToken(token: string): boolean { return token === '0' || token === 'auto' || LENGTH_TOKEN.test(token); } /** 1–4 whitespace-separated length tokens (padding/margin/width/radius). */ function isLengthList(value: string): boolean { if (!isAscii(value) || hasUnsafeToken(value)) return false; const tokens = value.trim().split(/\s+/); return tokens.length >= 1 && tokens.length <= 4 && tokens.every(isLengthToken); } function isColor(value: string): boolean { if (!isAscii(value)) return false; const v = value.trim(); if (/^[a-z]+$/i.test(v)) return true; if (/^#[0-9a-f]{3,8}$/i.test(v)) return true; // rgb()/rgba() with numeric (optionally %) arguments only. if (/^rgba?\(\s*[\d.]+%?(?:\s*,\s*[\d.]+%?){2,3}\s*\)$/i.test(v)) return true; // hsl()/hsla() with numeric (optionally %) arguments only — numeric-only, so a // smuggled `hsl(var(--x))` still fails (it has letters inside the parens). return /^hsla?\(\s*[\d.]+%?(?:\s*,\s*[\d.]+%?){2,3}\s*\)$/i.test(v); } /** Line-height for tables: a unitless number or a single length token. */ function isLineHeight(value: string): boolean { if (!isAscii(value) || hasUnsafeToken(value)) return false; const v = value.trim(); if (/^\d+(?:\.\d+)?$/.test(v)) return true; return isLengthToken(v); } /** Cell `text-align` keyword set (also the legacy `align` conversion source). */ const TEXT_ALIGN_KEYWORDS = new Set(['left', 'center', 'right', 'justify', 'start', 'end']); const LEGACY_ALIGN_KEYWORDS = new Set(['left', 'center', 'right', 'justify']); function isBorder(value: string): boolean { if (!isAscii(value)) return false; if (/[\\/]|url|var|calc|expression/i.test(value)) return false; const tokens = value.trim().match(/rgba?\([^)]*\)|\S+/gi) ?? []; if (!tokens.length) return false; let hasStyle = false; for (const token of tokens) { if (BORDER_STYLES.has(token.toLowerCase())) { hasStyle = true; continue; } if (isLengthToken(token) || isColor(token)) continue; return false; } return hasStyle; } function isValign(value: string): boolean { const v = value.trim().toLowerCase(); return VALIGN_KEYWORDS.has(v) || isLengthToken(v); } function isLayoutValid(name: string, value: string): boolean { if (name === 'vertical-align') return isValign(value); // `border-radius` is a length list (a corner rounding), not the width/style/ // colour grammar the `border*` shorthands use — validate it as such. if (name === 'border-radius') return isLengthList(value); if (name.startsWith('border')) return isBorder(value); return isLengthList(value); } /** Validates a cell `color`/`font-*` declaration kept as part of `textStyle`. */ function isCellTextValid(name: string, value: string): boolean { if (!isAscii(value) || /url|expression|var|calc/i.test(value)) return false; if (name === 'color') return isColor(value); if (name === 'font-size') return isLengthToken(value); if (name === 'font-weight') { return /^(normal|bold|bolder|lighter|[1-9]00)$/i.test(value.trim()); } // font-family: names, commas and quotes only — no function-ish characters. return !/[(){}<>;\\]/.test(value); } /** A container's kept style under fidelity: validated layout, carried background/font, legacy attrs. */ function computeContainerStyle(el: Element, tag: string): string { const isCell = tag === 'td' || tag === 'th'; const isTable = tag === 'table'; const isBlock = /^(?:p|h[1-6])$/.test(tag); const extra = SIZED_TAGS[tag]; const kept: string[] = []; for (const decl of (el.getAttribute('style') ?? '').split(';')) { const idx = decl.indexOf(':'); if (idx === -1) continue; const name = decl.slice(0, idx).trim().toLowerCase(); const value = decl.slice(idx + 1).trim(); if (!value || /url\s*\(|expression\s*\(/i.test(decl)) continue; if (name === 'background-color') { if ((isCell || isTable) && isColor(value)) kept.push(`${name}: ${value}`); continue; } if (name === 'color' || name === 'font-family' || name === 'font-size' || name === 'font-weight') { // Blocks and tables keep their own text style; cells inherit the table's. if ((isCell || isBlock || isTable) && isCellTextValid(name, value)) { kept.push(`${name}: ${value}`); } continue; } if (name === 'text-align') { // Cells keep a validated text-align (lifted to data-cell-align); blocks let // it fall through to the align attr via the ALLOWED_STYLES branch below. if (isCell && TEXT_ALIGN_KEYWORDS.has(value.toLowerCase())) { kept.push(`text-align: ${value.toLowerCase()}`); continue; } } if (name === 'line-height' && isTable) { if (isLineHeight(value)) kept.push(`line-height: ${value}`); continue; } if (name === 'margin' && isTable) { // `margin: 0 auto` centring: keep only the horizontal parts as longhands so // the table parseDOM's `margin-left/right: auto` check recognises it. if (isLengthList(value)) { const tokens = value.trim().split(/\s+/); const [, right] = tokens.length === 1 ? [tokens[0], tokens[0]] : [tokens[0], tokens[1]]; const left = tokens.length >= 4 ? tokens[3] : right; kept.push(`margin-left: ${left}`, `margin-right: ${right}`); } continue; } if (CONTAINER_DROP.has(name)) continue; // Border longhand groups (`border-width/-style/-color`, per side or not) are // how the browser re-serializes four equal sides; recombined per side below. if (isCell && CELL_BORDER_LONGHAND.test(name)) continue; if (isCell && CELL_LAYOUT.has(name) && isLayoutValid(name, value)) { kept.push(`${name}: ${value}`); } else if (isTable && TABLE_LAYOUT.has(name) && isLayoutValid(name, value)) { kept.push(`${name}: ${value}`); } else if (isBlock && BLOCK_LAYOUT.has(name) && isLengthList(value)) { kept.push(`${name}: ${value}`); } else if (ALLOWED_STYLES.has(name)) { kept.push(`${name}: ${value}`); } else if (extra && extra.has(name)) { kept.push(`${name}: ${value}`); } } // A cell border written as longhand groups: read each side back through CSSOM, // which resolves any mix of shorthands and longhands to `width style color`. if (isCell && !kept.some(d => d.startsWith('border:'))) { const style = (el as HTMLElement).style; for (const side of ['top', 'right', 'bottom', 'left']) { const prop = `border-${side}`; if (kept.some(d => d.startsWith(`${prop}:`))) continue; const value = style?.getPropertyValue(prop); if (value && isBorder(value)) kept.push(`${prop}: ${value}`); } } // Legacy presentational attributes → validated styles (raw attr removed by the // standard attribute pass, which doesn't list them). if (isCell || isTable) { const bgcolor = el.getAttribute('bgcolor'); if (bgcolor && isColor(bgcolor) && !kept.some(d => d.startsWith('background-color'))) { kept.push(`background-color: ${bgcolor}`); } const widthAttr = el.getAttribute('width'); if (widthAttr && !kept.some(d => d.startsWith('width'))) { const width = /^\d+$/.test(widthAttr) ? `${widthAttr}px` : /^\d+%$/.test(widthAttr) ? widthAttr : null; if (width) kept.push(`width: ${width}`); } const valign = el.getAttribute('valign'); if (isCell && valign && isValign(valign) && !kept.some(d => d.startsWith('vertical-align'))) { kept.push(`vertical-align: ${valign}`); } // Legacy cell align → text-align; the attr stays for table centring. const alignAttr = el.getAttribute('align'); if ( isCell && alignAttr && LEGACY_ALIGN_KEYWORDS.has(alignAttr.toLowerCase()) && !kept.some(d => d.startsWith('text-align')) ) { kept.push(`text-align: ${alignAttr.toLowerCase()}`); } } return kept.join('; '); } /** Cell text declarations lifted onto `data-cell-text-style` before PM parse. */ const CELL_TEXT_PROPS = ['color', 'font-family', 'font-size', 'font-weight']; /** Table text declarations lifted onto `data-table-text-style` before PM parse. */ const TABLE_TEXT_PROPS = ['color', 'font-family', 'font-size', 'font-weight', 'line-height']; /** Moves carried background/font declarations onto data-attrs so they never become marks. */ function relocateElement(el: HTMLElement, tag: string): void { if (tag === 'td' || tag === 'th') { const bg = el.style.backgroundColor; if (bg) { el.setAttribute('data-cell-bg', bg); el.style.removeProperty('background-color'); } const parts: string[] = []; for (const prop of CELL_TEXT_PROPS) { const value = el.style.getPropertyValue(prop); if (value) { parts.push(`${prop}: ${value}`); el.style.removeProperty(prop); } } if (parts.length) el.setAttribute('data-cell-text-style', parts.join('; ')); // Cell text-align → data-cell-align (so it stays a node attr, never a mark). const align = el.style.textAlign; if (align) { el.setAttribute('data-cell-align', align); el.style.removeProperty('text-align'); } if (!el.getAttribute('style')) el.removeAttribute('style'); } else if (tag === 'table') { const bg = el.style.backgroundColor; if (bg) { el.setAttribute('data-table-bg', bg); el.style.removeProperty('background-color'); } const textParts: string[] = []; for (const prop of TABLE_TEXT_PROPS) { const value = el.style.getPropertyValue(prop); if (value) { textParts.push(`${prop}: ${value}`); el.style.removeProperty(prop); } } if (textParts.length) el.setAttribute('data-table-text-style', textParts.join('; ')); // Email containers are centred with `align="center"` on the table or on // the cell that holds it. const holder = el.parentElement?.closest('td, th'); if ( el.getAttribute('align')?.toLowerCase() === 'center' || holder?.getAttribute('align')?.toLowerCase() === 'center' ) { el.setAttribute('data-table-align', 'center'); } if (!el.getAttribute('style')) el.removeAttribute('style'); } } function relocateWalk(node: Element): void { for (const child of Array.from(node.children)) { relocateElement(child as HTMLElement, child.tagName.toLowerCase()); relocateWalk(child); } } /** A `` width (style width or `width` attr) normalized to a px/% token. */ function colWidth(col: Element): string | null { const styleWidth = /width\s*:\s*([^;]+)/i.exec(col.getAttribute('style') ?? '')?.[1]?.trim(); const v = (styleWidth || col.getAttribute('width') || '').trim(); if (!v) return null; if (/^\d+$/.test(v)) return `${v}px`; if (LENGTH_TOKEN.test(v) && /(?:px|%)$/.test(v)) return v; return null; } /** Per-column widths of a table's own colgroup, expanding `span`. */ function tableColWidths(table: Element): (string | null)[] { const widths: (string | null)[] = []; for (const col of Array.from(table.querySelectorAll(':scope > colgroup > col, :scope > col'))) { const span = parseInt(col.getAttribute('span') ?? '1', 10); const n = Number.isFinite(span) && span > 0 ? span : 1; const w = colWidth(col); for (let i = 0; i < n; i += 1) widths.push(w); } return widths; } /** A table's own rows (not nested tables' rows). */ function directRows(table: Element): Element[] { const rows: Element[] = []; for (const child of Array.from(table.children)) { const t = child.tagName.toLowerCase(); if (t === 'tr') rows.push(child); else if (t === 'thead' || t === 'tbody' || t === 'tfoot') { for (const row of Array.from(child.children)) { if (row.tagName.toLowerCase() === 'tr') rows.push(row); } } } return rows; } /** Copies colgroup widths onto unsized cells (rows with a colspan are skipped). */ function applyColgroupWidths(table: Element): void { const widths = tableColWidths(table); if (!widths.some(Boolean)) return; for (const row of directRows(table)) { const cells = Array.from(row.children).filter(c => /^t[dh]$/.test(c.tagName.toLowerCase())); if (cells.some(c => parseInt(c.getAttribute('colspan') ?? '1', 10) > 1)) continue; cells.forEach((cell, idx) => { const w = widths[idx]; if (!w) return; const hasWidth = /(?:^|;)\s*width\s*:/i.test(cell.getAttribute('style') ?? '') || cell.hasAttribute('width'); if (hasWidth) return; const style = cell.getAttribute('style') ?? ''; cell.setAttribute('style', style ? `${style}; width: ${w}` : `width: ${w}`); }); } } /** Fidelity-only pass run right before parsing; see `relocateElement`. */ export function relocateEmailContainers(html: string): string { const doc = new DOMParser().parseFromString(`${html}`, 'text/html'); relocateWalk(doc.body); return doc.body.innerHTML; } /** * Sanitizes the CSS inside a