fix: keep security headers on dotted app paths and neutralise cid: blobs (GHSA-xvjh-v9c6-qcvc)

This commit is contained in:
Linus Rath
2026-09-17 21:08:37 +02:00
parent 53a6670e60
commit 9bc54a4ef6
8 changed files with 296 additions and 15 deletions
+64 -8
View File
@@ -4,7 +4,7 @@ import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, use
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 { applyNewTabToAnchor, escapeHtml, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { applyNewTabToAnchor, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
@@ -106,7 +106,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
import { MailtoLink } from "@/components/ui/mailto-link";
import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
import { inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
import { debug } from "@/lib/debug";
import type { TnefAttachment } from "@/lib/tnef";
@@ -677,6 +677,8 @@ export function EmailViewer({
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
const mailAttachmentActionRef = useRef(mailAttachmentAction);
mailAttachmentActionRef.current = mailAttachmentAction;
const attachmentPosition = useSettingsStore((state) => state.attachmentPosition);
const addTrustedSender = useSettingsStore((state) => state.addTrustedSender);
const isSenderTrusted = useSettingsStore((state) => state.isSenderTrusted);
@@ -810,6 +812,11 @@ export function EmailViewer({
const [allowExternalContent, setAllowExternalContent] = useState(false);
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState<Record<string, string>>({});
// blob: URL -> the cid: part behind it, so a click on a link the body points
// at a part can be routed through the attachment preview/download gate
// instead of window.open() (GHSA-xvjh-v9c6-qcvc). A ref because the iframe
// click handler is bound once per document.
const cidBlobPartsRef = useRef<Map<string, { name: string; type?: string }>>(new Map());
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
@@ -1518,6 +1525,8 @@ export function EmailViewer({
useEffect(() => {
let cancelled = false;
const objectUrls: string[] = [];
// The Map itself is never replaced, so the cleanup can hold it directly.
const cidBlobParts = cidBlobPartsRef.current;
const decryptedCidAttachments = pluginRenderedAttachments.filter(att => att.contentId);
if (decryptedCidAttachments.length > 0) {
@@ -1528,17 +1537,23 @@ export function EmailViewer({
if (!bytes) return;
const cidValue = att.contentId!.replace(/^<|>$/g, '');
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
const blob = new Blob([buffer], { type: att.mimeType || 'application/octet-stream' });
// Sender-typed part: never let a script-bearing MIME type reach a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
const blob = new Blob([buffer], { type: inertBlobType(att.mimeType) });
const objectUrl = URL.createObjectURL(blob);
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
cidBlobParts.set(objectUrl, { name: att.filename || cidValue, type: att.mimeType });
});
setCidBlobUrls(urls);
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
objectUrls.forEach(url => {
URL.revokeObjectURL(url);
cidBlobParts.delete(url);
});
};
}
@@ -1558,10 +1573,15 @@ export function EmailViewer({
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
// The download URL echoes the sender-declared Content-Type back as
// the Blob type; re-type anything that could execute as our origin
// before it becomes a blob: URL (GHSA-xvjh-v9c6-qcvc).
const blob = await blobClient!.fetchBlob(att.blobId, att.name || 'inline', att.type, blobAccountId);
const objectUrl = URL.createObjectURL(toInertBlob(blob));
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
cidBlobParts.set(objectUrl, { name: att.name || cidValue, type: att.type });
} else {
URL.revokeObjectURL(objectUrl);
}
@@ -1578,7 +1598,10 @@ export function EmailViewer({
return () => {
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
objectUrls.forEach(url => {
URL.revokeObjectURL(url);
cidBlobParts.delete(url);
});
};
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
@@ -2359,6 +2382,27 @@ export function EmailViewer({
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
}, [emailIframeSrcDoc]);
// Open a blob: link the rendered body points at a cid: part. Mirrors the
// attachment-chip gate: inert previewable types open in a tab, everything
// else (and any blob: URL we did not mint) is downloaded under its part name.
const openCidPartLink = useCallback((href: string) => {
const part = cidBlobPartsRef.current.get(href);
const opensPreview = !!part
&& mailAttachmentActionRef.current === 'preview'
&& isFilePreviewable(part.name, part.type)
&& isMimeTypeSafeForInlinePreview(part.type);
if (opensPreview) {
window.open(href, '_blank', 'noopener,noreferrer');
return;
}
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = part?.name || 'download';
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}, []);
const handleIframeLoad = useCallback(() => {
const iframe = iframeRef.current;
if (!iframe) return;
@@ -2459,13 +2503,25 @@ export function EmailViewer({
if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
ev.preventDefault();
ev.stopPropagation();
// A link at a cid: part now carries the blob: URL we minted for it.
// Blob URLs inherit our origin, so it goes through the same
// preview/download gate as an attachment chip rather than
// window.open() (GHSA-xvjh-v9c6-qcvc).
if (href.startsWith('blob:')) {
openCidPartLink(href);
return;
}
// Only web links and external protocol handlers may be opened;
// data:, unresolved cid: and relative paths would land inside our
// own origin.
if (!isOpenableLinkHref(href)) return;
const ctx = {
href,
target: targetEl.getAttribute('target') ?? undefined,
emailId: email?.id,
};
const ok = await uiHooks.onBeforeExternalLink.intercept(ctx);
if (!ok) return;
if (!ok || !isOpenableLinkHref(ctx.href)) return;
window.open(ctx.href, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
@@ -2569,7 +2625,7 @@ export function EmailViewer({
} catch {
// Cross-origin restrictions - iframe will still display content
}
}, [isDark, emailHasNativeDarkMode, email?.id, t]);
}, [isDark, emailHasNativeDarkMode, email?.id, t, openCidPartLink]);
// Wire up the iframe as soon as its sandboxed document has parsed, rather than
// waiting for the iframe 'load' event. 'load' also waits on every subresource,
@@ -36,7 +36,7 @@ import { useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore } from "@/stores/contact-store";
import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable } from "@/lib/file-preview";
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -299,7 +299,9 @@ function EmailCard({
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
// Re-type sender-declared script-bearing parts before they become a
// blob: URL in our origin (GHSA-xvjh-v9c6-qcvc).
const objectUrl = URL.createObjectURL(toInertBlob(await client!.fetchBlob(att.blobId, att.name || 'inline', att.type)));
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
+30
View File
@@ -12,6 +12,7 @@ import {
EMAIL_IFRAME_SANITIZE_CONFIG,
isExternalResourceUrl,
isHttpLinkHref,
isOpenableLinkHref,
applyNewTabToAnchor,
sanitizeI18nHtml,
sanitizePluginBodyHtml,
@@ -967,3 +968,32 @@ describe('email-sanitization', () => {
});
});
});
describe('isOpenableLinkHref (message-body click -> window.open gate, GHSA-xvjh-v9c6-qcvc)', () => {
it('lets web links and external protocol handlers through', () => {
expect(isOpenableLinkHref('https://example.com/x')).toBe(true);
expect(isOpenableLinkHref('HTTP://example.com')).toBe(true);
expect(isOpenableLinkHref('//example.com/x')).toBe(true);
expect(isOpenableLinkHref('tel:+4912345')).toBe(true);
expect(isOpenableLinkHref('sms:+4912345')).toBe(true);
expect(isOpenableLinkHref('ftp://example.com/f')).toBe(true);
});
it('refuses anything that would open inside the webmail origin', () => {
expect(isOpenableLinkHref('blob:https://mail.example/0f3d-ab12')).toBe(false);
expect(isOpenableLinkHref('data:text/html,<script>alert(1)</script>')).toBe(false);
expect(isOpenableLinkHref('data:image/png;base64,AAAA')).toBe(false);
expect(isOpenableLinkHref('statement.pdf')).toBe(false);
expect(isOpenableLinkHref('/en/mail/folder/inbox/statement.pdf')).toBe(false);
expect(isOpenableLinkHref('./x')).toBe(false);
expect(isOpenableLinkHref('cid:unresolved')).toBe(false);
expect(isOpenableLinkHref('javascript:alert(1)')).toBe(false);
expect(isOpenableLinkHref('')).toBe(false);
expect(isOpenableLinkHref(null)).toBe(false);
});
it('ignores leading control characters and whitespace when reading the scheme', () => {
expect(isOpenableLinkHref('blob:https://mail.example/x')).toBe(false);
expect(isOpenableLinkHref(' tel:123')).toBe(true);
});
});
+30 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { getFilePreviewKind, isFilePreviewable } from '../file-preview';
import { getFilePreviewKind, inertBlobType, isFilePreviewable, toInertBlob } from '../file-preview';
describe('file preview detection', () => {
it('detects browser-renderable image attachments', () => {
@@ -29,4 +29,32 @@ describe('file preview detection', () => {
expect(getFilePreviewKind('archive.zip', 'application/zip')).toBe('unsupported');
expect(isFilePreviewable('archive.zip', 'application/zip')).toBe(false);
});
});
});
describe('inert blob typing for cid: parts (GHSA-xvjh-v9c6-qcvc)', () => {
it('keeps inert media types so inline images and media still render', () => {
expect(inertBlobType('image/png')).toBe('image/png');
expect(inertBlobType('image/jpeg; name=a.jpg')).toBe('image/jpeg; name=a.jpg');
expect(inertBlobType('video/mp4')).toBe('video/mp4');
expect(inertBlobType('application/pdf')).toBe('application/pdf');
});
it('neutralises every sender-declared type that could execute as our origin', () => {
for (const type of ['text/html', 'TEXT/HTML; charset=utf-8', 'application/xhtml+xml', 'image/svg+xml', 'application/xml', 'text/xml', 'application/javascript']) {
expect(inertBlobType(type)).toBe('application/octet-stream');
}
expect(inertBlobType(undefined)).toBe('application/octet-stream');
expect(inertBlobType('')).toBe('application/octet-stream');
});
it('re-types a fetched Blob without touching its bytes', async () => {
const html = new Blob(['<script>alert(1)</script>'], { type: 'text/html' });
const inert = toInertBlob(html);
expect(inert.type).toBe('application/octet-stream');
expect(inert.size).toBe(html.size);
expect(await inert.text()).toBe('<script>alert(1)</script>');
const png = new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' });
expect(toInertBlob(png)).toBe(png);
});
});
@@ -0,0 +1,117 @@
/**
* @vitest-environment node
*
* The proxy used to treat every path whose last segment contains a dot as a
* static asset and return before setting the CSP, X-Content-Type-Options and
* the other security headers. This app routes [[...segments]] catch-alls
* under /<locale>/mail, /calendar, /contacts and /files, so
* /en/mail/folder/inbox/statement.pdf is a real, signed-in page - and it was
* served without a CSP, which let a sender-typed blob: URL opened from that
* document execute in the webmail origin (GHSA-xvjh-v9c6-qcvc). Only what
* Next genuinely serves from public/ may skip the headers now.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn((_key: string, fallback: unknown) => fallback),
getPolicy: vi.fn(() => ({ features: { pluginsEnabled: false } })),
},
}));
vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') }));
vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) }));
vi.mock('next-intl/middleware', async () => {
const { NextResponse } = await import('next/server');
return { default: () => () => NextResponse.next() };
});
import { proxy, isStaticAssetPath } from '@/proxy';
function load(path: string): Promise<Response> {
return proxy(new NextRequest('http://localhost:3000' + path, {
headers: { 'sec-fetch-dest': 'document', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'none' },
}));
}
const SECURITY_HEADERS = ['content-security-policy', 'x-content-type-options', 'x-frame-options', 'referrer-policy', 'permissions-policy'];
// Signed-in pages whose last segment happens to contain a dot.
const DOTTED_APP_PATHS = [
'/en/mail/folder/inbox/statement.pdf',
'/de/mail/folder/inbox/statement.pdf',
'/en/calendar/2026/09/17.ics',
'/en/contacts/jan.kahmen',
'/en/files/reports/q3.xlsx',
'/en/settings/account.json',
'/mail/folder/inbox/statement.pdf',
'/en/.well-known/security.txt',
];
// What public/ (and Next's own route handlers at the root) actually serve.
const STATIC_ASSETS = [
'/sw.js',
'/favicon.ico',
'/manifest.webmanifest',
'/icon-192x192.png',
'/branding/logo.png',
'/notification/cheerful-527.mp3',
];
beforeEach(() => {
vi.clearAllMocks();
});
describe('security headers on dotted app paths (GHSA-xvjh-v9c6-qcvc)', () => {
it.each(DOTTED_APP_PATHS)('serves %s with the full security header set', async (path) => {
const response = await load(path);
expect(response.status).toBe(200);
for (const header of SECURITY_HEADERS) {
expect(response.headers.get(header), header).not.toBeNull();
}
const csp = response.headers.get('content-security-policy') ?? '';
expect(csp).toMatch(/script-src 'self' 'nonce-[^']+'/);
expect(csp).not.toContain("'unsafe-eval'");
expect(response.headers.get('x-content-type-options')).toBe('nosniff');
// The nonce reaches the layout the same way it does on /en/mail/folder/inbox.
expect(response.headers.get('x-middleware-request-x-nonce')).toBeTruthy();
expect(response.headers.get('x-middleware-request-x-pathname')).toBe(path);
});
it('gives a dotted app path the same policy as its canonical sibling', async () => {
const dotted = await load('/en/mail/folder/inbox/statement.pdf');
const canonical = await load('/en/mail/folder/inbox');
const strip = (csp: string | null) => (csp ?? '').replace(/'nonce-[^']+'/g, "'nonce-X'");
expect(strip(dotted.headers.get('content-security-policy'))).toBe(strip(canonical.headers.get('content-security-policy')));
for (const header of SECURITY_HEADERS.filter((h) => h !== 'content-security-policy')) {
expect(dotted.headers.get(header)).toBe(canonical.headers.get(header));
}
});
it.each(STATIC_ASSETS)('still passes %s straight through untouched', async (path) => {
const response = await load(path);
expect(response.status).toBe(200);
expect(response.headers.get('content-security-policy')).toBeNull();
expect(response.headers.get('x-middleware-request-x-nonce')).toBeNull();
});
it.each(['/api/auth/session', '/_next/static/chunks/main.js', '/_next/image'])('still skips %s', async (path) => {
const response = await load(path);
expect(response.headers.get('content-security-policy')).toBeNull();
});
});
describe('isStaticAssetPath', () => {
it('matches only root-level files and the public/ asset folders', () => {
for (const path of STATIC_ASSETS) expect(isStaticAssetPath(path), path).toBe(true);
for (const path of DOTTED_APP_PATHS) expect(isStaticAssetPath(path), path).toBe(false);
expect(isStaticAssetPath('/en')).toBe(false);
expect(isStaticAssetPath('/en/mail')).toBe(false);
expect(isStaticAssetPath('/brandingx/logo.png')).toBe(false);
});
});
+15
View File
@@ -419,6 +419,21 @@ export function isHttpLinkHref(href: string | null | undefined): boolean {
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
}
/**
* Whether a clicked message-body link may be handed to window.open(). Web links
* and links to external protocol handlers (tel:, sms:, ...) are fine. Anything
* that resolves inside our own origin is not: blob: URLs (a cid: part re-typed
* by the sender, GHSA-xvjh-v9c6-qcvc), data:, and bare relative paths, which
* would open a webmail route of the sender's choosing in a new tab.
*/
export function isOpenableLinkHref(href: string | null | undefined): boolean {
if (!href) return false;
if (isHttpLinkHref(href)) return true;
// eslint-disable-next-line no-control-regex
const normalized = href.replace(/[\u0000-\u0020]+/g, '');
return /^(?:ftps?|tel|sms|callto|xmpp):/i.test(normalized);
}
/**
* Give one `<a>` the new-tab treatment uniformly across the iframe render paths
* (the DOMPurify hook and the post-render DOM walk in email-viewer): http(s)
+19
View File
@@ -89,4 +89,23 @@ export function isMimeTypeSafeForInlinePreview(type?: string): boolean {
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));
}
const INERT_BLOB_TYPE = 'application/octet-stream';
/**
* MIME type for a Blob whose blob: URL ends up inside untrusted markup (the
* cid: parts of a rendered message body). Blob URLs inherit the creating
* document's origin, so a sender-declared text/html or image/svg+xml part
* navigated to as a top-level document would run script as the webmail origin
* (GHSA-xvjh-v9c6-qcvc). Inert types keep their MIME so img/video/audio keep
* rendering them; anything else becomes octet-stream, which no browser executes.
*/
export function inertBlobType(type?: string): string {
return type && isMimeTypeSafeForInlinePreview(type) ? type : INERT_BLOB_TYPE;
}
/** Re-type a fetched Blob per inertBlobType() without copying its bytes. */
export function toInertBlob(blob: Blob): Blob {
return isMimeTypeSafeForInlinePreview(blob.type) ? blob : blob.slice(0, blob.size, INERT_BLOB_TYPE);
}
+17 -3
View File
@@ -51,7 +51,21 @@ function withMatchedChineseAcceptLanguage(request: NextRequest): NextRequest {
// (e.g. `export const config = { matcher }`) is no longer allowed in the
// proxy file. We replicate the previous matcher inline by short-circuiting
// requests for API routes, Next internals and static assets.
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)|\.[^/]+$/;
const PROXY_SKIP_PATTERN = /^\/(?:api|_next)(?:\/|$)/;
// What Next serves from public/: a single dotted segment at the root
// (/sw.js, /favicon.ico, /manifest.webmanifest) and the branding/ and
// notification/ asset folders. The upstream matcher treated EVERY path whose
// last segment has a dot as a static file, but the [[...segments]] catch-alls
// under /<locale>/mail, /calendar, /contacts and /files make
// /en/mail/folder/inbox/statement.pdf a real signed-in page, and it rendered
// without a CSP or any other security header (GHSA-xvjh-v9c6-qcvc). Only what
// is genuinely static may skip locale routing and the headers.
const STATIC_ASSET_PATTERN = /^\/[^/]+\.[^/]+$|^\/(?:branding|notification)\//;
export function isStaticAssetPath(pathname: string): boolean {
return STATIC_ASSET_PATTERN.test(pathname);
}
/**
* Hand the route every request header the client sent, plus `extra`.
@@ -128,7 +142,7 @@ export async function proxy(request: NextRequest) {
// Public read endpoint - serves wizard-uploaded branding assets so
// image previews work during the wizard. No auth on the GET route.
pathname.startsWith("/api/admin/branding/") ||
/\.[^/]+$/.test(pathname);
isStaticAssetPath(pathname);
if (!allowed) {
if (pathname.startsWith("/api/")) {
@@ -163,7 +177,7 @@ export async function proxy(request: NextRequest) {
return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 });
}
if (PROXY_SKIP_PATTERN.test(pathname)) {
if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) {
return NextResponse.next();
}