fix(browser): compare PNG IHDR bytes without ASCII folding (#2099)

* fix(browser): compare PNG IHDR bytes without ASCII folding

Screenshot checks already compare the PNG signature as raw bytes, then decoded the chunk type with an encoding that maps high-bit bytes onto IHDR.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(browser): satisfy the strict index check in the IHDR test

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(browser): share byte-faithful PNG header facts

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: REA preparation lane <rea-preparation@localhost>
This commit is contained in:
Faheem Ahmad 2026-10-12 14:16:18 -07:00 committed by GitHub
parent c59fbf5e9c
commit fdd8f82602
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 88 additions and 28 deletions

View File

@ -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]);

View File

@ -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(),

View File

@ -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();
}
});

17
src/domain/pngHeader.ts Normal file
View File

@ -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 };
};

View File

@ -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 = {