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:
parent
c59fbf5e9c
commit
fdd8f82602
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue