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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user