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.
This commit is contained in:
Linus Rath
2026-09-26 16:24:11 +02:00
parent 02703d8b3d
commit 0ccb533405
2 changed files with 69 additions and 9 deletions
+36 -9
View File
@@ -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<string, string> {
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;
+33
View File
@@ -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('<svg><script>alert(1)</script></svg>'.padEnd(64), { headers: { 'content-type': type } }));
expect(res.headers.get('content-type')).toBe('image/png');
expect(res.headers.get('x-bulwark-favicon')).toBe('missing');
});
});