fix(browser): reject malformed UTF-8 CDP protocol bytes (#10040)

This commit is contained in:
morluto authored and GitHub committed 2026-11-11 17:13:45 +08:00
1 parent 8c40ba78de
commit 9cd1eadfb7
3 files changed
+55 -12

No files matched your search

+14 -11
View File
@@ -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<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
@@ -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);
+1 -1
View File
@@ -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;