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);