Merge commit from fork
Harden the email sanitizer's CSS handling
This commit is contained in:
@@ -268,6 +268,112 @@ describe("mail CSS cannot climb out of its card", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mail CSS cannot smuggle markup or remote loads", () => {
|
||||||
|
const TRACKER = "/api/image?url=https%3A%2F%2Ftrk.example%2Fo";
|
||||||
|
// Parse the output the way the reading pane does, and look at what came out.
|
||||||
|
const parse = (html: string) => {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.innerHTML = html;
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("cannot close its style block, however an @import strip would join", () => {
|
||||||
|
const payload = "<p>hi</p><style>p{}<@im@import a;port b;/style><@im@import a;port b;img src=x onerror=alert(1)></style>";
|
||||||
|
const out = sanitizeEmailHtml(payload).html;
|
||||||
|
const dom = parse(out);
|
||||||
|
expect(dom.querySelector("img")).toBeNull();
|
||||||
|
expect(dom.querySelectorAll("style")).toHaveLength(1);
|
||||||
|
expect(out).not.toMatch(/<\/style>\s*<img/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes every < left in a style block", () => {
|
||||||
|
const out = sanitizeEmailHtml("<div><style>p{color:red} q::before{content:'< b'}</style><p>x</p></div>").html;
|
||||||
|
const css = parse(out).querySelector("style")!.textContent!;
|
||||||
|
expect(css).toContain("p{color:red}");
|
||||||
|
expect(css).toContain("content:'\\3c b'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables @import in every spelling, even with remote content allowed", () => {
|
||||||
|
for (const css of ['@import "https://t.example/a.css";', "@import url(https://t.example/a.css);", '@\\69mport "https://t.example/a.css";', '@IM\\PORT "https://t.example/a.css";']) {
|
||||||
|
const out = sanitizeEmailHtml(`<div><style>${css}</style><p>x</p></div>`, { allowRemote: true }).html;
|
||||||
|
expect(out, css).not.toMatch(/@import/i);
|
||||||
|
expect(out, css).toContain("@ihm-blocked-import");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks image-set and other string-image functions", () => {
|
||||||
|
const out = sanitizeEmailHtml(`<p style="background-image:image-set('${TRACKER}' 1x)">x</p><p style="background:-webkit-image-set('${TRACKER}' 1x)">y</p>`).html;
|
||||||
|
expect(out).not.toMatch(/image-set\(/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites escaped and awkwardly quoted url()", () => {
|
||||||
|
const cases = [
|
||||||
|
`<p style="background:\\75rl(${TRACKER})">x</p>`,
|
||||||
|
`<p style="background:url('${TRACKER}&a="b')">x</p>`,
|
||||||
|
`<div><style>p{background:u\\RL( "https://t.example/p.gif" )}</style><p>x</p></div>`,
|
||||||
|
];
|
||||||
|
for (const html of cases) {
|
||||||
|
const r = sanitizeEmailHtml(html);
|
||||||
|
expect(r.html, html).not.toContain("trk.example");
|
||||||
|
expect(r.html, html).not.toContain("t.example");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops relative urls, which would reach the app's own image proxy", () => {
|
||||||
|
const out = sanitizeEmailHtml(`<p style="background:url(${TRACKER})">x</p>`).html;
|
||||||
|
expect(out).not.toContain("/api/image");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a style attribute it cannot parse", () => {
|
||||||
|
const out = sanitizeEmailHtml(`<p style="color:red;background:url('https://t.example/p.gif">x</p>`).html;
|
||||||
|
expect(out).not.toContain("t.example");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sees escaped fixed positioning and :host", () => {
|
||||||
|
const out = sanitizeEmailHtml(`<div><style>:\\68ost{color:red}.x{position:\\66ixed}</style><p style="position:\\000066ixed">x</p></div>`).html;
|
||||||
|
expect(out).not.toMatch(/:host/i);
|
||||||
|
expect(out).not.toMatch(/fixed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let decoded letters merge into the escape before them", () => {
|
||||||
|
const out = sanitizeEmailHtml("<div><style>.\\31\\61 {color:red}</style><p>x</p></div>").html;
|
||||||
|
expect(out).toContain(".\\31 a{color:red}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-letter escapes, such as CJK font names", () => {
|
||||||
|
const out = sanitizeEmailHtml(`<p style="font-family:'\\5FAE\\8F6F\\96C5\\9ED1'">x</p>`).html;
|
||||||
|
expect(out).toContain("\\5FAE \\8F6F \\96C5 \\9ED1 ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes the body element's style too", () => {
|
||||||
|
const r = sanitizeEmailHtml(`<html><body style="background:image-set('${TRACKER}' 1x);position:fixed"><p>x</p></body></html>`);
|
||||||
|
expect(r.bodyStyle).not.toMatch(/image-set\(/i);
|
||||||
|
expect(r.bodyStyle).not.toMatch(/fixed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still maps cid and allowed remote images in CSS", () => {
|
||||||
|
const r = sanitizeEmailHtml(`<div><style>p{background:url(cid:bg@x)}</style></div><p style="background:url('https://t.example/b.png')">x</p>`, { cidMap: { "bg@x": "/api/blob/a/b/bg.png" }, allowRemote: true, proxyRemote: true });
|
||||||
|
expect(r.html).toContain('url("/api/blob/a/b/bg.png")');
|
||||||
|
expect(r.html).toContain("/api/image?url=https%3A%2F%2Ft.example%2Fb.png");
|
||||||
|
expect(r.remoteCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops style blocks for the composer", () => {
|
||||||
|
const out = sanitizeEmailHtml("<div><style>body *{visibility:hidden}</style><p>x</p></div>", { dropStyleBlocks: true }).html;
|
||||||
|
expect(out).not.toContain("<style");
|
||||||
|
expect(out).toContain("<p>x</p>");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("image map links", () => {
|
||||||
|
it("cannot target the app's tab", () => {
|
||||||
|
const out = sanitizeEmailHtml('<img src="cid:x" usemap="#m"><map name="m"><area coords="0,0,9,9" href="https://evil.test/" target="_top"></map>').html;
|
||||||
|
const area = new DOMParser().parseFromString(out, "text/html").querySelector("area");
|
||||||
|
expect(area?.getAttribute("target")).toBe("_blank");
|
||||||
|
expect(area?.getAttribute("rel")).toContain("noopener");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the containment that mail CSS cannot override", () => {
|
describe("the containment that mail CSS cannot override", () => {
|
||||||
it("is still applied to the message body container", async () => {
|
it("is still applied to the message body container", async () => {
|
||||||
// jsdom does no layout, so this asserts the control is present rather than
|
// jsdom does no layout, so this asserts the control is present rather than
|
||||||
|
|||||||
+115
-18
@@ -8,6 +8,12 @@ export interface SanitizeOptions {
|
|||||||
allowRemote?: boolean;
|
allowRemote?: boolean;
|
||||||
/** Route remote images through the privacy proxy. */
|
/** Route remote images through the privacy proxy. */
|
||||||
proxyRemote?: boolean;
|
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 {
|
export interface SanitizeResult {
|
||||||
@@ -17,21 +23,14 @@ export interface SanitizeResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REMOTE_URL_RE = /^(https?:)?\/\//i;
|
const REMOTE_URL_RE = /^(https?:)?\/\//i;
|
||||||
const CSS_URL_RE = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
|
|
||||||
|
|
||||||
let hooked = false;
|
let hooked = false;
|
||||||
function ensureHooks() {
|
function ensureHooks() {
|
||||||
if (hooked) return;
|
if (hooked) return;
|
||||||
hooked = true;
|
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) => {
|
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("target", "_blank");
|
||||||
node.setAttribute("rel", "noopener noreferrer nofollow");
|
node.setAttribute("rel", "noopener noreferrer nofollow");
|
||||||
}
|
}
|
||||||
@@ -61,6 +60,100 @@ function hardenCss(css: string): string {
|
|||||||
.replace(/position\s*:\s*(fixed|sticky)/gi, "position:static");
|
.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 {
|
export function proxiedImageUrl(url: string): string {
|
||||||
return withBase(`/api/image?url=${encodeURIComponent(url)}`);
|
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
|
// CSS in style attributes and <style> blocks
|
||||||
const rewriteCss = (css: string): string =>
|
const cssUrl = (u: string): string | null => {
|
||||||
css.replace(CSS_URL_RE, (_m, q: string, u: string) => {
|
|
||||||
const r = rewriteUrl(u);
|
const r = rewriteUrl(u);
|
||||||
return r.keep ? `url(${q}${r.url}${q})` : "none";
|
return r.keep ? r.url : null;
|
||||||
});
|
};
|
||||||
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
clean.querySelectorAll<HTMLElement>("[style]").forEach((el) => {
|
||||||
const s = el.getAttribute("style");
|
const s = el.getAttribute("style");
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
const out = hardenCss(/url\(/i.test(s) ? rewriteCss(s) : s);
|
const out = sanitizeCss(s, cssUrl);
|
||||||
if (out !== s) el.setAttribute("style", out);
|
if (out === null) el.removeAttribute("style");
|
||||||
|
else if (out !== s) el.setAttribute("style", out);
|
||||||
});
|
});
|
||||||
clean.querySelectorAll("style").forEach((st) => {
|
clean.querySelectorAll("style").forEach((st) => {
|
||||||
|
if (opts.dropStyleBlocks) {
|
||||||
|
st.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const css = st.textContent ?? "";
|
const css = st.textContent ?? "";
|
||||||
if (!css) return;
|
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 };
|
return { html: clean.innerHTML, remoteCount, bodyStyle };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
showCc: Boolean(full.cc?.length),
|
showCc: Boolean(full.cc?.length),
|
||||||
showBcc: Boolean(full.bcc?.length),
|
showBcc: Boolean(full.bcc?.length),
|
||||||
subject: full.subject ?? "",
|
subject: full.subject ?? "",
|
||||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||||
text: text || (html ? htmlToText(html) : ""),
|
text: text || (html ? htmlToText(html) : ""),
|
||||||
format: html ? "html" : settings().composeFormat,
|
format: html ? "html" : settings().composeFormat,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -306,7 +306,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
showCc: Boolean(full.cc?.length),
|
showCc: Boolean(full.cc?.length),
|
||||||
showBcc: Boolean(full.bcc?.length),
|
showBcc: Boolean(full.bcc?.length),
|
||||||
subject: full.subject ?? "",
|
subject: full.subject ?? "",
|
||||||
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
html: html ? sanitizeEmailHtml(html, { cidMap, allowRemote: true, dropStyleBlocks: true }).html : textToHtml(text).replace(/\n/g, "<br>"),
|
||||||
text: text || (html ? htmlToText(html) : ""),
|
text: text || (html ? htmlToText(html) : ""),
|
||||||
format: html ? "html" : settings().composeFormat,
|
format: html ? "html" : settings().composeFormat,
|
||||||
attachments,
|
attachments,
|
||||||
@@ -388,7 +388,7 @@ export const useCompose = create<ComposeState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
|
// Inline images are shown via their blob URLs in the editor and converted back to cid: at send time.
|
||||||
const quotedHtmlBody = origHtml
|
const quotedHtmlBody = origHtml
|
||||||
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false }).html
|
? sanitizeEmailHtml(origHtml, { cidMap, allowRemote: true, proxyRemote: false, dropStyleBlocks: true }).html
|
||||||
: textToHtml(origText).replace(/\n/g, "<br>");
|
: textToHtml(origText).replace(/\n/g, "<br>");
|
||||||
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
|
const fromStr = escapeHtml((full.from ?? []).map(formatAddress).join(", "));
|
||||||
const date = formatFullDate(full.receivedAt);
|
const date = formatFullDate(full.receivedAt);
|
||||||
|
|||||||
@@ -1594,7 +1594,8 @@ select optgroup { background-color: var(--bg-elev); color: var(--fg); }
|
|||||||
.recipients .chip { height: 24px; }
|
.recipients .chip { height: 24px; }
|
||||||
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
|
.recipients input { flex: 1; min-width: 120px; border: 0; background: transparent; outline: none; height: 28px; }
|
||||||
.composer-editor { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
|
.composer-editor { flex: 1; min-height: 0; display: flex; flex-direction: column; position: relative; }
|
||||||
.editor-area { flex: 1; min-height: 120px; overflow-y: auto; padding: 12px 16px; outline: none; line-height: 1.5; font-size: 14px; font-family: var(--font-sans); }
|
/* Quoted mail lands in the app document, so contain it as .message-body does: its positioning cannot reach past the editor. */
|
||||||
|
.editor-area { flex: 1; min-height: 120px; overflow-y: auto; contain: layout; padding: 12px 16px; outline: none; line-height: 1.5; font-size: 14px; font-family: var(--font-sans); }
|
||||||
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
.editor-area:empty::before, .editor-area[data-empty="true"]::before { content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none; position: absolute; }
|
||||||
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
.editor-area blockquote { margin: 0 0 0 .8ex; border-left: 2px solid var(--border-strong); padding-left: 1ex; color: var(--fg-muted); }
|
||||||
.editor-area img { max-width: 100%; height: auto; }
|
.editor-area img { max-width: 100%; height: auto; }
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ function HtmlBody({ html, bodyStyle, themed, forced, onShowImages, onFollowLink
|
|||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(ev: Event) => {
|
(ev: Event) => {
|
||||||
const t = ev.target as HTMLElement;
|
const t = ev.target as HTMLElement;
|
||||||
const a = t.closest("a");
|
const a = t.closest("a, area");
|
||||||
if (a) {
|
if (a) {
|
||||||
const href = a.getAttribute("href") ?? "";
|
const href = a.getAttribute("href") ?? "";
|
||||||
if (href.startsWith("mailto:")) {
|
if (href.startsWith("mailto:")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user