diff --git a/web/src/lib/text/__tests__/html.test.ts b/web/src/lib/text/__tests__/html.test.ts
index c392d22..b610cf4 100644
--- a/web/src/lib/text/__tests__/html.test.ts
+++ b/web/src/lib/text/__tests__/html.test.ts
@@ -268,6 +268,112 @@ describe("mail CSS cannot climb out of its card", () => {
});
});
+describe("mail CSS cannot smuggle markup or remote loads", () => {
+ const TRACKER = "/api/image?url=https%3A%2F%2Ftrk.example%2Fo";
+ // Parse the output the way the reading pane does, and look at what came out.
+ const parse = (html: string) => {
+ const host = document.createElement("div");
+ host.innerHTML = html;
+ return host;
+ };
+
+ it("cannot close its style block, however an @import strip would join", () => {
+ const payload = "
hi
";
+ 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("").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(``, { 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
`,
+ ``,
+ ];
+ 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(``).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("").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("", { dropStyleBlocks: true }).html;
+ expect(out).not.toContain("` -- 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