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:
2026-07-07 15:32:54 +02:00
parent 6d2f82d018
commit 30f62682c6
2 changed files with 213 additions and 14 deletions
@@ -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<boolean> {
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
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<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;
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount++) {
@@ -223,14 +237,14 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
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<FetchHtmlResult | null> {
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<FetchHtmlResult | 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()
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 <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) };
}
}