diff --git a/components/files/__tests__/file-preview-modal-image.test.tsx b/components/files/__tests__/file-preview-modal-image.test.tsx
new file mode 100644
index 000000000..95b67a35b
--- /dev/null
+++ b/components/files/__tests__/file-preview-modal-image.test.tsx
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+import { FilePreviewModal } from '../file-preview-modal';
+
+// The image preview used a plain object URL of the attachment, and those share
+// the webmail origin. "Open image in new tab" on a sender's SVG (or on HTML
+// declared on an .svg name) then loaded it as a same-origin document.
+
+const SVG = '';
+
+let created: Blob[];
+
+beforeEach(() => {
+ created = [];
+ vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => {
+ created.push(blob as Blob);
+ return 'blob:test';
+ });
+ vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+async function previewSrc(name: string, contentType: string, bytes: BlobPart): Promise {
+ render(
+ {}}
+ onDownload={() => {}}
+ getFileContent={async () => ({ blob: new Blob([bytes], { type: contentType }), contentType })}
+ />,
+ );
+ return (await screen.findByAltText(name)).getAttribute('src') ?? '';
+}
+
+describe('FilePreviewModal image URLs', () => {
+ it('shows an SVG from a data: URL, never a same-origin object URL', async () => {
+ const src = await previewSrc('drawing.svg', 'image/svg+xml', SVG);
+ expect(src.startsWith('data:image/svg+xml;base64,')).toBe(true);
+ expect(created).toEqual([]);
+ expect(screen.queryByLabelText('open_in_new_tab')).toBeNull();
+ });
+
+ it('retypes HTML declared on an image name to something no browser runs', async () => {
+ expect(await previewSrc('drawing.svg', 'text/html', '')).toBe('blob:test');
+ expect(created.map((b) => b.type)).toEqual(['application/octet-stream']);
+ });
+
+ it('keeps raster images as object URLs of their own type', async () => {
+ expect(await previewSrc('photo.png', 'image/png', new Uint8Array([137, 80, 78, 71]))).toBe('blob:test');
+ expect(created.map((b) => b.type)).toEqual(['image/png']);
+ expect(screen.getByLabelText('open_in_new_tab')).toBeTruthy();
+ });
+});
diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx
index 556981550..9d89c45f4 100644
--- a/components/files/file-preview-modal.tsx
+++ b/components/files/file-preview-modal.tsx
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2, ExternalLink } from "@/components/icons";
import { Button } from "@/components/ui/button";
-import { getFilePreviewKind, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
+import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previewBlobType } from "@/lib/file-preview";
import dynamic from "next/dynamic";
import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview";
@@ -215,12 +215,20 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
const typedBlob = blob.type !== effectiveType
? new Blob([blob], { type: effectiveType })
: blob;
- revokeUrl = URL.createObjectURL(typedBlob);
- if (!cancelled) {
- setObjectUrl(revokeUrl);
- setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType));
- if (previewType === "pdf") setPdfBlob(typedBlob);
+ // "Open image in new tab" loads the
URL as a document of its
+ // own, so an image gets one that stays inert there: a sender's SVG
+ // must not run script as the webmail origin (GHSA-xvjh-v9c6-qcvc).
+ const url = previewType === "image"
+ ? await imageBlobUrl(typedBlob)
+ : URL.createObjectURL(typedBlob);
+ if (cancelled) {
+ URL.revokeObjectURL(url);
+ return;
}
+ revokeUrl = url;
+ setObjectUrl(url);
+ setCanOpenInNewTab(isMimeTypeSafeForInlinePreview(effectiveType));
+ if (previewType === "pdf") setPdfBlob(typedBlob);
}
} catch {
if (!cancelled) setError(true);