diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cd770e915..a23fd423b 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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>({}); + // 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>(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, diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 3374d706b..034fd4a62 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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); diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index 16dcf0d7e..a8c9418cc 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -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,')).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); + }); +}); diff --git a/lib/__tests__/file-preview.test.ts b/lib/__tests__/file-preview.test.ts index 84beb67d6..cb20dd3d3 100644 --- a/lib/__tests__/file-preview.test.ts +++ b/lib/__tests__/file-preview.test.ts @@ -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); }); -}); \ No newline at end of file +}); + +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([''], { 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(''); + + const png = new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' }); + expect(toInertBlob(png)).toBe(png); + }); +}); diff --git a/lib/__tests__/proxy-security-headers.test.ts b/lib/__tests__/proxy-security-headers.test.ts new file mode 100644 index 000000000..b6e482c3d --- /dev/null +++ b/lib/__tests__/proxy-security-headers.test.ts @@ -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 //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 { + 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); + }); +}); diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index 132c312c2..6e77131af 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -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 `` the new-tab treatment uniformly across the iframe render paths * (the DOMPurify hook and the post-render DOM walk in email-viewer): http(s) diff --git a/lib/file-preview.ts b/lib/file-preview.ts index 77d242962..b4dc2c528 100644 --- a/lib/file-preview.ts +++ b/lib/file-preview.ts @@ -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); } \ No newline at end of file diff --git a/proxy.ts b/proxy.ts index def9b2ab6..ede029dd1 100644 --- a/proxy.ts +++ b/proxy.ts @@ -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 //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(); }