From f1d53a4c6ba0f3119633f574e17adf6b0ac8ee8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E5=98=89=E4=BC=9F?= <202383014@uibe.edu.cn> Date: Wed, 7 Oct 2026 23:06:10 +0800 Subject: [PATCH] fix: compare image signature bytes without ASCII coercion --- .../IMAGE_SIGNATURE_BYTES_2026-10-07.md | 18 ++++++++ .../ai-providers/cursor/input-images.ts | 12 ++++-- tests/extensions/ai-providers/cursor.test.ts | 32 ++++++++++++++ tests/web/web-host.test.ts | 42 +++++++++++++++++++ web/host/web-host.ts | 8 ++-- 5 files changed, 104 insertions(+), 8 deletions(-) create mode 100644 docs/research/IMAGE_SIGNATURE_BYTES_2026-10-07.md diff --git a/docs/research/IMAGE_SIGNATURE_BYTES_2026-10-07.md b/docs/research/IMAGE_SIGNATURE_BYTES_2026-10-07.md new file mode 100644 index 00000000..d4e44382 --- /dev/null +++ b/docs/research/IMAGE_SIGNATURE_BYTES_2026-10-07.md @@ -0,0 +1,18 @@ +# Exact image signature bytes + +- Status: validated source and regression evidence +- Created / verified: 2026-10-07 +- Source: origin/main at 3cb2ecfe1bfbb98252651885311d309f83749428 +- Issue: [#697](https://github.com/openpi-dev/openpi/issues/697) +- Supersedes: none + +## Verified facts + +ASCII decoding cleared high bits in GIF and RIFF/WEBP signatures, accepting corrupted image bytes. Cursor input and Web HTTP admission regressions failed before the fix and pass after exact Buffer comparisons. Valid GIF87a, GIF89a and WEBP signatures remain accepted; PNG/JPEG behavior is unchanged. + +## Verification boundary + +Production input and HTTP tests; no model or installed runtime acceptance is claimed. + +Full repository validation results and CI are recorded on the linked PR. Local +Windows failures are retained separately and are not reported as passing. diff --git a/extensions/ai-providers/cursor/input-images.ts b/extensions/ai-providers/cursor/input-images.ts index 1faaa159..87e9ddea 100644 --- a/extensions/ai-providers/cursor/input-images.ts +++ b/extensions/ai-providers/cursor/input-images.ts @@ -43,13 +43,17 @@ function detectImageMimeType( return "image/jpeg"; } if (bytes.length >= 6) { - const signature = Buffer.from(bytes.subarray(0, 6)).toString("ascii"); - if (signature === "GIF87a" || signature === "GIF89a") return "image/gif"; + const signature = Buffer.from(bytes.subarray(0, 6)); + if ( + signature.equals(Buffer.from("GIF87a")) || + signature.equals(Buffer.from("GIF89a")) + ) + return "image/gif"; } if ( bytes.length >= 12 && - Buffer.from(bytes.subarray(0, 4)).toString("ascii") === "RIFF" && - Buffer.from(bytes.subarray(8, 12)).toString("ascii") === "WEBP" + Buffer.from(bytes.subarray(0, 4)).equals(Buffer.from("RIFF")) && + Buffer.from(bytes.subarray(8, 12)).equals(Buffer.from("WEBP")) ) { return "image/webp"; } diff --git a/tests/extensions/ai-providers/cursor.test.ts b/tests/extensions/ai-providers/cursor.test.ts index d2ad217e..69745cf2 100644 --- a/tests/extensions/ai-providers/cursor.test.ts +++ b/tests/extensions/ai-providers/cursor.test.ts @@ -361,6 +361,38 @@ test("Cursor image-path conversion is scoped to interactive Cursor input", async ); }); +test("Cursor image-path conversion requires literal GIF and WebP signature bytes", async (t) => { + const directory = await mkdtemp(join(tmpdir(), "openpi-cursor-signatures-")); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const signature of ["GIF87a", "GIF89a", "RIFF\0\0\0\0WEBP"]) { + const bytes = Buffer.from(signature); + const path = join( + directory, + signature.startsWith("GIF") ? "fixture.gif" : "fixture.webp", + ); + const event = { + type: "input" as const, + source: "interactive" as const, + text: JSON.stringify(path), + }; + const context = { model: { provider: "cursor" } } as ExtensionContext; + await writeFile(path, bytes); + const valid = await transformCursorImageInput(event, context); + assert.equal(valid.action, "transform"); + for (let index = 0; index < bytes.length; index++) { + if (signature.startsWith("RIFF") && index >= 4 && index < 8) continue; + const corrupted = Buffer.from(bytes); + corrupted[index]! |= 0x80; + await writeFile(path, corrupted); + assert.deepEqual( + await transformCursorImageInput(event, context), + { action: "continue" }, + `signature byte ${index}`, + ); + } + } +}); + test("Cursor multi-turn request omits prior thinking outside OMP's Kimi-only replay", async () => { const built = await buildCursorRequest(MODEL, { messages: [ diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d87aba09..e0dcdcaf 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -3539,6 +3539,48 @@ test("admits the advertised image byte limits through the HTTP body boundary", a } }); +test("prompt image admission requires literal GIF and WebP signature bytes", async () => { + const cwd = await mkdtemp(join(tmpdir(), "openpi-web-image-signatures-")); + let dispatches = 0; + const runtime = testRuntime(cwd, async () => { + dispatches++; + return { pendingFollowUps: 0 }; + }); + const { host, launched, headers } = await startTestHost(runtime); + const post = (mimeType: string, bytes: Buffer) => + fetch(`${launched.origin}/api/prompt`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: runtime.sessionManager.getSessionId(), + sessionPath: mutationSessionPath(runtime.sessionManager), + content: "Signature fixture; no model call.", + images: [{ mimeType, data: bytes.toString("base64") }], + }), + }); + try { + for (const signature of ["GIF87a", "GIF89a", "RIFF\0\0\0\0WEBP"]) { + const mime = signature.startsWith("GIF") ? "image/gif" : "image/webp"; + const bytes = Buffer.from(signature); + for (let index = 0; index < bytes.length; index++) { + if (signature.startsWith("RIFF") && index >= 4 && index < 8) continue; + const corrupted = Buffer.from(bytes); + corrupted[index]! |= 0x80; + const response = await post(mime, corrupted); + assert.equal(response.status, 400, `signature byte ${index}`); + await response.arrayBuffer(); + } + const response = await post(mime, bytes); + assert.equal(response.status, 202); + await response.arrayBuffer(); + } + assert.equal(dispatches, 3); + } finally { + await host.stop(); + await rm(cwd, { recursive: true, force: true }); + } +}); + test("replays one prompt admission after a browser timeout", async () => { const cwd = await mkdtemp(join(tmpdir(), "openpi-web-prompt-retry-")); let sendCalls = 0; diff --git a/web/host/web-host.ts b/web/host/web-host.ts index c8f0a3a0..906c7711 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -120,12 +120,12 @@ function hasImageSignature(bytes: Buffer, mimeType: WebPromptImage["mimeType"]) if (mimeType === "image/jpeg") return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; if (mimeType === "image/gif") { - const signature = bytes.subarray(0, 6).toString("ascii"); - return signature === "GIF87a" || signature === "GIF89a"; + const signature = bytes.subarray(0, 6); + return signature.equals(Buffer.from("GIF87a")) || signature.equals(Buffer.from("GIF89a")); } return ( - bytes.subarray(0, 4).toString("ascii") === "RIFF" && - bytes.subarray(8, 12).toString("ascii") === "WEBP" + bytes.subarray(0, 4).equals(Buffer.from("RIFF")) && + bytes.subarray(8, 12).equals(Buffer.from("WEBP")) ); }