From 6ebf7206887e70bf51a16cd251dfb9f96e6d90b3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 18 May 2026 12:47:44 +0200 Subject: [PATCH] fix: block script-bearing MIME types from inline attachment preview --- components/email/email-viewer.tsx | 9 +++++++-- lib/file-preview.ts | 20 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 90a839a6..9ebb861b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -81,7 +81,7 @@ import { useTour } from "@/components/tour/tour-provider"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover } from "./recipient-popover"; -import { isFilePreviewable } from "@/lib/file-preview"; +import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview"; import { SmimeStatusBanner } from "./smime-status-banner"; import { detectSmime } from "@/lib/smime/smime-detect"; import { smimeDecrypt, SmimeKeyLockedError, normalizeCmsBytes } from "@/lib/smime/smime-decrypt"; @@ -2535,7 +2535,12 @@ export function EmailViewer({ const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => { const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type); - const opensPreview = isPreviewable && mailAttachmentAction === 'preview'; + // Blob URLs inherit our origin; script-bearing MIME types (text/html, + // image/svg+xml, etc.) would execute as the webmail origin if opened + // top-level. Force the download path for anything not on the inert allowlist. + const opensPreview = isPreviewable + && mailAttachmentAction === 'preview' + && isMimeTypeSafeForInlinePreview(attachment.type); const info: AttachmentInfo = { name: attachment.name || '', diff --git a/lib/file-preview.ts b/lib/file-preview.ts index c60a1907..048bba23 100644 --- a/lib/file-preview.ts +++ b/lib/file-preview.ts @@ -63,4 +63,24 @@ export function getFilePreviewKind(name?: string, type?: string): FilePreviewKin export function isFilePreviewable(name?: string, type?: string): boolean { return getFilePreviewKind(name, type) !== 'unsupported'; +} + +const INLINE_PREVIEW_SAFE_MIME_PREFIXES = ['image/', 'audio/', 'video/']; +const INLINE_PREVIEW_SAFE_MIME_TYPES = new Set(['application/pdf', 'text/plain']); +const INLINE_PREVIEW_UNSAFE_MIME_TYPES = new Set([ + 'image/svg+xml', + 'image/svg', +]); + +// Whether a Blob with this MIME type is safe to open as a top-level navigation +// (e.g. window.open on a blob: URL). Blob URLs inherit the creator's origin, so +// script-bearing types like text/html, application/xhtml+xml, image/svg+xml, and +// XML variants would execute in our origin. Only an explicit allowlist of inert +// types is permitted; everything else must be downloaded. +export function isMimeTypeSafeForInlinePreview(type?: string): boolean { + const mimeType = type?.split(';')[0]?.trim().toLowerCase() || ''; + if (!mimeType) return false; + if (INLINE_PREVIEW_UNSAFE_MIME_TYPES.has(mimeType)) return false; + if (INLINE_PREVIEW_SAFE_MIME_TYPES.has(mimeType)) return true; + return INLINE_PREVIEW_SAFE_MIME_PREFIXES.some((prefix) => mimeType.startsWith(prefix)); } \ No newline at end of file