Harden the email sanitizer's CSS handling
Rewrite mail CSS in place instead of cutting pieces out, so a strip can no longer join text into a closing </style>, and escape < last. Decode escaped letters before checking, parse url() properly and drop CSS that cannot be parsed, and disable @import and image-set() in every spelling. The body element's style goes through the same path. Give <area> links the same target, rel and click handling as <a>, strip <style> blocks from HTML quoted into the composer, and contain the editor's layout as .message-body already is.
This commit is contained in:
+116
-19
@@ -8,6 +8,12 @@ export interface SanitizeOptions {
|
||||
allowRemote?: boolean;
|
||||
/** Route remote images through the privacy proxy. */
|
||||
proxyRemote?: boolean;
|
||||
/**
|
||||
* Drop `<style>` blocks. For HTML headed into the composer, which lives in
|
||||
* the app document rather than a shadow root, so a sender's stylesheet
|
||||
* would style the whole app.
|
||||
*/
|
||||
dropStyleBlocks?: boolean;
|
||||
}
|
||||
|
||||
export interface SanitizeResult {
|
||||
@@ -17,21 +23,14 @@ export interface SanitizeResult {
|
||||
}
|
||||
|
||||
const REMOTE_URL_RE = /^(https?:)?\/\//i;
|
||||
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
||||
|
||||
let hooked = false;
|
||||
function ensureHooks() {
|
||||
if (hooked) return;
|
||||
hooked = true;
|
||||
DOMPurify.addHook("uponSanitizeElement", (node, data) => {
|
||||
// Strip <style> in dark-mode-unfriendly cases? No - keep styles, we scope them in a shadow root.
|
||||
if (data.tagName === "style" && node.textContent) {
|
||||
// Remove @import and remote url() references; they're handled later in processRemote().
|
||||
node.textContent = node.textContent.replace(/@import[^;]+;?/gi, "");
|
||||
}
|
||||
});
|
||||
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
|
||||
if (node.tagName === "A") {
|
||||
// An image map's <area> is a link too, and must not be able to navigate the app's tab.
|
||||
if (node.tagName === "A" || node.tagName === "AREA") {
|
||||
node.setAttribute("target", "_blank");
|
||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||
}
|
||||
@@ -61,6 +60,100 @@ function hardenCss(css: string): string {
|
||||
.replace(/position\s*:\s*(fixed|sticky)/gi, "position:static");
|
||||
}
|
||||
|
||||
/*
|
||||
* Mail CSS is rewritten as text, so one rule holds throughout: nothing is ever
|
||||
* cut out of it. Deleting a substring joins what was either side of it, and a
|
||||
* sender can arrange for the join to spell `</style>` -- which is how an
|
||||
* `@import` strip that ran after DOMPurify let markup out of a style block.
|
||||
* Everything below replaces in place instead, and `<` is escaped last, so
|
||||
* whatever the text says, it cannot close its element.
|
||||
*/
|
||||
|
||||
const IDENT_CHAR = /[\w\-\u0080-\uFFFF]/;
|
||||
|
||||
/**
|
||||
* Decode escapes that stand for letters or `-`, and write every other hex
|
||||
* escape in the form that always ends with one space.
|
||||
*
|
||||
* `\75rl(` is a `url(` to a browser, and `position:\66ixed` is fixed, so the
|
||||
* checks below have to see the letters. Decoding only letters keeps the
|
||||
* meaning: an escaped letter is that letter in an identifier or a string
|
||||
* alike. The canonical space stops a decoded letter being read as more hex
|
||||
* digits of the escape before it (`\31\61` would otherwise become `\31a`).
|
||||
*/
|
||||
function decodeCssLetters(css: string): string {
|
||||
return css.replace(/\\(?:([0-9a-fA-F]{1,6})(?:\r\n|[ \t\n\r\f])?|([^0-9a-fA-F\n\r\f]))/g, (m, hex: string | undefined, ch: string | undefined) => {
|
||||
if (hex !== undefined) {
|
||||
const cp = parseInt(hex, 16);
|
||||
const c = cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "";
|
||||
return /^[A-Za-z-]$/.test(c) ? c : `\\${hex} `;
|
||||
}
|
||||
return /^[A-Za-z-]$/.test(ch!) ? ch! : m;
|
||||
});
|
||||
}
|
||||
|
||||
/** Functions that load an image from a bare string, with no url() to rewrite. */
|
||||
const STRING_IMAGE_FN = /(?<![\w\-\\\u0080-\uFFFF])(-webkit-image-set|image-set|-webkit-cross-fade|cross-fade|image|src)(\s*\()/gi;
|
||||
|
||||
/**
|
||||
* Rewrite every `url(...)` through `rewrite`, or return null when one cannot
|
||||
* be parsed, in which case the caller drops the CSS rather than guess.
|
||||
*/
|
||||
function rewriteCssUrls(css: string, rewrite: (url: string) => string | null): string | null {
|
||||
const re = /url\(/gi;
|
||||
let out = "";
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(css))) {
|
||||
if (m.index > 0 && IDENT_CHAR.test(css[m.index - 1]!)) continue;
|
||||
let i = m.index + 4;
|
||||
while (i < css.length && /\s/.test(css[i]!)) i++;
|
||||
let value = "";
|
||||
const q = css[i];
|
||||
if (q === '"' || q === "'") {
|
||||
i++;
|
||||
for (;;) {
|
||||
if (i >= css.length || css[i] === "\n") return null;
|
||||
if (css[i] === "\\") { value += css.slice(i, i + 2); i += 2; continue; }
|
||||
if (css[i] === q) { i++; break; }
|
||||
value += css[i++];
|
||||
}
|
||||
while (i < css.length && /\s/.test(css[i]!)) i++;
|
||||
if (css[i] !== ")") return null;
|
||||
} else {
|
||||
const end = css.indexOf(")", i);
|
||||
if (end < 0) return null;
|
||||
value = css.slice(i, end).trim();
|
||||
if (/["'(\s]/.test(value)) return null;
|
||||
i = end;
|
||||
}
|
||||
// A backslash in a URL is an escape we would have to decode to judge; no
|
||||
// image mail really needs one, so it is simply not loaded.
|
||||
const r = value.includes("\\") ? null : rewrite(value);
|
||||
out += css.slice(last, m.index) + (r ? `url("${r.replace(/[\\"]/g, (c) => (c === '"' ? "\\22 " : "\\5c ")).replace(/[\r\n\f]/g, "")}")` : "none");
|
||||
last = i + 1;
|
||||
re.lastIndex = last;
|
||||
}
|
||||
return out + css.slice(last);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make mail CSS safe to place in the page: urls rewritten, imports and
|
||||
* string-image functions disabled, positioning hardened, and `<` escaped.
|
||||
* Null means the CSS could not be read and should be dropped whole.
|
||||
*/
|
||||
function sanitizeCss(css: string, rewrite: (url: string) => string | null): string | null {
|
||||
let s = decodeCssLetters(css).replace(/\/\*[\s\S]*?(\*\/|$)/g, " ");
|
||||
const urls = rewriteCssUrls(s, rewrite);
|
||||
if (urls === null) return null;
|
||||
s = urls
|
||||
// Renamed rather than removed: an unknown at-rule or function is dropped
|
||||
// by the browser, and a rename cannot join anything together.
|
||||
.replace(/@import/gi, "@ihm-blocked-import")
|
||||
.replace(STRING_IMAGE_FN, "ihm-blocked$2");
|
||||
return hardenCss(s).replace(/</g, "\\3c ");
|
||||
}
|
||||
|
||||
export function proxiedImageUrl(url: string): string {
|
||||
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
||||
}
|
||||
@@ -136,24 +229,28 @@ export function sanitizeEmailHtml(input: string, opts: SanitizeOptions = {}): Sa
|
||||
}
|
||||
});
|
||||
|
||||
// CSS url() in style attributes and <style> blocks
|
||||
const rewriteCss = (css: string): string =>
|
||||
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
|
||||
const r = rewriteUrl(u);
|
||||
return r.keep ? `url(${q}${r.url}${q})` : "none";
|
||||
});
|
||||
// CSS in style attributes and <style> blocks
|
||||
const cssUrl = (u: string): string | null => {
|
||||
const r = rewriteUrl(u);
|
||||
return r.keep ? r.url : null;
|
||||
};
|
||||
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
||||
const s = el.getAttribute("style");
|
||||
if (!s) return;
|
||||
const out = hardenCss(/url\(/i.test(s) ? rewriteCss(s) : s);
|
||||
if (out !== s) el.setAttribute("style", out);
|
||||
const out = sanitizeCss(s, cssUrl);
|
||||
if (out === null) el.removeAttribute("style");
|
||||
else if (out !== s) el.setAttribute("style", out);
|
||||
});
|
||||
clean.querySelectorAll("style").forEach((st) => {
|
||||
if (opts.dropStyleBlocks) {
|
||||
st.remove();
|
||||
return;
|
||||
}
|
||||
const css = st.textContent ?? "";
|
||||
if (!css) return;
|
||||
st.textContent = hardenCss(rewriteCss(css.replace(/@import[^;]+;?/gi, "")));
|
||||
st.textContent = sanitizeCss(css, cssUrl) ?? "";
|
||||
});
|
||||
if (bodyStyle && /url\(/i.test(bodyStyle)) bodyStyle = rewriteCss(bodyStyle);
|
||||
if (bodyStyle) bodyStyle = sanitizeCss(bodyStyle, cssUrl) ?? "";
|
||||
|
||||
return { html: clean.innerHTML, remoteCount, bodyStyle };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user