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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <origin>/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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,8 +16,10 @@ import { isIP } from 'net';
|
|||||||
|
|
||||||
const FALLBACK_ICON_PATH = '/favicon.ico';
|
const FALLBACK_ICON_PATH = '/favicon.ico';
|
||||||
const HTML_FETCH_TIMEOUT_MS = 4000;
|
const HTML_FETCH_TIMEOUT_MS = 4000;
|
||||||
|
const ICON_FETCH_TIMEOUT_MS = 4000;
|
||||||
const MAX_REDIRECTS = 2;
|
const MAX_REDIRECTS = 2;
|
||||||
const MAX_HTML_CHARS = 200000;
|
const MAX_HTML_CHARS = 200000;
|
||||||
|
const MAX_ICON_BYTES = 1_000_000;
|
||||||
|
|
||||||
type FetchHtmlResult = {
|
type FetchHtmlResult = {
|
||||||
html: string;
|
html: string;
|
||||||
@@ -96,7 +98,7 @@ function isBlockedHostname(hostname: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,19 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
return metaImage ?? null;
|
return metaImage ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
/**
|
||||||
|
* 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;
|
let currentUrl = pageUrl;
|
||||||
|
|
||||||
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
|
||||||
@@ -223,14 +237,14 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
|||||||
if (!isPublic) return null;
|
if (!isPublic) return null;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), HTML_FETCH_TIMEOUT_MS);
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(currentUrl.toString(), {
|
const response = await fetch(currentUrl.toString(), {
|
||||||
redirect: 'manual', // SSRF: follow manually so each hop is re-validated
|
redirect: 'manual', // SSRF: follow manually so each hop is re-validated
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'text/html,application/xhtml+xml,*/*',
|
Accept: options.accept,
|
||||||
'User-Agent': 'tessera/1.0',
|
'User-Agent': 'tessera/1.0',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -246,16 +260,7 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
|||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') ?? '';
|
return { response, finalUrl: currentUrl };
|
||||||
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -266,6 +271,26 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||||
|
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()
|
@Injectable()
|
||||||
export class IconDiscoveryService {
|
export class IconDiscoveryService {
|
||||||
/**
|
/**
|
||||||
@@ -289,4 +314,42 @@ export class IconDiscoveryService {
|
|||||||
return fallback;
|
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 <img> 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) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user