From 30f62682c6594653b686857e279adcc5ddf933be Mon Sep 17 00:00:00 2001 From: Schalli Date: Tue, 7 Jul 2026 15:32:54 +0200 Subject: [PATCH] feat(favorites): shared SSRF-guarded icon byte-fetch (icon-discovery) Extracts the manual-redirect/per-hop-revalidation/timeout loop from fetchHtml into a shared fetchWithRedirectGuard, exports isPublicHttpUrl, and adds fetchIconBytes() -- an image-content-type-gated, 1MB-capped byte fetch reusing the same SSRF guard as the existing HTML discovery path. discoverFavoriteIconUrl behavior is unchanged. Prepares the fix for favicon hotlinks breaking on sites that send Cross-Origin-Resource-Policy: same-origin (e.g. claude.ai) -- Tessera will proxy the bytes through its own origin instead. Co-Authored-By: Claude Sonnet 5 --- .../favorites/icon-discovery.service.spec.ts | 136 ++++++++++++++++++ .../src/favorites/icon-discovery.service.ts | 91 ++++++++++-- 2 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 apps/api/src/favorites/icon-discovery.service.spec.ts diff --git a/apps/api/src/favorites/icon-discovery.service.spec.ts b/apps/api/src/favorites/icon-discovery.service.spec.ts new file mode 100644 index 0000000..c1ea368 --- /dev/null +++ b/apps/api/src/favorites/icon-discovery.service.spec.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { IconDiscoveryService, isPublicHttpUrl } from './icon-discovery.service'; + +function mockResponse(options: { + contentType?: string; + body?: ArrayBuffer; +}): Response { + const body = options.body ?? new ArrayBuffer(10); + return { + ok: true, + status: 200, + headers: { + get: (name: string) => + name.toLowerCase() === 'content-type' + ? (options.contentType ?? 'image/png') + : null, + }, + arrayBuffer: async () => body, + } as unknown as Response; +} + +describe('isPublicHttpUrl', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('rejects private IPv4 addresses', async () => { + await expect(isPublicHttpUrl(new URL('http://127.0.0.1/x'))).resolves.toBe(false); + await expect(isPublicHttpUrl(new URL('http://10.0.0.5/x'))).resolves.toBe(false); + await expect(isPublicHttpUrl(new URL('http://192.168.1.1/x'))).resolves.toBe(false); + await expect(isPublicHttpUrl(new URL('http://169.254.1.1/x'))).resolves.toBe(false); + }); + + it('rejects blocked hostnames', async () => { + await expect(isPublicHttpUrl(new URL('http://localhost/x'))).resolves.toBe(false); + await expect(isPublicHttpUrl(new URL('http://foo.local/x'))).resolves.toBe(false); + await expect(isPublicHttpUrl(new URL('http://0.0.0.0/x'))).resolves.toBe(false); + }); + + it('rejects non-http(s) protocols', async () => { + await expect(isPublicHttpUrl(new URL('ftp://example.com/x'))).resolves.toBe(false); + }); + + it('accepts a public IPv4 address without DNS lookup', async () => { + await expect(isPublicHttpUrl(new URL('http://8.8.8.8/x'))).resolves.toBe(true); + }); +}); + +describe('IconDiscoveryService.fetchIconBytes', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('returns contentType and body for a valid image response', async () => { + const body = new ArrayBuffer(100); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png', body })), + ); + + const service = new IconDiscoveryService(); + const result = await service.fetchIconBytes('http://8.8.8.8/favicon.ico'); + + expect(result.contentType).toBe('image/png'); + expect(result.body).toBeInstanceOf(Buffer); + expect(result.body.length).toBe(100); + }); + + it('rejects when Content-Type is not an image', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })), + ); + + const service = new IconDiscoveryService(); + + await expect( + service.fetchIconBytes('http://8.8.8.8/favicon.ico'), + ).rejects.toThrow(/not an image/); + }); + + it('rejects when the SSRF guard blocks the target', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const service = new IconDiscoveryService(); + + await expect( + service.fetchIconBytes('http://127.0.0.1/favicon.ico'), + ).rejects.toThrow(/blocked or failed/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('rejects when the body exceeds the size cap', async () => { + const oversized = new ArrayBuffer(1_000_001); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png', body: oversized })), + ); + + const service = new IconDiscoveryService(); + + await expect( + service.fetchIconBytes('http://8.8.8.8/favicon.ico'), + ).rejects.toThrow(/size limit/); + }); +}); + +describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('falls back to /favicon.ico when the page cannot be fetched', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error'))); + + const service = new IconDiscoveryService(); + const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); + + expect(result).toBe('http://8.8.8.8/favicon.ico'); + }); + + it('still returns a URL string', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })), + ); + + const service = new IconDiscoveryService(); + const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page'); + + expect(typeof result).toBe('string'); + }); +}); diff --git a/apps/api/src/favorites/icon-discovery.service.ts b/apps/api/src/favorites/icon-discovery.service.ts index 6bc565a..1d741dd 100644 --- a/apps/api/src/favorites/icon-discovery.service.ts +++ b/apps/api/src/favorites/icon-discovery.service.ts @@ -16,8 +16,10 @@ import { isIP } from 'net'; const FALLBACK_ICON_PATH = '/favicon.ico'; const HTML_FETCH_TIMEOUT_MS = 4000; +const ICON_FETCH_TIMEOUT_MS = 4000; const MAX_REDIRECTS = 2; const MAX_HTML_CHARS = 200000; +const MAX_ICON_BYTES = 1_000_000; type FetchHtmlResult = { html: string; @@ -96,7 +98,7 @@ function isBlockedHostname(hostname: string): boolean { ); } -async function isPublicHttpUrl(url: URL): Promise { +export async function isPublicHttpUrl(url: URL): Promise { if (url.protocol !== 'http:' && url.protocol !== 'https:') { return false; } @@ -214,7 +216,19 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null { return metaImage ?? null; } -async function fetchHtml(pageUrl: URL): Promise { +/** + * Shared SSRF-guarded fetch used by every outbound request this service makes. + * Follows redirects manually (up to MAX_REDIRECTS) so each hop is re-validated + * against isPublicHttpUrl before being requested — a redirect target must not + * be able to bypass the private-IP/blocked-hostname guard. + * + * Returns the final non-redirect Response, or null if the guard blocks any + * hop, the request errors, or the redirect budget is exhausted. + */ +async function fetchWithRedirectGuard( + pageUrl: URL, + options: { accept: string; timeoutMs: number }, +): Promise<{ response: Response; finalUrl: URL } | null> { let currentUrl = pageUrl; for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) { @@ -223,14 +237,14 @@ async function fetchHtml(pageUrl: URL): Promise { if (!isPublic) return null; const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), HTML_FETCH_TIMEOUT_MS); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs); try { const response = await fetch(currentUrl.toString(), { redirect: 'manual', // SSRF: follow manually so each hop is re-validated signal: controller.signal, headers: { - Accept: 'text/html,application/xhtml+xml,*/*', + Accept: options.accept, 'User-Agent': 'tessera/1.0', }, }); @@ -246,16 +260,7 @@ async function fetchHtml(pageUrl: URL): Promise { if (!response.ok) return null; - const contentType = response.headers.get('content-type') ?? ''; - - if (!contentType.toLowerCase().includes('text/html')) return null; - - const html = await response.text(); - - return { - html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap - finalUrl: currentUrl.toString(), - }; + return { response, finalUrl: currentUrl }; } catch { return null; } finally { @@ -266,6 +271,26 @@ async function fetchHtml(pageUrl: URL): Promise { return null; } +async function fetchHtml(pageUrl: URL): Promise { + const result = await fetchWithRedirectGuard(pageUrl, { + accept: 'text/html,application/xhtml+xml,*/*', + timeoutMs: HTML_FETCH_TIMEOUT_MS, + }); + + if (!result) return null; + + const contentType = result.response.headers.get('content-type') ?? ''; + + if (!contentType.toLowerCase().includes('text/html')) return null; + + const html = await result.response.text(); + + return { + html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap + finalUrl: result.finalUrl.toString(), + }; +} + @Injectable() export class IconDiscoveryService { /** @@ -289,4 +314,42 @@ export class IconDiscoveryService { return fallback; } } + + /** + * Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming + * back to the browser from Tessera's own origin (avoids Cross-Origin- + * Resource-Policy blocks on hotlinked cross-origin loads). + * + * Throws on any failure — blocked target, timeout, non-image content-type, + * or an oversized body. Callers must not return a placeholder image; let + * the caller map the failure to an HTTP error status instead. + */ + async fetchIconBytes( + iconUrl: string, + ): Promise<{ contentType: string; body: Buffer }> { + const url = new URL(iconUrl); + + const result = await fetchWithRedirectGuard(url, { + accept: 'image/*', + timeoutMs: ICON_FETCH_TIMEOUT_MS, + }); + + if (!result) { + throw new Error('Icon fetch blocked or failed'); + } + + const contentType = result.response.headers.get('content-type') ?? ''; + + if (!contentType.toLowerCase().startsWith('image/')) { + throw new Error(`Icon response is not an image (${contentType})`); + } + + const arrayBuffer = await result.response.arrayBuffer(); + + if (arrayBuffer.byteLength > MAX_ICON_BYTES) { + throw new Error('Icon response exceeds size limit'); + } + + return { contentType, body: Buffer.from(arrayBuffer) }; + } }