Skip to content
Source
Sign inSign up

FaviconCapture.test.ts

472 lines15.2 KB

1import { describe, expect, it, vi } from "vite-plus/test";
2​
3import {
4 MAX_FAVICON_CANDIDATES,
5 MAX_FAVICON_RESPONSE_BYTES,
6 captureFavicon,
7 selectFaviconCandidates,
8 sourceDimensions,
9} from "./FaviconCapture.ts";
10​
11const PNG = "data:image/png;base64,cG5n";
12const SOURCE_PNG = Buffer.alloc(24);
13Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(SOURCE_PNG);
14SOURCE_PNG.writeUInt32BE(1, 16);
15SOURCE_PNG.writeUInt32BE(1, 20);
16const SOURCE_PNG_URL = `data:image/png;base64,${SOURCE_PNG.toString("base64")}`;
17​
18function sourceGif(
19 width: number,
20 height: number,
21 frameWidth = width,
22 frameHeight = height,
23 additionalFrames: ReadonlyArray<{
24 readonly left?: number;
25 readonly top?: number;
26 readonly width: number;
27 readonly height: number;
28 }> = [],
29): Buffer {
30 const frames = [{ width: frameWidth, height: frameHeight }, ...additionalFrames];
31 const buffer = Buffer.alloc(13 + frames.length * 12 + 1);
32 buffer.write("GIF89a", 0, "ascii");
33 buffer.writeUInt16LE(width, 6);
34 buffer.writeUInt16LE(height, 8);
35 let offset = 13;
36 for (const frame of frames) {
37 buffer[offset] = 0x2c;
38 buffer.writeUInt16LE(frame.left ?? 0, offset + 1);
39 buffer.writeUInt16LE(frame.top ?? 0, offset + 3);
40 buffer.writeUInt16LE(frame.width, offset + 5);
41 buffer.writeUInt16LE(frame.height, offset + 7);
42 offset += 10;
43 buffer[offset] = 2;
44 buffer[offset + 1] = 0;
45 offset += 2;
46 }
47 buffer[offset] = 0x3b;
48 return buffer;
50​
51it.each([
52 ["logical pixels", sourceGif(2048, 1024), { width: 2048, height: 1024 }],
53 [
54 "cumulative frame pixels",
55 sourceGif(1024, 1024, 1024, 1024, [{ width: 1024, height: 1024 }]),
56 { width: 1024, height: 1024 },
57 ],
58])("uses the caller's GIF limit for %s while keeping the favicon limit", (_, gif, dimensions) => {
59 expect(sourceDimensions(gif)).toBeNull();
60 expect(sourceDimensions(gif, 8_388_608)).toEqual(dimensions);
61});
62​
63it("rejects GIF frames exceeding the caller's cumulative pixel limit", () => {
64 const gif = sourceGif(2048, 2048, 2048, 2048, [
65 { width: 2048, height: 2048 },
66 { width: 2048, height: 2048 },
67 ]);
68 expect(sourceDimensions(gif, 8_388_608)).toBeNull();
69});
70​
71function sourceJpeg(
72 width: number,
73 height: number,
74 orientations: number | ReadonlyArray<number> = [],
75): Buffer {
76 const frame = Buffer.from([
77 0xff,
78 0xd8,
79 0xff,
80 0xc0,
81 0x00,
82 0x07,
83 0x08,
84 height >>> 8,
85 height & 0xff,
86 width >>> 8,
87 width & 0xff,
88 ]);
89 const app1Segments = (typeof orientations === "number" ? [orientations] : orientations).map(
90 (orientation) => sourceJpegExifSegment([orientation]),
91 );
92 return Buffer.concat([frame.subarray(0, 2), ...app1Segments, frame.subarray(2)]);
94​
95function sourceJpegApp1Segment(payload: Buffer): Buffer {
96 const app1 = Buffer.alloc(4 + payload.byteLength);
97 app1[0] = 0xff;
98 app1[1] = 0xe1;
99 app1.writeUInt16BE(payload.byteLength + 2, 2);
100 payload.copy(app1, 4);
101 return app1;
103​
104function sourceJpegExifSegment(
105 orientations: ReadonlyArray<number>,
106 options?: {
107 readonly byteOrder?: "II" | "MM";
108 readonly magic?: number;
109 readonly padding?: number;
110 },
111): Buffer {
112 const exif = Buffer.alloc(20 + orientations.length * 12);
113 exif.write("Exif\0\0", 0, "binary");
114 exif[5] = options?.padding ?? 0;
115 const littleEndian = options?.byteOrder !== "MM";
116 exif.write(littleEndian ? "II" : "MM", 6, "ascii");
117 const writeUInt16 = (value: number, offset: number) =>
118 littleEndian ? exif.writeUInt16LE(value, offset) : exif.writeUInt16BE(value, offset);
119 const writeUInt32 = (value: number, offset: number) =>
120 littleEndian ? exif.writeUInt32LE(value, offset) : exif.writeUInt32BE(value, offset);
121 writeUInt16(options?.magic ?? 42, 8);
122 writeUInt32(8, 10);
123 writeUInt16(orientations.length, 14);
124 orientations.forEach((orientation, index) => {
125 const entryOffset = 16 + index * 12;
126 writeUInt16(0x0112, entryOffset);
127 writeUInt16(3, entryOffset + 2);
128 writeUInt32(1, entryOffset + 4);
129 writeUInt16(orientation, entryOffset + 8);
130 });
131 return sourceJpegApp1Segment(exif);
133​
134function sourceJpegWithApp1Segments(
135 width: number,
136 height: number,
137 segments: ReadonlyArray<Buffer>,
138): Buffer {
139 const frame = sourceJpeg(width, height);
140 return Buffer.concat([frame.subarray(0, 2), ...segments, frame.subarray(2)]);
142​
143function sourceJpegExifWithOverlappingSubIfds(pointerCount: number, subIfdEntries: number): Buffer {
144 const rootIfdOffset = 14;
145 const rootEntries = pointerCount + 1;
146 const subIfdOffset = rootIfdOffset + 2 + rootEntries * 12 + 4;
147 const exif = Buffer.alloc(subIfdOffset + pointerCount * 2 + 2 + subIfdEntries * 12);
148 exif.write("Exif\0\0", 0, "binary");
149 exif.write("II", 6, "ascii");
150 exif.writeUInt32LE(8, 10);
151 exif.writeUInt16LE(rootEntries, rootIfdOffset);
152 for (let index = 0; index < pointerCount; index += 1) {
153 const entryOffset = rootIfdOffset + 2 + index * 12;
154 exif.writeUInt16LE(0x8769, entryOffset);
155 exif.writeUInt16LE(4, entryOffset + 2);
156 exif.writeUInt32LE(1, entryOffset + 4);
157 exif.writeUInt32LE(subIfdOffset + index * 2 - 6, entryOffset + 8);
158 exif.writeUInt16LE(subIfdEntries, subIfdOffset + index * 2);
159 }
160 const orientationOffset = rootIfdOffset + 2 + pointerCount * 12;
161 exif.writeUInt16LE(0x0112, orientationOffset);
162 exif.writeUInt16LE(3, orientationOffset + 2);
163 exif.writeUInt32LE(1, orientationOffset + 4);
164 exif.writeUInt16LE(6, orientationOffset + 8);
165 return sourceJpegApp1Segment(exif);
167​
168function sourceWebp(width: number, height: number): Buffer {
169 const buffer = Buffer.alloc(30);
170 buffer.write("RIFF", 0, "ascii");
171 buffer.write("WEBP", 8, "ascii");
172 buffer.write("VP8X", 12, "ascii");
173 buffer.writeUIntLE(width - 1, 24, 3);
174 buffer.writeUIntLE(height - 1, 27, 3);
175 return buffer;
177​
178function sourceIco(embedded: Buffer): Buffer {
179 const buffer = Buffer.alloc(22 + embedded.byteLength);
180 buffer.writeUInt16LE(1, 2);
181 buffer.writeUInt16LE(1, 4);
182 buffer.writeUInt32LE(embedded.byteLength, 14);
183 buffer.writeUInt32LE(22, 18);
184 embedded.copy(buffer, 22);
185 return buffer;
187​
188function makeUnsafePng(): Buffer {
189 const buffer = Buffer.from(SOURCE_PNG);
190 buffer.writeUInt32BE(4096, 16);
191 buffer.writeUInt32BE(4096, 20);
192 return buffer;
194​
195function makeUnsafeDib(): Buffer {
196 const buffer = Buffer.alloc(40);
197 buffer.writeUInt32LE(40, 0);
198 buffer.writeInt32LE(4096, 4);
199 buffer.writeInt32LE(4096, 8);
200 return buffer;
202​
203function makeWebContents(options?: {
204 readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
205 readonly rasterize?: (code: string) => Promise<unknown>;
206}) {
207 const fetch = vi.fn(
208 options?.fetch ??
209 (async () =>
210 new Response(new Uint8Array(SOURCE_PNG), {
211 headers: { "content-type": "image/png" },
212 })),
213 );
214 const executeJavaScriptInIsolatedWorld = vi.fn(
215 async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
216 options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : PNG,
217 );
218 return {
219 webContents: {
220 session: { fetch },
221 executeJavaScriptInIsolatedWorld,
222 } as never,
223 executeJavaScriptInIsolatedWorld,
224 fetch,
225 };
227​
228describe("selectFaviconCandidates", () => {
229 it("filters and deduplicates before applying the candidate cap", () => {
230 const valid = Array.from(
231 { length: MAX_FAVICON_CANDIDATES + 2 },
232 (_, index) => `https://example.com/favicon-${index}.png`,
233 );
234 expect(
235 selectFaviconCandidates([
236 ...Array.from({ length: 64 }, () => "javascript:alert(1)"),
237 valid[0]!,
238 valid[0]!,
239 ...valid.slice(1),
240 ]),
241 ).toEqual(valid.slice(0, MAX_FAVICON_CANDIDATES));
242 });
243​
244 it("bounds raw candidate scanning independently of the usable-candidate cap", () => {
245 const oversizedInvalid = `javascript:${"x".repeat(2_048)}`;
246 expect(
247 selectFaviconCandidates([
248 ...Array.from({ length: 128 }, () => oversizedInvalid),
249 "https://example.com/too-late.png",
250 ]),
251 ).toEqual([]);
252 });
253});
254​
255describe("captureFavicon", () => {
256 it.each([
257 {
258 label: "same-origin",
259 pageUrl: "https://example.com/page",
260 faviconUrl: "https://example.com/favicon.png",
261 credentials: "include",
262 },
263 {
264 label: "cross-origin",
265 pageUrl: "https://example.com/page",
266 faviconUrl: "https://cdn.example.net/favicon.png",
267 credentials: "omit",
268 },
269 ])("uses the explicit credential policy for $label requests", async (testCase) => {
270 const { webContents, fetch } = makeWebContents();
271 const result = await captureFavicon({
272 webContents,
273 pageUrl: testCase.pageUrl,
274 candidates: [testCase.faviconUrl],
275 signal: new AbortController().signal,
276 });
277​
278 expect(result).toEqual({ kind: "captured", dataUrl: PNG });
279 expect(fetch).toHaveBeenCalledWith(
280 testCase.faviconUrl,
281 expect.objectContaining({ credentials: testCase.credentials, redirect: "error" }),
282 );
283 });
284​
285 it("decodes base64 and percent-encoded inline images without fetching", async () => {
286 const { webContents, fetch, executeJavaScriptInIsolatedWorld } = makeWebContents();
287 const percentEncodedPng = [...SOURCE_PNG]
288 .map((byte) => `%${byte.toString(16).padStart(2, "0")}`)
289 .join("");
290​
291 for (const candidate of [SOURCE_PNG_URL, `data:image/png,${percentEncodedPng}`]) {
292 expect(
293 await captureFavicon({
294 webContents,
295 pageUrl: "https://example.com/page",
296 candidates: [candidate],
297 signal: new AbortController().signal,
298 }),
299 ).toEqual({ kind: "captured", dataUrl: PNG });
300 }
301​
302 expect(fetch).not.toHaveBeenCalled();
303 expect(executeJavaScriptInIsolatedWorld).toHaveBeenCalledTimes(2);
304 });
305​
306 it("tries the next candidate after an ordinary rejection", async () => {
307 const { webContents, fetch } = makeWebContents({
308 fetch: async (url) =>
309 url.endsWith("first.png")
310 ? new Response(null, { status: 404 })
311 : new Response(new Uint8Array(SOURCE_PNG), {
312 headers: { "content-type": "image/png" },
313 }),
314 });
315​
316 expect(
317 await captureFavicon({
318 webContents,
319 pageUrl: "https://example.com/page",
320 candidates: ["https://example.com/first.png", "https://example.com/second.png"],
321 signal: new AbortController().signal,
322 }),
323 ).toEqual({ kind: "captured", dataUrl: PNG });
324 expect(fetch).toHaveBeenCalledTimes(2);
325 });
326​
327 it("stops a pending fetch when its capture is aborted", async () => {
328 const controller = new AbortController();
329 const { webContents } = makeWebContents({
330 fetch: (_url, init) =>
331 new Promise<Response>((_resolve, reject) => {
332 init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), {
333 once: true,
334 });
335 }),
336 });
337 const capture = captureFavicon({
338 webContents,
339 pageUrl: "https://example.com/page",
340 candidates: ["https://example.com/favicon.png"],
341 signal: controller.signal,
342 });
343 controller.abort();
344 expect(await capture).toEqual({ kind: "none" });
345 });
346​
347 it("rejects and cancels an oversized streamed response", async () => {
348 const cancel = vi.fn();
349 const body = new ReadableStream<Uint8Array>({
350 start(controller) {
351 controller.enqueue(new Uint8Array(MAX_FAVICON_RESPONSE_BYTES));
352 controller.enqueue(new Uint8Array(1));
353 },
354 cancel,
355 });
356 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents({
357 fetch: async () => new Response(body, { headers: { "content-type": "image/png" } }),
358 });
359​
360 expect(
361 await captureFavicon({
362 webContents,
363 pageUrl: "https://example.com/page",
364 candidates: ["https://example.com/favicon.png"],
365 signal: new AbortController().signal,
366 }),
367 ).toEqual({ kind: "none" });
368 expect(cancel).toHaveBeenCalledOnce();
369 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
370 });
371​
372 it("rejects JPEG metadata when distinct SubIFDs exhaust the linear work budget", async () => {
373 const source = sourceJpegWithApp1Segments(64, 32, [
374 sourceJpegExifWithOverlappingSubIfds(32, 32),
375 ]);
376 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
377​
378 expect(
379 await captureFavicon({
380 webContents,
381 pageUrl: "https://example.com/page",
382 candidates: [`data:image/jpeg;base64,${source.toString("base64")}`],
383 signal: new AbortController().signal,
384 }),
385 ).toEqual({ kind: "none" });
386 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
387 });
388​
389 it.each([
390 ["GIF", "image/gif", sourceGif(4096, 4096)],
391 ["GIF frame", "image/gif", sourceGif(1, 1, 4096, 4096)],
392 ["GIF later frame", "image/gif", sourceGif(1, 1, 1, 1, [{ width: 4096, height: 4096 }])],
393 [
394 "GIF cumulative frames",
395 "image/gif",
396 sourceGif(
397 64,
398 64,
399 64,
400 64,
401 Array.from({ length: 256 }, () => ({ width: 64, height: 64 })),
402 ),
403 ],
404 ["JPEG", "image/jpeg", sourceJpeg(4096, 4096)],
405 ["WebP", "image/webp", sourceWebp(4096, 4096)],
406 ["ICO with PNG", "image/x-icon", sourceIco(makeUnsafePng())],
407 ["ICO with DIB", "image/x-icon", sourceIco(makeUnsafeDib())],
408 ["SVG", "image/svg+xml", Buffer.from('<svg width="1" height="1"/>')],
409 [
410 "SVG with embedded bitmap",
411 "image/svg+xml",
412 Buffer.from(
413 `<svg width="1" height="1"><image href="data:image/png;base64,${makeUnsafePng().toString("base64")}"/></svg>`,
414 ),
415 ],
416 [
417 "ICO invalid payload span",
418 "image/x-icon",
419 (() => {
420 const buffer = Buffer.alloc(22);
421 buffer.writeUInt16LE(1, 2);
422 buffer.writeUInt16LE(1, 4);
423 buffer.writeUInt32LE(100, 14);
424 buffer.writeUInt32LE(22, 18);
425 return buffer;
426 })(),
427 ],
428 ])("rejects unsafe or unsupported %s before rasterization", async (_label, mime, buffer) => {
429 const { webContents, executeJavaScriptInIsolatedWorld } = makeWebContents();
430 const candidate = `data:${mime};base64,${buffer.toString("base64")}`;
431 expect(
432 await captureFavicon({
433 webContents,
434 pageUrl: "https://example.com/page",
435 candidates: [candidate],
436 signal: new AbortController().signal,
437 }),
438 ).toEqual({ kind: "none" });
439 expect(executeJavaScriptInIsolatedWorld).not.toHaveBeenCalled();
440 });
441​
442 it("coalesces queued rasterizations so only the latest pending capture launches", async () => {
443 let resolveFirst!: (value: unknown) => void;
444 let executions = 0;
445 const { webContents } = makeWebContents({
446 rasterize: () => {
447 executions += 1;
448 return executions === 1
449 ? new Promise((resolve) => {
450 resolveFirst = resolve;
451 })
452 : Promise.resolve(PNG);
453 },
454 });
455 const input = {
456 webContents,
457 pageUrl: "https://example.com/page",
458 candidates: [SOURCE_PNG_URL],
459 signal: new AbortController().signal,
460 };
461 const first = captureFavicon(input);
462 const superseded = captureFavicon(input);
463 const newest = captureFavicon(input);
464​
465 expect(executions).toBe(1);
466 resolveFirst(PNG);
467 expect(await first).toEqual({ kind: "captured", dataUrl: PNG });
468 expect(await superseded).toEqual({ kind: "none" });
469 expect(await newest).toEqual({ kind: "captured", dataUrl: PNG });
470 expect(executions).toBe(2);
471 });
472});