import DOMPurify from "dompurify"; import { BASE_PATH, withBase } from "@/lib/basePath"; export interface SanitizeOptions { /** Map of Content-ID (without angle brackets) → URL for inline images. */ cidMap?: Record; /** Whether remote content (http/https images, css urls) may load. */ allowRemote?: boolean; /** Route remote images through the privacy proxy. */ proxyRemote?: boolean; /** * Drop `` -- which is how an * `@import` strip that ran after DOMPurify let markup out of a style block. * Everything below replaces in place instead, and `<` is escaped last, so * whatever the text says, it cannot close its element. */ const IDENT_CHAR = /[\w\-\u0080-\uFFFF]/; /** * Decode escapes that stand for letters or `-`, and write every other hex * escape in the form that always ends with one space. * * `\75rl(` is a `url(` to a browser, and `position:\66ixed` is fixed, so the * checks below have to see the letters. Decoding only letters keeps the * meaning: an escaped letter is that letter in an identifier or a string * alike. The canonical space stops a decoded letter being read as more hex * digits of the escape before it (`\31\61` would otherwise become `\31a`). */ function decodeCssLetters(css: string): string { return css.replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|([^0-9a-fA-F\n\r\f]))/g, (m, hex: string | undefined, ch: string | undefined) => { if (hex !== undefined) { const cp = parseInt(hex, 16); const c = cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : ""; return /^[A-Za-z-]$/.test(c) ? c : `\\${hex} `; } return /^[A-Za-z-]$/.test(ch!) ? ch! : m; }); } /** Functions that load an image from a bare string, with no url() to rewrite. */ const STRING_IMAGE_FN = /(? string | null): string | null { const re = /url\(/gi; let out = ""; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(css))) { if (m.index > 0 && IDENT_CHAR.test(css[m.index - 1]!)) continue; let i = m.index + 4; while (i < css.length && /\s/.test(css[i]!)) i++; let value = ""; const q = css[i]; if (q === '"' || q === "'") { i++; for (;;) { if (i >= css.length || css[i] === "\n") return null; if (css[i] === "\\") { value += css.slice(i, i + 2); i += 2; continue; } if (css[i] === q) { i++; break; } value += css[i++]; } while (i < css.length && /\s/.test(css[i]!)) i++; if (css[i] !== ")") return null; } else { const end = css.indexOf(")", i); if (end < 0) return null; value = css.slice(i, end).trim(); if (/["'(\s]/.test(value)) return null; i = end; } // A backslash in a URL is an escape we would have to decode to judge; no // image mail really needs one, so it is simply not loaded. const r = value.includes("\\") ? null : rewrite(value); out += css.slice(last, m.index) + (r ? `url("${r.replace(/[\\"]/g, (c) => (c === '"' ? "\\22 " : "\\5c ")).replace(/[\r\n\f]/g, "")}")` : "none"); last = i + 1; re.lastIndex = last; } return out + css.slice(last); } /** * Make mail CSS safe to place in the page: urls rewritten, imports and * string-image functions disabled, positioning hardened, and `<` escaped. * Null means the CSS could not be read and should be dropped whole. */ function sanitizeCss(css: string, rewrite: (url: string) => string | null): string | null { let s = decodeCssLetters(css).replace(/\/\*[\s\S]*?(\*\/|$)/g, " "); const urls = rewriteCssUrls(s, rewrite); if (urls === null) return null; s = urls // Renamed rather than removed: an unknown at-rule or function is dropped // by the browser, and a rename cannot join anything together. .replace(/@import/gi, "@ihm-blocked-import") .replace(STRING_IMAGE_FN, "ihm-blocked$2"); return hardenCss(s).replace(/]*)>/i.exec(input); if (bodyMatch) { const attrs = bodyMatch[1]!; const bg = /bgcolor\s*=\s*["']?([#\w()%,.\s-]+)["']?/i.exec(attrs)?.[1]; const style = /style\s*=\s*"([^"]*)"/i.exec(attrs)?.[1] ?? /style\s*=\s*'([^']*)'/i.exec(attrs)?.[1]; if (bg) bodyStyle += `background-color:${bg.trim()};`; if (style) bodyStyle += style; } const clean = DOMPurify.sanitize(input, { WHOLE_DOCUMENT: false, RETURN_DOM: true, FORBID_TAGS: ["script", "iframe", "frame", "frameset", "object", "embed", "applet", "form", "input", "button", "textarea", "select", "option", "meta", "link", "base", "svg", "math", "video", "audio", "source", "track", "canvas", "template", "slot", "dialog", "noscript"], FORBID_ATTR: ["srcdoc", "formaction", "action", "ping", "autofocus", "autoplay", "contenteditable", "draggable", "tabindex"], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false, USE_PROFILES: { html: true }, ADD_TAGS: ["style", "center", "font", "marquee"], ADD_ATTR: ["bgcolor", "background", "valign", "align", "border", "cellpadding", "cellspacing", "width", "height", "color", "face", "size", "target"], }) as unknown as HTMLElement; let remoteCount = 0; const cidMap = opts.cidMap ?? {}; const allow = Boolean(opts.allowRemote); const proxy = Boolean(opts.proxyRemote); const remote = (url: string): string => { remoteCount++; if (!allow) return ""; return proxy ? proxiedImageUrl(url) : url; }; const rewriteUrl = (raw: string): { url: string; keep: boolean } => { const url = raw.trim(); if (/^cid:/i.test(url)) { const cid = url.slice(4).replace(/^<|>$/g, ""); const mapped = cidMap[cid] ?? cidMap[cid.toLowerCase()]; return mapped ? { url: mapped, keep: true } : { url: "", keep: false }; } if (/^data:image\//i.test(url)) return { url, keep: true }; if (REMOTE_URL_RE.test(url)) { const abs = url.startsWith("//") ? `https:${url}` : url; const u = remote(abs); return { url: u, keep: Boolean(u) }; } // Relative or unknown scheme -> drop. return { url: "", keep: false }; }; // Image-bearing attributes const els = clean.querySelectorAll("[src],[background],[poster],[srcset]"); els.forEach((el) => { if (el.hasAttribute("srcset")) el.removeAttribute("srcset"); for (const attr of ["src", "background", "poster"]) { const v = el.getAttribute(attr); if (v == null) continue; const r = rewriteUrl(v); if (r.keep) el.setAttribute(attr, r.url); else { el.removeAttribute(attr); if (attr === "src" && el.tagName === "IMG") { el.setAttribute("data-ihm-blocked", "1"); if (REMOTE_URL_RE.test(v)) el.setAttribute("data-ihm-remote", v.trim()); } } } }); // CSS in style attributes and