Stop a message painting over the whole application

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 <style>, which is inserted after it and simply
overrides it. Any sender could cover the entire window with markup of their
choosing, inside our own origin: a ready-made place to ask for a password.

Verified in a browser: the message rendered at exactly the viewport size with
the maximum z-index.

Moving the containment onto the shadow host does not fix it — mail CSS reaches
the host through `:host`, and an `!important` there beats an `!important` from
the app's own stylesheet, because importance reverses tree order in the
cascade. An ancestor of the host is the one thing mail CSS has no selector
for, so the control goes on .message-body. `layout` rather than `paint`: it
makes the element a containing block for fixed descendants without clipping
tall messages.

The sanitizer now also turns fixed and sticky positioning static and defangs
`:host`, as a second line that does not depend on one CSS declaration.

Checked end to end against the real stylesheet afterwards: the same message
renders 1678x112 instead of 1720x1279.
This commit is contained in:
2026-08-24 11:00:10 -07:00
parent f29a504ead
commit 6242d4dee1
3 changed files with 96 additions and 5 deletions
+57
View File
@@ -50,3 +50,60 @@ describe("htmlDeclaresColors", () => {
expect(htmlDeclaresColors("<p>plain</p>", "background:#eee")).toBe(true);
});
});
/**
* A shadow root scopes selectors, not layout. Mail CSS saying `position:fixed`
* is still positioned against the viewport, so a sender could paint over the
* whole application — a ready-made phishing surface inside our own origin.
*
* The control that actually stops it is layout containment on an ancestor of
* the shadow host, which mail CSS has no selector for; that lives in app.css
* and is asserted at the bottom of this file, because jsdom does no layout and
* cannot prove it here. These cover the second line of defence.
*/
describe("mail CSS cannot climb out of its card", () => {
const render = (html: string) => sanitizeEmailHtml(html).html;
it("turns fixed and sticky positioning into static", () => {
const out = render(`<div><style>.x{position:fixed;inset:0;z-index:2147483647}</style><p class="x">hi</p></div>`);
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(`<p style="position: FIXED; color:red">x</p>`)).not.toMatch(/position\s*:\s*fixed/i);
expect(render(`<p style="position:sticky;top:0">x</p>`)).not.toMatch(/position\s*:\s*sticky/i);
});
it("defangs :host, which is how mail CSS would reach the host element", () => {
const out = render(`<div><style>:host{contain:none!important;position:fixed!important}</style><p>x</p></div>`);
expect(out).not.toContain(":host");
expect(out).not.toMatch(/position\s*:\s*fixed/i);
});
it("leaves ordinary positioning alone", () => {
const out = render(`<div><style>.a{position:relative}.b{position:absolute;top:2px}</style><p>x</p></div>`);
expect(out).toContain("position:relative");
expect(out).toContain("position:absolute");
});
it("still rewrites url() while hardening", () => {
const out = sanitizeEmailHtml(`<div><style>.x{position:fixed;background:url(https://tracker.example/p.gif)}</style><p>x</p></div>`, { 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/);
});
});
+25 -4
View File
@@ -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<HTMLElement>("[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);
+14 -1
View File
@@ -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; }