diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 2e4193644..b29ab121d 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -17,6 +17,7 @@ import { mainFrameUrl } from "./CdpCaptureDocuments.js"; import { captureFrames } from "./CdpCaptureDocuments.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { decodeCanonicalBase64 } from "../domain/webScreenshot.js"; +import { readPngHeaderDimensions } from "../domain/pngHeader.js"; interface ScreenshotContext { readonly connection: CdpConnection; @@ -94,7 +95,9 @@ export const captureCdpScreenshot = async ( encoded === undefined ? undefined : decodeCanonicalBase64(encoded); if (bytes === undefined) throw new BrowserObservationError("inspect_web_page", "payload_limit"); - const dimensions = pngDimensions(bytes); + const dimensions = readPngHeaderDimensions(bytes); + if (dimensions === undefined) + throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (navigation.leftScope) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); const afterUrl = mainFrameUrl(after); @@ -158,21 +161,3 @@ const observeScreenshotNavigation = ( if (allowedSanitizedUrl(frame?.url, origins) === undefined) state.leftScope = true; }; - -const pngDimensions = ( - bytes: Buffer, -): { readonly width: number; readonly height: number } => { - if ( - bytes.byteLength < 24 || - !bytes.subarray(0, 8).equals(PNG_SIGNATURE) || - bytes.subarray(12, 16).toString("ascii") !== "IHDR" - ) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - const width = bytes.readUInt32BE(16); - const height = bytes.readUInt32BE(20); - if (width === 0 || height === 0) - throw new BrowserObservationError("inspect_web_page", "protocol_error"); - return { width, height }; -}; - -const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); diff --git a/src/domain/native/nativeUiObservation.ts b/src/domain/native/nativeUiObservation.ts index 392d4d8db..22493175c 100644 --- a/src/domain/native/nativeUiObservation.ts +++ b/src/domain/native/nativeUiObservation.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { z } from "zod"; import { decodeCanonicalBase64 } from "../webScreenshot.js"; +import { readPngHeaderDimensions } from "../pngHeader.js"; const scope = { pid: z.number().int().positive(), @@ -95,11 +96,8 @@ const nativeUiScreenshotSchema = z code: "custom", message: "PNG screenshot digest mismatch", }); - if ( - bytes.byteLength < 24 || - !bytes.subarray(0, 8).equals(PNG_SIGNATURE) || - bytes.subarray(12, 16).toString("ascii") !== "IHDR" - ) { + const dimensions = readPngHeaderDimensions(bytes); + if (dimensions === undefined) { context.addIssue({ code: "custom", message: "Screenshot bytes are not a PNG image", @@ -107,8 +105,8 @@ const nativeUiScreenshotSchema = z return; } if ( - bytes.readUInt32BE(16) !== screenshot.width || - bytes.readUInt32BE(20) !== screenshot.height + dimensions.width !== screenshot.width || + dimensions.height !== screenshot.height ) context.addIssue({ code: "custom", @@ -116,8 +114,6 @@ const nativeUiScreenshotSchema = z }); }); -const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); - export const nativeUiSnapshotSchema = z.strictObject({ window: z.strictObject({ pid: z.number().int().positive(), diff --git a/src/domain/pngHeader.test.ts b/src/domain/pngHeader.test.ts new file mode 100644 index 000000000..e8df531bc --- /dev/null +++ b/src/domain/pngHeader.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "vitest"; +import { readPngHeaderDimensions } from "./pngHeader.js"; + +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=", + "base64", +); + +it("reads encoded dimensions without changing screenshot bytes", () => { + const bytes = Buffer.from(png); + bytes.writeUInt32BE(257, 16); + bytes.writeUInt32BE(129, 20); + const original = Buffer.from(bytes); + expect(readPngHeaderDimensions(bytes)).toEqual({ width: 257, height: 129 }); + expect(bytes.equals(original)).toBe(true); +}); + +it.each([12, 13, 14, 15])("rejects ASCII-folded IHDR byte at %s", (offset) => { + const bytes = Buffer.from(png); + bytes[offset] = (bytes[offset] ?? 0) | 0x80; + expect(bytes.subarray(12, 16).toString("ascii")).toBe("IHDR"); + expect(readPngHeaderDimensions(bytes)).toBeUndefined(); +}); + +it("rejects incomplete headers, invalid signatures and zero dimensions", () => { + expect(readPngHeaderDimensions(png.subarray(0, 23))).toBeUndefined(); + const badSignature = Buffer.from(png); + badSignature[0] = 0; + expect(readPngHeaderDimensions(badSignature)).toBeUndefined(); + for (const offset of [16, 20]) { + const zero = Buffer.from(png); + zero.writeUInt32BE(0, offset); + expect(readPngHeaderDimensions(zero)).toBeUndefined(); + } +}); diff --git a/src/domain/pngHeader.ts b/src/domain/pngHeader.ts new file mode 100644 index 000000000..58cdd7d3f --- /dev/null +++ b/src/domain/pngHeader.ts @@ -0,0 +1,17 @@ +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +const IHDR_TYPE = Buffer.from("IHDR"); + +/** Read screenshot dimensions from a byte-faithful PNG header, not a full image decode. */ +export const readPngHeaderDimensions = ( + bytes: Buffer, +): { readonly width: number; readonly height: number } | undefined => { + if ( + bytes.byteLength < 24 || + !bytes.subarray(0, 8).equals(PNG_SIGNATURE) || + !bytes.subarray(12, 16).equals(IHDR_TYPE) + ) + return undefined; + const width = bytes.readUInt32BE(16); + const height = bytes.readUInt32BE(20); + return width === 0 || height === 0 ? undefined : { width, height }; +}; diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index 4d1093826..d2238bd01 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -90,6 +90,33 @@ describe("native UI screenshot validation", () => { }, ); + it("rejects a PNG whose IHDR type only matches after ASCII folding", async () => { + const bytes = Buffer.from(validPng); + const type = bytes.subarray(12, 16); + type[0] = (type[0] ?? 0) | 0x80; + expect(type.toString("ascii")).toBe("IHDR"); + const screenshot = { + mime_type: "image/png", + base64: bytes.toString("base64"), + sha256: createHash("sha256").update(bytes).digest("hex"), + width: 1, + height: 1, + }; + const result = await observeNativeUi( + target, + "observe_native_ui", + { ...scope, screenshot: true }, + { + environment: {}, + invoke: async () => ({ + ok: true, + result: { ...snapshot, screenshot }, + }), + }, + ); + expect(result.ok).toBe(false); + }); + it("accepts a self-consistent PNG screenshot from the helper", async () => { const bytes = validPng; const screenshot = {