mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: keep security headers on dotted app paths and neutralise cid: blobs (GHSA-xvjh-v9c6-qcvc)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user