Merge pull request #17 from LINUXexpert-org/message-css-containment
Stop a message painting over the whole application
This commit is contained in:
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user