fix: keep a previewed SVG attachment from running script in the webmail origin

This commit is contained in:
Linus Rath
2026-09-30 15:12:19 +02:00
parent 1d90fe17f4
commit 13b2ea3d19
2 changed files with 75 additions and 6 deletions
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(document.domain)</script></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<string> {
render(
<FilePreviewModal
name={name}
onClose={() => {}}
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', '<script>alert(1)</script>')).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();
});
});
+14 -6
View File
@@ -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 <img> 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);