mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user