diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d64b145ab..723de4ac0 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, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; -import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailHtml, sanitizeEmailHtmlForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { withBasePath } from "@/lib/browser-navigation"; @@ -1694,6 +1694,9 @@ export function EmailViewer({ // http(s) links open in a new tab; other schemes keep their default. applyNewTabToAnchor(node); + // Re-apply the data:-URI allowlist DOMPurify skips on media tags. + restrictDataUriResourcesOnNode(node); + // No dark mode color transforms - emails render true-to-life in iframe }); @@ -1786,7 +1789,7 @@ export function EmailViewer({ return cidBlobUrls[cidRef] || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; } ); - const cleanHtml = DOMPurify.sanitize(htmlWithCidUrls, EMAIL_IFRAME_SANITIZE_CONFIG); + const cleanHtml = sanitizeEmailHtmlForIframe(htmlWithCidUrls); return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(pluginRenderedHtml), externalBlocked: false }; } if (pluginRenderedText) { @@ -1794,7 +1797,7 @@ export function EmailViewer({ } // TNEF (winmail.dat) extracted content if (tnefHtml) { - const cleanHtml = DOMPurify.sanitize(tnefHtml, EMAIL_IFRAME_SANITIZE_CONFIG); + const cleanHtml = sanitizeEmailHtmlForIframe(tnefHtml); return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(tnefHtml), externalBlocked: false }; } if (tnefText) { @@ -1802,7 +1805,7 @@ export function EmailViewer({ } // Embedded message/rfc822 unwrapped content if (embeddedEmailHtml) { - const cleanHtml = DOMPurify.sanitize(embeddedEmailHtml, EMAIL_IFRAME_SANITIZE_CONFIG); + const cleanHtml = sanitizeEmailHtmlForIframe(embeddedEmailHtml); return { html: cleanHtml, isHtml: true, hasStyleTag: /]/i.test(embeddedEmailHtml), externalBlocked: false }; } if (embeddedEmailText) { diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 275c73e53..9b339cdca 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useRef, useCallback } from "react"; import DOMPurify from "dompurify"; import { Email, ThreadGroup } from "@/lib/jmap/types"; -import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; +import { EMAIL_SANITIZE_CONFIG, collapseBlockedImageContainers, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { hasMeaningfulHtmlBody } from "@/lib/signature-utils"; import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse"; import { transformInlineStyles, transformColorForDarkMode, transformBgColorForDarkMode } from "@/lib/color-transform"; @@ -364,6 +364,9 @@ function EmailCard({ DOMPurify.addHook('afterSanitizeAttributes', (node) => { const htmlNode = node as HTMLElement; + // Re-apply the data:-URI allowlist DOMPurify skips on media tags. + restrictDataUriResourcesOnNode(node); + if (!allowExternal) { if (node.tagName === 'IMG') { const src = node.getAttribute('src'); diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index cba60414b..119fee224 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -20,6 +20,8 @@ import { stripExternalCssUrls, stripExternalStyleSheetCss, blockExternalResourcesOnNode, + restrictDataUriResourcesOnNode, + sanitizeEmailHtmlForIframe, TRANSPARENT_BLOCKED_PIXEL, } from '../email-sanitization'; @@ -786,4 +788,58 @@ describe('email-sanitization', () => { expect(sanitizePluginBodyHtml(' ')).toBe(''); }); }); + + // ALLOWED_URI_REGEXP excludes image/svg+xml on purpose (DOMPurify cannot see + // inside a data: URI), but its media-tag carve-out short-circuits that check + // before the regexp runs - so every data: URI reached and friends. + describe('restrictDataUriResourcesOnNode', () => { + const blocked = [ + ['svg base64', 'data:image/svg+xml;base64,PHN2Zy8+'], + ['svg inline', 'data:image/svg+xml,'], + ['text/html', 'data:text/html;base64,PHNjcmlwdD4='], + ['javascript', 'data:application/javascript,alert(1)'], + ['svg, leading space', ' data:image/svg+xml,x'], + ['svg, control chars in scheme', 'da\tta:image/svg+xml,x'], + ] as const; + + for (const [label, uri] of blocked) { + it(`drops ${label} from an img src`, () => { + expect(sanitizeEmailHtmlForIframe(``)).not.toContain('data:'); + expect(sanitizeEmailHtml(``)).not.toContain('data:'); + }); + } + + it('covers the other media tags DOMPurify waves through', () => { + for (const tag of ['video', 'audio', 'source', 'track']) { + const html = `<${tag} src="data:image/svg+xml,x">`; + expect(sanitizeEmailHtmlForIframe(html)).not.toContain('data:'); + } + // is rewritten to by the parser, href included. + expect(sanitizeEmailHtmlForIframe('')).not.toContain('data:'); + }); + + it('keeps inline raster images and every other allowed scheme', () => { + expect(sanitizeEmailHtmlForIframe('')).toContain('data:image/png'); + expect(sanitizeEmailHtmlForIframe('')).toContain('data:image/gif'); + expect(sanitizeEmailHtmlForIframe('')).toContain('data:image/jpeg'); + expect(sanitizeEmailHtmlForIframe('')).toContain('cid:part1'); + expect(sanitizeEmailHtmlForIframe('')).toContain('https://example.com/a.png'); + expect(sanitizeEmailHtmlForIframe('')).toContain('blob:'); + }); + + it('leaves non-media elements to ALLOWED_URI_REGEXP', () => { + const node = parseHtmlSafely('x').querySelector('a')!; + restrictDataUriResourcesOnNode(node); + expect(node.getAttribute('href')).toBe('data:image/svg+xml,x'); + }); + + // The blocked-external placeholder shares this hook pass, so it must not be + // a data: URI the restriction would strip right back out. + it('preserves the blocked-image placeholder', () => { + const node = parseHtmlSafely('').querySelector('img')!; + blockExternalResourcesOnNode(node); + restrictDataUriResourcesOnNode(node); + expect(node.getAttribute('src')).toBe(TRANSPARENT_BLOCKED_PIXEL); + }); + }); }); diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index a7d67b172..1aeb8d541 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -34,13 +34,27 @@ export const EMAIL_SANITIZE_CONFIG = { ], }; +/** + * Sanitize with `restrictDataUriResourcesOnNode` applied - the config alone + * cannot express it, because DOMPurify's data:-URI carve-out for media tags + * short-circuits ALLOWED_URI_REGEXP and is not removable via config. + */ +function sanitizeWithDataUriGuard(html: string, config: typeof EMAIL_SANITIZE_CONFIG): string { + DOMPurify.addHook('afterSanitizeAttributes', restrictDataUriResourcesOnNode); + try { + return DOMPurify.sanitize(html, config); + } finally { + DOMPurify.removeAllHooks(); + } +} + /** * Sanitize email HTML content * @param html - Raw HTML content from email * @returns Sanitized HTML safe for rendering */ export function sanitizeEmailHtml(html: string): string { - return DOMPurify.sanitize(html, EMAIL_SANITIZE_CONFIG); + return sanitizeWithDataUriGuard(html, EMAIL_SANITIZE_CONFIG); } /** @@ -60,7 +74,7 @@ export const EMAIL_IFRAME_SANITIZE_CONFIG = { * Preserves