From f5373c6fcda3bc62df13b0abe416574e591befc3 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Mon, 24 Aug 2026 11:00:10 -0700 Subject: [PATCH] Stop a message painting over the whole application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shadow root scopes selectors, not layout. `position:fixed` in mail CSS is still positioned against the viewport, and the containment meant to stop that sat inside the shadow root as `.ihm-email-root { contain: content }` — in the same tree as the message's own

hi

`); + expect(out).toContain("position:static"); + expect(out).not.toMatch(/position\s*:\s*fixed/i); + }); + + it("does so in style attributes too, however they are spaced", () => { + expect(render(`

x

`)).not.toMatch(/position\s*:\s*fixed/i); + expect(render(`

x

`)).not.toMatch(/position\s*:\s*sticky/i); + }); + + it("defangs :host, which is how mail CSS would reach the host element", () => { + const out = render(`

x

`); + expect(out).not.toContain(":host"); + expect(out).not.toMatch(/position\s*:\s*fixed/i); + }); + + it("leaves ordinary positioning alone", () => { + const out = render(`

x

`); + expect(out).toContain("position:relative"); + expect(out).toContain("position:absolute"); + }); + + it("still rewrites url() while hardening", () => { + const out = sanitizeEmailHtml(`

x

`, { allowRemote: true, proxyRemote: true }).html; + expect(out).toContain("position:static"); + expect(out).toContain("/api/image?url="); + }); +}); + +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 + // that it works; the behaviour was verified in a real browser. Without it, + // a message can cover the viewport regardless of what the sanitizer does. + const { readFile } = await import("node:fs/promises"); + const { join } = await import("node:path"); + // vitest serves modules over http, so import.meta.url is not a file URL. + const css = await readFile(join(process.cwd(), "src/styles/app.css"), "utf8"); + const rule = /\.message-body\s*\{[^}]*\}/.exec(css)?.[0] ?? ""; + expect(rule).toMatch(/contain\s*:\s*layout/); + }); +}); diff --git a/web/src/lib/html.ts b/web/src/lib/html.ts index 181a023..0bbf569 100644 --- a/web/src/lib/html.ts +++ b/web/src/lib/html.ts @@ -41,6 +41,25 @@ function ensureHooks() { }); } +/** + * Blunt the positioning tricks mail CSS can use to escape its card. + * + * A shadow root scopes selectors but not layout, so `position:fixed` in a + * message is still positioned against the viewport — enough to paint a + * convincing fake over the whole app. The control that actually stops that is + * layout containment on an ancestor of the shadow host (see `.message-body` in + * app.css), which mail CSS has no selector for. This is the second line: + * neutralise the declarations themselves, and defang `:host`, which is how mail + * CSS would otherwise reach the host element. + */ +function hardenCss(css: string): string { + return css + // `:host` / `:host-context` become a selector that matches nothing; where + // they took an argument the rule is left invalid, and so dropped. + .replace(/:host(-context)?/gi, ":not(*)") + .replace(/position\s*:\s*(fixed|sticky)/gi, "position:static"); +} + export function proxiedImageUrl(url: string): string { return `/api/image?url=${encodeURIComponent(url)}`; } @@ -124,12 +143,14 @@ export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): Sa }); clean.querySelectorAll("[style]").forEach((el) => { const s = el.getAttribute("style"); - if (s && /url\(/i.test(s)) el.setAttribute("style", rewriteCss(s)); + if (!s) return; + const out = hardenCss(/url\(/i.test(s) ? rewriteCss(s) : s); + if (out !== s) el.setAttribute("style", out); }); clean.querySelectorAll("style").forEach((st) => { - if (st.textContent && /url\(|@import/i.test(st.textContent)) { - st.textContent = rewriteCss(st.textContent.replace(/@import[^;]+;?/gi, "")); - } + const css = st.textContent ?? ""; + if (!css) return; + st.textContent = hardenCss(rewriteCss(css.replace(/@import[^;]+;?/gi, ""))); }); if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 469d90a..c83b2be 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -471,7 +471,20 @@ img { max-width: 100%; } .message-details { margin: 0 16px 8px; padding: 10px 12px; background: var(--bg-sunken); border-radius: var(--radius-sm); font-size: .88em; display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; } .message-details dt { color: var(--fg-muted); } .message-details dd { margin: 0; overflow-wrap: anywhere; } -.message-body { padding: 4px 16px 16px; } +/* + * Layout containment here is a security control, not a layout tweak. + * + * Message bodies render in a shadow root, which scopes selectors but not + * layout: a `position:fixed` rule in mail CSS is still positioned against the + * viewport, so a sender could paint over the whole application. Containment + * inside the shadow root cannot stop it — mail CSS lives in the same tree and + * simply overrides it, and `:host` reaches the host element too (an + * `!important` there even beats an `!important` from this file, because + * importance reverses tree order). An ancestor of the host is the one thing + * mail CSS has no selector for. `layout` rather than `paint`: it makes this a + * containing block for fixed descendants without clipping tall messages. + */ +.message-body { padding: 4px 16px 16px; contain: layout; } .message-body .body-host { display: block; } .remote-banner { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0 0 12px; padding: 8px 12px; background: var(--warn-soft); color: var(--warn); border-radius: var(--radius-sm); font-size: .9em; } .remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; }