From d0ed4b4dfe9ebe52ae21e5228f60f2da29a69a9e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:07:23 +0200 Subject: [PATCH] fix: block remaining email tracking vectors #457 --- components/email/email-viewer.tsx | 154 ++++++----------- lib/__tests__/email-sanitization.test.ts | 206 +++++++++++++++++++++++ lib/email-sanitization.ts | 155 +++++++++++++++++ 3 files changed, 411 insertions(+), 104 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 239c86f8..e8b8aadd 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -5,7 +5,7 @@ import DOMPurify from "dompurify"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { withBasePath } from "@/lib/browser-navigation"; import { Button } from "@/components/ui/button"; @@ -2462,7 +2462,7 @@ export function EmailViewer({ // Sanitize and prepare email HTML content const emailContent = useMemo(() => { - if (!email) return { html: "", isHtml: false, hasStyleTag: false }; + if (!email) return { html: "", isHtml: false, hasStyleTag: false, externalBlocked: false }; // Check if we have body values if (email.bodyValues) { @@ -2530,38 +2530,14 @@ export function EmailViewer({ } DOMPurify.addHook('afterSanitizeAttributes', (node) => { - const htmlNode = node as HTMLElement; - if (shouldBlockExternal) { - if (node.tagName === 'IMG') { - const src = node.getAttribute('src'); - if (src && (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//'))) { - node.setAttribute('data-blocked-src', src); - node.setAttribute('src', 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB2aWV3Qm94PSIwIDAgMSAxIiBmaWxsPSJub25lIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSJ0cmFuc3BhcmVudCIvPgo8L3N2Zz4='); - node.setAttribute('alt', ''); - htmlNode.style.display = 'none'; - blockedExternalContent = true; - } - } - - const bgAttr = node.getAttribute?.('background'); - if (bgAttr && (bgAttr.startsWith('http://') || bgAttr.startsWith('https://') || bgAttr.startsWith('//'))) { - node.setAttribute('data-blocked-background', bgAttr); - node.removeAttribute('background'); + // Blocks every external-resource vector (img src incl. + // whitespace/newline tricks, srcset, ,