diff --git a/web/src/lib/__tests__/html.test.ts b/web/src/lib/__tests__/html.test.ts
index e40af30..f386132 100644
--- a/web/src/lib/__tests__/html.test.ts
+++ b/web/src/lib/__tests__/html.test.ts
@@ -50,3 +50,60 @@ describe("htmlDeclaresColors", () => {
expect(htmlDeclaresColors("
plain
", "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(``);
+ 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(``);
+ expect(out).not.toContain(":host");
+ expect(out).not.toMatch(/position\s*:\s*fixed/i);
+ });
+
+ it("leaves ordinary positioning alone", () => {
+ const out = render(``);
+ expect(out).toContain("position:relative");
+ expect(out).toContain("position:absolute");
+ });
+
+ it("still rewrites url() while hardening", () => {
+ const out = sanitizeEmailHtml(``, { 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; }