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 </style>, 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 <area> links the same target, rel and click handling as <a>, strip
<style> blocks from HTML quoted into the composer, and contain the editor's
layout as .message-body already is.
This commit is contained in:
2026-09-16 07:07:26 -07:00
parent d0b13272f3
commit 55fcbf72f5
5 changed files with 228 additions and 24 deletions
+106
View File
@@ -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 = "<p>hi</p><style>p{}<@im@import a;port b;/style><@im@import a;port b;img src=x onerror=alert(1)></style>";
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*<img/i);
});
it("escapes every < left in a style block", () => {
const out = sanitizeEmailHtml("<div><style>p{color:red} q::before{content:'< b'}</style><p>x</p></div>").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(`<div><style>${css}</style><p>x</p></div>`, { 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(`<p style="background-image:image-set('${TRACKER}' 1x)">x</p><p style="background:-webkit-image-set('${TRACKER}' 1x)">y</p>`).html;
expect(out).not.toMatch(/image-set\(/i);
});
it("rewrites escaped and awkwardly quoted url()", () => {
const cases = [
`<p style="background:\\75rl(${TRACKER})">x</p>`,
`<p style="background:url('${TRACKER}&a=&quot;b')">x</p>`,
`<div><style>p{background:u\\RL( "https://t.example/p.gif" )}</style><p>x</p></div>`,
];
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(`<p style="background:url(${TRACKER})">x</p>`).html;
expect(out).not.toContain("/api/image");
});
it("drops a style attribute it cannot parse", () => {
const out = sanitizeEmailHtml(`<p style="color:red;background:url('https://t.example/p.gif">x</p>`).html;
expect(out).not.toContain("t.example");
});
it("sees escaped fixed positioning and :host", () => {
const out = sanitizeEmailHtml(`<div><style>:\\68ost{color:red}.x{position:\\66ixed}</style><p style="position:\\000066ixed">x</p></div>`).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("<div><style>.\\31\\61 {color:red}</style><p>x</p></div>").html;
expect(out).toContain(".\\31 a{color:red}");
});
it("keeps non-letter escapes, such as CJK font names", () => {
const out = sanitizeEmailHtml(`<p style="font-family:'\\5FAE\\8F6F\\96C5\\9ED1'">x</p>`).html;
expect(out).toContain("\\5FAE \\8F6F \\96C5 \\9ED1 ");
});
it("sanitizes the body element's style too", () => {
const r = sanitizeEmailHtml(`<html><body style="background:image-set('${TRACKER}' 1x);position:fixed"><p>x</p></body></html>`);
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(`<div><style>p{background:url(cid:bg@x)}</style></div><p style="background:url('https://t.example/b.png')">x</p>`, { 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("<div><style>body *{visibility:hidden}</style><p>x</p></div>", { dropStyleBlocks: true }).html;
expect(out).not.toContain("<style");
expect(out).toContain("<p>x</p>");
});
});
describe("image map links", () => {
it("cannot target the app's tab", () => {
const out = sanitizeEmailHtml('<img src="cid:x" usemap="#m"><map name="m"><area coords="0,0,9,9" href="https://evil.test/" target="_top"></map>').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", () => { describe("the containment that mail CSS cannot override", () => {
it("is still applied to the message body container", async () => { it("is still applied to the message body container", async () => {
// jsdom does no layout, so this asserts the control is present rather than // jsdom does no layout, so this asserts the control is present rather than
+115 -18
View File
@@ -8,6 +8,12 @@ export interface SanitizeOptions {
allowRemote?: boolean; allowRemote?: boolean;
/** Route remote images through the privacy proxy. */ /** Route remote images through the privacy proxy. */
proxyRemote?: boolean; proxyRemote?: boolean;
/**
* Drop `<style>` blocks. For HTML headed into the composer, which lives in
* the app document rather than a shadow root, so a sender's stylesheet
* would style the whole app.
*/
dropStyleBlocks?: boolean;
} }
export interface SanitizeResult { export interface SanitizeResult {
@@ -17,21 +23,14 @@ export interface SanitizeResult {
} }
const REMOTE_URL_RE = /^(https?:)?\/\//i; const REMOTE_URL_RE = /^(https?:)?\/\//i;
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
let hooked = false; let hooked = false;
function ensureHooks() { function ensureHooks() {
if (hooked) return; if (hooked) return;
hooked = true; hooked = true;
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
// Strip <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
if (data.tagName === "style" && node.textContent) {
// Remove @import and remote url() references; they're handled later in processRemote().
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
}
});
DOMPurify.addHook("afterSanitizeAttributes", (node) => { DOMPurify.addHook("afterSanitizeAttributes", (node) => {
if (node.tagName === "A") { // An image map's <area> is a link too, and must not be able to navigate the app's tab.
if (node.tagName === "A" || node.tagName === "AREA") {
node.setAttribute("target", "_blank"); node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer nofollow"); node.setAttribute("rel", "noopener noreferrer nofollow");
} }
@@ -61,6 +60,100 @@ function hardenCss(css: string): string {
.replace(/position\s*:\s*(fixed|sticky)/gi, "position:static"); .replace(/position\s*:\s*(fixed|sticky)/gi, "position:static");
} }
/*
* Mail CSS is rewritten as text, so one rule holds throughout: nothing is ever
* cut out of it. Deleting a substring joins what was either side of it, and a
* sender can arrange for the join to spell `</style>` -- 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 = /(?<![\w\-\\\u0080-\uFFFF])(-webkit-image-set|image-set|-webkit-cross-fade|cross-fade|image|src)(\s*\()/gi;
/**
* Rewrite every `url(...)` through `rewrite`, or return null when one cannot
* be parsed, in which case the caller drops the CSS rather than guess.
*/
function rewriteCssUrls(css: string, rewrite: (url: string) => 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(/</g, "\\3c ");
}
export function proxiedImageUrl(url: string): string { export function proxiedImageUrl(url: string): string {
return withBase(`/api/image?url=${encodeURIComponent(url)}`); return withBase(`/api/image?url=${encodeURIComponent(url)}`);
} }
@@ -136,24 +229,28 @@ export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): Sa
} }
}); });
// CSS url() in style attributes and <style> blocks // CSS in style attributes and <style> blocks
const rewriteCss = (css: string): string => const cssUrl = (u: string): string | null => {
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
const r = rewriteUrl(u); const r = rewriteUrl(u);
return r.keep ? `url(${q}${r.url}${q})` : "none"; return r.keep ? r.url : null;
}); };
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => { clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const s = el.getAttribute("style"); const s = el.getAttribute("style");
if (!s) return; if (!s) return;
const out = hardenCss(/url\(/i.test(s) ? rewriteCss(s) : s); const out = sanitizeCss(s, cssUrl);
if (out !== s) el.setAttribute("style", out); if (out === null) el.removeAttribute("style");
else if (out !== s) el.setAttribute("style", out);
}); });
clean.querySelectorAll("style").forEach((st) => { clean.querySelectorAll("style").forEach((st) => {
if (opts.dropStyleBlocks) {
st.remove();
return;
}
const css = st.textContent ?? ""; const css = st.textContent ?? "";
if (!css) return; if (!css) return;
st.textContent = hardenCss(rewriteCss(css.replace(/@import[^;]+;?/gi, ""))); st.textContent = sanitizeCss(css, cssUrl) ?? "";
}); });
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle); if (bodyStyle) bodyStyle = sanitizeCss(bodyStyle, cssUrl) ?? "";
return { html: clean.innerHTML, remoteCount, bodyStyle }; return { html: clean.innerHTML, remoteCount, bodyStyle };
} }
+3 -3
View File
@@ -240,7 +240,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
showCc: Boolean(full.cc?.length), showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length), showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "", subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"), html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""), text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat, format: html ? "html" : settings().composeFormat,
attachments, attachments,
@@ -306,7 +306,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
showCc: Boolean(full.cc?.length), showCc: Boolean(full.cc?.length),
showBcc: Boolean(full.bcc?.length), showBcc: Boolean(full.bcc?.length),
subject: full.subject ?? "", subject: full.subject ?? "",
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"), html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
text: text || (html ? htmlToText(html) : ""), text: text || (html ? htmlToText(html) : ""),
format: html ? "html" : settings().composeFormat, format: html ? "html" : settings().composeFormat,
attachments, attachments,
@@ -388,7 +388,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
} }
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time. // Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
const quotedHtmlBody = origHtml const quotedHtmlBody = origHtml
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false }).html ? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false, dropStyleBlocks: true }).html
: textToHtml(origText).replace(/\n/g, "<br>"); : textToHtml(origText).replace(/\n/g, "<br>");
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", ")); const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
const date = formatFullDate(full.receivedAt); const date = formatFullDate(full.receivedAt);
+2 -1
View File
@@ -1594,7 +1594,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
.recipients .chip { height: 24px; } .recipients .chip { height: 24px; }
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; } .recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
.composer-editor { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; } .composer-editor { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
.editor-area { flex: 1; min-height: 120px; overflow-y: auto; padding: 12px 16px; outline: none; line-height: 1.5; font-size: 14px; font-family: var(--font-sans); } /* Quoted mail lands in the app document, so contain it as .message-body does: its positioning cannot reach past the editor. */
.editor-area { flex: 1; min-height: 120px; overflow-y: auto; contain: layout; padding: 12px 16px; outline: none; line-height: 1.5; font-size: 14px; font-family: var(--font-sans); }
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; } .editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); } .editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
.editor-area img { max-width: 100%; height: auto; } .editor-area img { max-width: 100%; height: auto; }
+1 -1
View File
@@ -531,7 +531,7 @@ function HtmlBody({ html, bodyStyle, themed, forced, onShowImages, onFollowLink
const onClick = useCallback( const onClick = useCallback(
(ev: Event) => { (ev: Event) => {
const t = ev.target as HTMLElement; const t = ev.target as HTMLElement;
const a = t.closest("a"); const a = t.closest("a, area");
if (a) { if (a) {
const href = a.getAttribute("href") ?? ""; const href = a.getAttribute("href") ?? "";
if (href.startsWith("mailto:")) { if (href.startsWith("mailto:")) {