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