Merge pull request #17 from LINUXexpert-org/message-css-containment

Stop a message painting over the whole application
This commit is contained in:
LINUXexpert.org
2026-08-24 11:07:54 -07:00
committed by GitHub
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); 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 { export function proxiedImageUrl(url: string): string {
return `/api/image?url=${encodeURIComponent(url)}`; return `/api/image?url=${encodeURIComponent(url)}`;
} }
@@ -124,12 +143,14 @@ export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): Sa
}); });
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => { clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
const s = el.getAttribute("style"); 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) => { clean.querySelectorAll("style").forEach((st) => {
if (st.textContent && /url\(|@import/i.test(st.textContent)) { const css = st.textContent ?? "";
st.textContent = rewriteCss(st.textContent.replace(/@import[^;]+;?/gi, "")); if (!css) return;
} st.textContent = hardenCss(rewriteCss(css.replace(/@import[^;]+;?/gi, "")));
}); });
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle); 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 { 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 dt { color: var(--fg-muted); }
.message-details dd { margin: 0; overflow-wrap: anywhere; } .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; } .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 { 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; } .remote-banner button { color: inherit; font-weight: 700; text-decoration: underline; }