From 55fcbf72f50ca3b652d49fdeff5a26a2c26dc11e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 16 Sep 2026 07:07:26 -0700 Subject: [PATCH] Harden the email sanitizer's CSS handling Rewrite mail CSS in place instead of cutting pieces out, so a strip can no longer join text into a closing , and escape < last. Decode escaped letters before checking, parse url() properly and drop CSS that cannot be parsed, and disable @import and image-set() in every spelling. The body element's style goes through the same path. Give links the same target, rel and click handling as , strip "; + const out = sanitizeEmailHtml(payload).html; + const dom = parse(out); + expect(dom.querySelector("img")).toBeNull(); + expect(dom.querySelectorAll("style")).toHaveLength(1); + expect(out).not.toMatch(/<\/style>\s* { + const out = sanitizeEmailHtml("

x

").html; + const css = parse(out).querySelector("style")!.textContent!; + expect(css).toContain("p{color:red}"); + expect(css).toContain("content:'\\3c b'"); + }); + + it("disables @import in every spelling, even with remote content allowed", () => { + for (const css of ['@import "https://t.example/a.css";', "@import url(https://t.example/a.css);", '@\\69mport "https://t.example/a.css";', '@IM\\PORT "https://t.example/a.css";']) { + const out = sanitizeEmailHtml(`

x

`, { allowRemote: true }).html; + expect(out, css).not.toMatch(/@import/i); + expect(out, css).toContain("@ihm-blocked-import"); + } + }); + + it("blocks image-set and other string-image functions", () => { + const out = sanitizeEmailHtml(`

x

y

`).html; + expect(out).not.toMatch(/image-set\(/i); + }); + + it("rewrites escaped and awkwardly quoted url()", () => { + const cases = [ + `

x

`, + `

x

`, + `

x

`, + ]; + for (const html of cases) { + const r = sanitizeEmailHtml(html); + expect(r.html, html).not.toContain("trk.example"); + expect(r.html, html).not.toContain("t.example"); + } + }); + + it("drops relative urls, which would reach the app's own image proxy", () => { + const out = sanitizeEmailHtml(`

x

`).html; + expect(out).not.toContain("/api/image"); + }); + + it("drops a style attribute it cannot parse", () => { + const out = sanitizeEmailHtml(`

x

`).html; + expect(out).not.toContain("t.example"); + }); + + it("sees escaped fixed positioning and :host", () => { + const out = sanitizeEmailHtml(`

x

`).html; + expect(out).not.toMatch(/:host/i); + expect(out).not.toMatch(/fixed/i); + }); + + it("does not let decoded letters merge into the escape before them", () => { + const out = sanitizeEmailHtml("

x

").html; + expect(out).toContain(".\\31 a{color:red}"); + }); + + it("keeps non-letter escapes, such as CJK font names", () => { + const out = sanitizeEmailHtml(`

x

`).html; + expect(out).toContain("\\5FAE \\8F6F \\96C5 \\9ED1 "); + }); + + it("sanitizes the body element's style too", () => { + const r = sanitizeEmailHtml(`

x

`); + expect(r.bodyStyle).not.toMatch(/image-set\(/i); + expect(r.bodyStyle).not.toMatch(/fixed/i); + }); + + it("still maps cid and allowed remote images in CSS", () => { + const r = sanitizeEmailHtml(`

x

`, { cidMap: { "bg@x": "/api/blob/a/b/bg.png" }, allowRemote: true, proxyRemote: true }); + expect(r.html).toContain('url("/api/blob/a/b/bg.png")'); + expect(r.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fb.png"); + expect(r.remoteCount).toBe(1); + }); + + it("drops style blocks for the composer", () => { + const out = sanitizeEmailHtml("

x

", { dropStyleBlocks: true }).html; + expect(out).not.toContain("x

"); + }); +}); + +describe("image map links", () => { + it("cannot target the app's tab", () => { + const out = sanitizeEmailHtml('').html; + const area = new DOMParser().parseFromString(out, "text/html").querySelector("area"); + expect(area?.getAttribute("target")).toBe("_blank"); + expect(area?.getAttribute("rel")).toContain("noopener"); + }); +}); + describe("the containment that mail CSS cannot override", () => { it("is still applied to the message body container", async () => { // jsdom does no layout, so this asserts the control is present rather than diff --git a/web/src/lib/text/html.ts b/web/src/lib/text/html.ts index 9ad5e71..bb0d9a4 100644 --- a/web/src/lib/text/html.ts +++ b/web/src/lib/text/html.ts @@ -8,6 +8,12 @@ export interface SanitizeOptions { 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(/ blocks - const rewriteCss = (css: string): string => - css.replace(CSS_URL_RE, (_m, q: string, u: string) => { - const r = rewriteUrl(u); - return r.keep ? `url(${q}${r.url}${q})` : "none"; - }); + // CSS in style attributes and