From 9cd1eadfb7ef5f33f7f2b46f7ee119288d7f25d1 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:13:45 +0800 Subject: [PATCH] fix(browser): reject malformed UTF-8 CDP protocol bytes (#10040) --- src/browser/CdpConnection.ts | 25 ++++++------ tests/boundary/browser/cdpConnection.test.ts | 40 ++++++++++++++++++++ tests/fixtures/fakeCdpBrowserTypes.ts | 2 +- 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 3dadb952ef..8ab66d367e 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -1,4 +1,5 @@ import WebSocket, { type RawData } from "ws"; +import { isUtf8 } from "node:buffer"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -295,7 +296,14 @@ class CdpTransportCore { #receive(data: RawData): void { if (this.#closed || this.#protocolFailed) return; - const parsed = safeParseJson(rawText(data)); + const bytes = rawBytes(data); + // Binary WebSocket messages bypass ws's text-frame UTF-8 validation. + // Never replacement-decode protocol facts into a different accepted value. + if (!isUtf8(bytes)) { + this.#failPending("protocol_error"); + return; + } + const parsed = safeParseJson(bytes.toString("utf8")); if (!parsed.ok) { this.#failPending("protocol_error"); return; @@ -326,7 +334,7 @@ class CdpTransportCore { this.#failPending("protocol_error"); return; } - const wireBytes = rawByteLength(data); + const wireBytes = bytes.byteLength; const event: CdpTransportEvent = { method: message.method, params: "params" in message ? message.params : {}, @@ -593,17 +601,12 @@ export class CdpConnection { const socketIsClosed = (socket: WebSocket): boolean => socket.readyState === WebSocket.CLOSED; -const rawText = (data: RawData): string => +const rawBytes = (data: RawData): Buffer => Array.isArray(data) - ? Buffer.concat(data).toString("utf8") + ? Buffer.concat(data) : Buffer.isBuffer(data) - ? data.toString("utf8") - : Buffer.from(data).toString("utf8"); - -const rawByteLength = (data: RawData): number => - Array.isArray(data) - ? data.reduce((bytes, chunk) => bytes + chunk.byteLength, 0) - : data.byteLength; + ? data + : Buffer.from(data); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index f57a1ad702..ed26fbbe51 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -359,6 +359,46 @@ describe("CDP transport failure precedence", () => { }); describe("CDP connection command responses", () => { + it("rejects invalid UTF-8 replies without aliasing a literal replacement character", async () => { + const browser = await startFakeCdpBrowser({ hangOnMethod: "Page.enable" }); + browsers.push(browser); + const connection = await CdpConnection.connect( + browser.browserWebSocketUrl, + "inspect_web_page", + ); + try { + const control = connection.send("Page.enable"); + await vi.waitFor(() => expect(browser.commands).toHaveLength(1)); + browser.emitRawMessage( + Buffer.from( + JSON.stringify({ + id: browser.commands[0]?.id, + result: { value: "�" }, + }), + ), + ); + await expect(control).resolves.toEqual({ value: "�" }); + const pending = connection.send("Page.enable"); + const rejected = expect(pending).rejects.toMatchObject({ + reason: "protocol_error", + }); + await vi.waitFor(() => expect(browser.commands).toHaveLength(2)); + browser.emitRawMessage( + Buffer.concat([ + Buffer.from( + `{"id":${String(browser.commands[1]?.id)},"result":{"value":"`, + ), + Buffer.from([0xff]), + Buffer.from('"}}'), + ]), + ); + await rejected; + expect(connection.isReusable()).toBe(false); + } finally { + await connection.close(); + } + }); + it("surfaces an unmodeled fake command as a CDP method rejection", async () => { const browser = await startFakeCdpBrowser(); browsers.push(browser); diff --git a/tests/fixtures/fakeCdpBrowserTypes.ts b/tests/fixtures/fakeCdpBrowserTypes.ts index ef2862a412..8e87831d61 100644 --- a/tests/fixtures/fakeCdpBrowserTypes.ts +++ b/tests/fixtures/fakeCdpBrowserTypes.ts @@ -18,7 +18,7 @@ export interface FakeCdpBrowser { /** WebSocket upgrades accepted over the fixture's lifetime. */ openedConnectionCount(): number; /** Deliver actual malformed wire data without bypassing the production transport parser. */ - emitRawMessage(message: string): void; + emitRawMessage(message: string | Buffer): void; emitEvent(event: CdpEvent): void; /** Drop currently open WebSockets from the server side to exercise reconnect paths. */ closeActiveConnections(): void;