From 0ccb5334054d9275ca12e06bcac8e1a0c319f8e4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:24:11 +0200 Subject: [PATCH] fix: serve sender favicons only as inert raster images `/api/favicon` is unauthenticated and answers on the webmail origin, and it passed the upstream Content-Type through unchecked. Should the favicon service ever return `image/svg+xml` or `text/html`, opening that URL on its own would run script as the webmail. Only raster image types are served now, with `nosniff` and a sandboxing CSP; anything else falls back to the transparent placeholder. --- app/api/favicon/route.ts | 45 ++++++++++++++++++----- lib/__tests__/favicon-route-types.test.ts | 33 +++++++++++++++++ 2 files changed, 69 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/favicon-route-types.test.ts diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index f1fafce2f..41d292c1e 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -455,10 +455,7 @@ export async function GET(request: NextRequest) { const cached = cache.get(normalizedDomain); if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { return new NextResponse(cached.data, { - headers: { - 'Content-Type': cached.contentType, - 'Cache-Control': 'public, max-age=1209600', // 2 weeks - }, + headers: faviconHeaders(cached.contentType), }); } @@ -474,7 +471,15 @@ export async function GET(request: NextRequest) { return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS }); } - const contentType = upstream.headers.get('content-type') || 'image/x-icon'; + // Served inline on the webmail origin, so only raster image types pass: + // an image/svg+xml or text/html favicon opened on its own would run + // script as the webmail. + const contentType = rasterFaviconType(upstream.headers.get('content-type')); + if (!contentType) { + evictNegativeOldest(); + negativeCache.set(normalizedDomain, { fetchedAt: Date.now() }); + return new NextResponse(TRANSPARENT_PNG, { headers: MISSING_FAVICON_HEADERS }); + } const data = await upstream.arrayBuffer(); // Don't cache empty/tiny responses (likely no real favicon) @@ -489,10 +494,7 @@ export async function GET(request: NextRequest) { cache.set(normalizedDomain, { data, contentType, fetchedAt: Date.now() }); return new NextResponse(data, { - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=1209600', - }, + headers: faviconHeaders(contentType), }); } catch { return new NextResponse(null, { @@ -502,6 +504,31 @@ export async function GET(request: NextRequest) { } } +const RASTER_FAVICON_TYPES = new Set([ + 'image/x-icon', + 'image/vnd.microsoft.icon', + 'image/png', + 'image/gif', + 'image/jpeg', + 'image/webp', + 'image/bmp', +]); + +/** The upstream type when it is a raster image, else null. */ +function rasterFaviconType(header: string | null): string | null { + const type = (header ?? 'image/x-icon').split(';')[0].trim().toLowerCase(); + return RASTER_FAVICON_TYPES.has(type) ? type : null; +} + +function faviconHeaders(contentType: string): Record { + return { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=1209600', // 2 weeks + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'; sandbox", + }; +} + function evictNegativeOldest() { if (negativeCache.size < NEGATIVE_CACHE_MAX_SIZE) return; let oldestKey: string | null = null; diff --git a/lib/__tests__/favicon-route-types.test.ts b/lib/__tests__/favicon-route-types.test.ts new file mode 100644 index 000000000..478ea4aaa --- /dev/null +++ b/lib/__tests__/favicon-route-types.test.ts @@ -0,0 +1,33 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// /api/favicon answers on the webmail origin without authentication and used +// to pass the upstream Content-Type through: an SVG or HTML "favicon" opened +// on its own would run script as the webmail. + +afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); +}); + +async function favicon(domain: string, upstream: Response) { + vi.stubGlobal('fetch', vi.fn(async () => upstream)); + const { GET } = await import('@/app/api/favicon/route'); + return GET(new NextRequest(`https://webmail.example/api/favicon?domain=${domain}`)); +} + +describe('favicon route', () => { + it('serves a raster favicon inert: nosniff and a sandboxing CSP', async () => { + const res = await favicon('example.org', new Response(new Uint8Array(64), { headers: { 'content-type': 'image/png' } })); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + expect(res.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox"); + }); + + it.each(['image/svg+xml', 'text/html; charset=utf-8'])('does not pass %s through', async (type) => { + const res = await favicon('example.com', new Response(''.padEnd(64), { headers: { 'content-type': type } })); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(res.headers.get('x-bulwark-favicon')).toBe('missing'); + }); +});