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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user