fix(browser)!: preserve unknown WebSocket frame facts

Keep malformed or missing CDP opcodes and payload facts unknown, retain observed zero and empty payloads, and avoid false capture differences while preserving independently known facts.

Validation: 59 source tests; compiled public CLI and stdio MCP fake-CDP acceptance; check:fast; docs:check; changed-file formatting. Real Chromium malformed-event behavior was not tested.

BREAKING CHANGE: WebSocket event opcode is nullable and numeric values are restricted to the four-bit protocol range.

Fixes #5666
This commit is contained in:
morluto
2026-10-21 05:00:37 +08:00
parent c38f96fa69
commit 0bc2b42001
7 changed files with 458 additions and 26 deletions
+1 -1
View File
@@ -362,7 +362,7 @@ REA retains local observation data and removes only URL userinfo credentials, ex
WebSocket coverage. Redirect resource types use only shared known metadata:
an omitted type leaves equality unknown rather than proving a modification.
- Console observations with a stack source retain call type, argument types, the CDP `Runtime.Timestamp` in milliseconds since Unix epoch, and source location. An absent or invalid timestamp is `null` and makes `console_events` incomplete; an observed zero remains zero. This epoch-millisecond clock is separate from the monotonic-second timestamps in `observe-web-session`. When console text capture is selected, already-delivered primitive values are retained verbatim; objects, getters, and remote properties are never expanded.
- WebSocket observations retain direction, opcode, and payload byte length. When shape capture is selected, text frames are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw frames are never retained.
- WebSocket observations retain direction, opcode, and payload byte length. A missing or malformed CDP opcode is returned as `null`, with opcode-dependent payload byte length and shape also `null`, and marks `websocket_frames` incomplete. Missing or wrong-typed payload data is not treated as empty; its byte length is `null`, and a text payload shape is unknown. Numeric opcode `0` and an observed empty payload remain distinct from missing data. Opcode is nullable in the public inspection schema, so clients must handle unknown protocol values. When shape capture is selected, text messages are classified as text or complete value-free JSON shape; binary bytes, hashes, prefixes, and raw payloads are never retained.
- Storage observations always redact values. Key names, IndexedDB names, and cache names require `include_storage_keys`; stable content fingerprints require the additional `include_storage_fingerprints` selection. Cookie fingerprints cover cookies applicable to the current main-frame URL, including its path, rather than every cookie path on that origin. Other storage remains scoped to the origin. Cache fingerprints hash all decoded response bytes, including bodies above 64 KiB, subject to the [64 MiB per-message CDP limit](#request-boundary). Missing or undecodable cache bodies and partial IndexedDB remote objects make fingerprint coverage incomplete, so identical observations remain `unknown`.
- Unavailable or malformed storage inventories mark `storage_keys` unavailable, even without fingerprints. Comparisons suppress inferred key additions and removals while preserving modifications proven by complete fingerprints on both sides. Previously exported captures are not retroactively corrected; recapture them to obtain these coverage markers.
- Script metadata is included only when CDP supplies a URL on an allowed origin. Stable keys exclude transient CDP script IDs, and exact transient raw URLs are used only during script/resource reconciliation. URL-less scripts are excluded because their origin cannot be established. When CDP supplies an execution-context association, the accepted script retains its authorized frame ID for later attribution. Script metadata admission bounds retained identity and source-map strings to 8 MiB and 50,000 records. When a source-map URL cannot fit, the script identity remains and source-map coverage reports `resource_budget_exhausted`; rejected script observations are counted without retaining their IDs. Source content is omitted unless explicitly requested and is returned inline as a self-verifying artifact with its SHA-256 digest.
+22 -9
View File
@@ -509,28 +509,41 @@ export const handleWebSocketFrame = (
const connection = state.webSocketConnectionsById.get(requestId);
if (connection === undefined) return;
const response = recordValue(params.response);
const payload = cdpStringValue(response?.payloadData) ?? "";
const opcode = Math.max(0, Math.trunc(numberValue(response?.opcode) ?? 0));
const decodedBytes =
opcode === 1 ? undefined : paddedBase64ByteLength(payload);
if (opcode !== 1 && decodedBytes === undefined)
const opcodeValue = numberValue(response?.opcode);
// RFC 6455 encodes opcodes in four bits; keep reserved values as observed data.
const opcode =
opcodeValue !== undefined &&
Number.isInteger(opcodeValue) &&
opcodeValue >= 0 &&
opcodeValue <= 15
? opcodeValue
: null;
const payload = cdpStringValue(response?.payloadData);
const payloadBytes =
opcode === null || payload === undefined
? null
: opcode === 1
? Buffer.byteLength(payload)
: (paddedBase64ByteLength(payload) ?? null);
if (opcode === null || payloadBytes === null)
state.completeness.exclude("websocket_frames", "invalid_protocol_value");
connection.events.push({
direction,
opcode,
payload_bytes:
opcode === 1 ? Buffer.byteLength(payload) : (decodedBytes ?? null),
payload_bytes: payloadBytes,
payload_shape: captureWebSocketShape(state, payload, opcode),
});
};
const captureWebSocketShape = (
state: CdpCaptureEventsState,
payload: string,
opcode: number,
payload: string | undefined,
opcode: number | null,
): WebPageInspection["network"]["websocket_connections"][number]["events"][number]["payload_shape"] => {
if (!state.input.include_websocket_shapes) return null;
if (opcode === null) return null;
if (opcode !== 1) return { format: "binary", json_shape: null };
if (payload === undefined) return null;
const shape = inferJsonShape(payload);
return {
format: shape === null ? "text" : "json",
@@ -0,0 +1,182 @@
import { expect, it } from "vitest";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
import { CdpCaptureEvents } from "./CdpCaptureEvents.js";
const origin = "https://example.test";
const createEvents = (): CdpCaptureEvents =>
new CdpCaptureEvents(
inspectWebPageInputSchema.parse({
cdp_endpoint: "http://127.0.0.1:9222",
allowed_origins: [origin],
target_id: "page",
include_websocket_shapes: true,
}),
new Set([origin]),
);
const captureFrame = (
opcode: unknown,
payloadData: unknown,
includeOpcode = true,
includePayload = true,
) => {
const events = createEvents();
events.ingest({
method: "Network.webSocketCreated",
params: { requestId: "socket", url: "wss://example.test/live" },
});
events.ingest({
method: "Network.webSocketFrameReceived",
params: {
requestId: "socket",
response: {
...(includeOpcode ? { opcode } : {}),
...(includePayload ? { payloadData } : {}),
},
},
});
return {
events,
frame: events.webSocketConnectionsById.get("socket")?.events[0],
};
};
it.each([
{
label: "missing",
opcode: undefined,
payload: "",
hasOpcode: false,
hasPayload: true,
},
{
label: "wrong type",
opcode: "1",
payload: "text",
hasOpcode: true,
hasPayload: true,
},
{
label: "explicit null",
opcode: null,
payload: "",
hasOpcode: true,
hasPayload: true,
},
{
label: "negative",
opcode: -1,
payload: "",
hasOpcode: true,
hasPayload: true,
},
{
label: "fractional",
opcode: 1.5,
payload: "text",
hasOpcode: true,
hasPayload: true,
},
{
label: "above opcode width",
opcode: 16,
payload: "",
hasOpcode: true,
hasPayload: true,
},
{
label: "non-finite",
opcode: Number.NaN,
payload: "",
hasOpcode: true,
hasPayload: true,
},
] as const)(
"keeps $label WebSocket opcode unknown",
({ opcode, payload, hasOpcode, hasPayload }) => {
const { events, frame } = captureFrame(
opcode,
payload,
hasOpcode,
hasPayload,
);
expect(frame).toEqual({
direction: "received",
opcode: null,
payload_bytes: null,
payload_shape: null,
});
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "websocket_frames",
reason: "invalid_protocol_value",
count: 1,
});
expect(
webPageInspectionSchema.shape.network.shape.websocket_connections.element.safeParse(
{
request_id: "socket",
url: "wss://example.test/live",
events: [frame],
},
).success,
).toBe(true);
},
);
it.each([
{ label: "continuation", opcode: 0, payload: "", bytes: 0, format: "binary" },
{ label: "empty text", opcode: 1, payload: "", bytes: 0, format: "text" },
{
label: "reserved four-bit opcode",
opcode: 15,
payload: "",
bytes: 0,
format: "binary",
},
] as const)(
"preserves observed $label data",
({ opcode, payload, bytes, format }) => {
const { events, frame } = captureFrame(opcode, payload);
expect(frame).toEqual({
direction: "received",
opcode,
payload_bytes: bytes,
payload_shape: { format, json_shape: null },
});
expect(events.completeness.snapshot().excluded).not.toContainEqual(
expect.objectContaining({
section: "websocket_frames",
reason: "invalid_protocol_value",
}),
);
},
);
it.each([
{
label: "missing payload",
opcode: 1,
payload: undefined,
hasPayload: false,
},
{ label: "wrong-type payload", opcode: 1, payload: 42, hasPayload: true },
] as const)(
"does not fabricate an empty payload for $label",
({ opcode, payload, hasPayload }) => {
const { events, frame } = captureFrame(opcode, payload, true, hasPayload);
expect(frame).toEqual({
direction: "received",
opcode,
payload_bytes: null,
payload_shape: null,
});
expect(events.completeness.snapshot().excluded).toContainEqual({
section: "websocket_frames",
reason: "invalid_protocol_value",
count: 1,
});
},
);
+1 -1
View File
@@ -185,7 +185,7 @@ const browserConsoleEventSchema = z.object({
});
const browserWebSocketFrameSchema = z.object({
direction: z.enum(["sent", "received"]),
opcode: z.number().int().min(0),
opcode: z.number().int().min(0).max(15).nullable(),
payload_bytes: z.number().int().min(0).nullable(),
payload_shape: z
.object({
+68
View File
@@ -219,6 +219,74 @@ describe("passive network inventory changes", () => {
});
});
describe("unknown WebSocket opcode evidence", () => {
const captureWithWebSocketFrames = (
events: WebPageInspection["network"]["websocket_connections"][number]["events"],
): WebPageInspection => {
const capture = unselectedShapes(passiveNetworkCapture([]));
capture.network.websocket_connections.push({
request_id: "capture-local-socket-id",
url: "wss://app.example.test/live",
events: structuredClone(events),
});
return capture;
};
it("does not compare an unknown opcode as a real continuation frame", () => {
const malformed = captureWithWebSocketFrames([
{
direction: "received",
opcode: null,
payload_bytes: null,
payload_shape: null,
},
]);
const observedZero = captureWithWebSocketFrames([
{
direction: "received",
opcode: 0,
payload_bytes: 0,
payload_shape: { format: "binary", json_shape: null },
},
]);
for (const [before, after] of [
[malformed, observedZero],
[observedZero, malformed],
] as const)
expect(compare(before, after)).toMatchObject({
status: "unknown",
total_changes: 0,
changes: [],
});
});
it("still compares known payload facts in a stream with another unknown frame", () => {
const before = captureWithWebSocketFrames([
{
direction: "received",
opcode: null,
payload_bytes: null,
payload_shape: null,
},
{
direction: "sent",
opcode: 1,
payload_bytes: 3,
payload_shape: { format: "text", json_shape: null },
},
]);
const after = structuredClone(before);
after.network.websocket_connections[0]!.events[1]!.payload_bytes = 4;
expect(compare(before, after)).toMatchObject({
status: "changed",
total_changes: 1,
changes: [{ change: "modified" }],
});
});
});
describe("passive network coverage uncertainty", () => {
it.each([
"attach_limited_sections",
+36 -15
View File
@@ -367,6 +367,26 @@ const networkInventoryComplete = (
(section) => inspection.completeness.dropped_events[section] === 0,
);
type WebSocketEvent =
WebPageInspection["network"]["websocket_connections"][number]["events"][number];
const webSocketEventHasOpcode = (
event: WebSocketEvent | undefined,
): event is WebSocketEvent => event !== undefined && event.opcode !== null;
const webSocketEventHasPayloadBytes = (
event: WebSocketEvent | undefined,
): boolean => webSocketEventHasOpcode(event) && event.payload_bytes !== null;
const webSocketEventHasPayloadShape = (
event: WebSocketEvent | undefined,
): boolean => webSocketEventHasOpcode(event) && event.payload_shape !== null;
const webSocketOpcodesKnown = (inspection: WebPageInspection): boolean =>
inspection.network.websocket_connections.every((connection) =>
connection.events.every(webSocketEventHasOpcode),
);
const networkDimension = (
before: WebPageInspection,
after: WebPageInspection,
@@ -374,11 +394,12 @@ const networkDimension = (
const requestsComplete = [before, after].every((inspection) =>
networkInventoryComplete(inspection, ["network_requests"]),
);
const webSocketInventoryComplete = [before, after].every((inspection) =>
networkInventoryComplete(inspection, [
"websocket_connections",
"websocket_frames",
]),
const webSocketInventoryComplete = [before, after].every(
(inspection) =>
networkInventoryComplete(inspection, [
"websocket_connections",
"websocket_frames",
]) && webSocketOpcodesKnown(inspection),
);
const requestIdentities = networkIdentityCoverage(before, after);
const metadata = sharedNetworkMetadata(before, after);
@@ -432,12 +453,12 @@ const networkDimension = (
(inspection) =>
sectionsComplete(inspection, ["websocket_shapes"]) &&
inspection.network.websocket_connections.every((connection) =>
connection.events.every((event) => event.payload_shape !== null),
connection.events.every(webSocketEventHasPayloadShape),
),
);
const webSocketPayloadBytesComplete = [before, after].every((inspection) =>
inspection.network.websocket_connections.every((connection) =>
connection.events.every((event) => event.payload_bytes !== null),
connection.events.every(webSocketEventHasPayloadBytes),
),
);
const beforeWebSockets = webSocketGroups(before);
@@ -633,11 +654,11 @@ const webSocketPayloadMaps = (
const comparableShapes = new Set<number>();
for (let index = 0; index < eventCount; index += 1) {
if (
beforeGroup.connections.every(
(connection) => connection.events[index]?.payload_bytes !== null,
beforeGroup.connections.every((connection) =>
webSocketEventHasPayloadBytes(connection.events[index]),
) &&
afterGroup.connections.every(
(connection) => connection.events[index]?.payload_bytes !== null,
afterGroup.connections.every((connection) =>
webSocketEventHasPayloadBytes(connection.events[index]),
)
)
comparableBytes.add(index);
@@ -645,11 +666,11 @@ const webSocketPayloadMaps = (
// on both sides; this avoids assigning an unknown shape to a known one.
if (
selected &&
beforeGroup.connections.every(
(connection) => connection.events[index]?.payload_shape !== null,
beforeGroup.connections.every((connection) =>
webSocketEventHasPayloadShape(connection.events[index]),
) &&
afterGroup.connections.every(
(connection) => connection.events[index]?.payload_shape !== null,
afterGroup.connections.every((connection) =>
webSocketEventHasPayloadShape(connection.events[index]),
)
)
comparableShapes.add(index);
@@ -0,0 +1,148 @@
import { expect } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const websocketEvents = (origin: string) => {
const cases = [
{ requestId: "missing-opcode", response: { payloadData: "text" } },
{
requestId: "wrong-type-opcode",
response: { opcode: "1", payloadData: "text" },
},
{ requestId: "null-opcode", response: { opcode: null, payloadData: "" } },
{ requestId: "negative-opcode", response: { opcode: -1, payloadData: "" } },
{
requestId: "fractional-opcode",
response: { opcode: 1.5, payloadData: "text" },
},
{ requestId: "observed-zero", response: { opcode: 0, payloadData: "" } },
{ requestId: "observed-text", response: { opcode: 1, payloadData: "" } },
{ requestId: "bad-width", response: { opcode: 16, payloadData: "" } },
{ requestId: "missing-payload", response: { opcode: 1 } },
];
return cases.flatMap(({ requestId, response }) => [
{
method: "Network.webSocketCreated",
params: { requestId, url: `${origin.replace(/^http/u, "ws")}/live` },
},
{
method: "Network.webSocketFrameReceived",
params: { requestId, response },
},
]);
};
cliTest(
"preserves unknown WebSocket opcodes and known zero through public CLI and stdio MCP",
async ({ cli }) => {
const { call } = await connectLocalToolsMcp();
const browser = await startFakeCdpBrowser({
eventsBeforeReply: ["Network.enable"],
commandEvents: (command, origin) =>
command.method === "Network.enable"
? websocketEvents(origin).map((event) => ({
...event,
...(command.sessionId === undefined
? {}
: { sessionId: command.sessionId }),
}))
: undefined,
});
try {
const cliResponse = await cli.run({
arguments: [
"inspect-web-page",
browser.endpoint,
"allowed-page",
"--allowed-origins",
browser.allowedOrigin,
"--observation-ms",
"0",
"--include-websocket-shapes",
"--json",
],
});
expect(cliResponse.exitCode, cliResponse.stdout).toBe(0);
const cliContract = toolContract("inspect_web_page").outputSchema.parse(
cliResponse.json,
);
const cliEvidence = parseEvidence(cliContract);
const mcpResponse = await call("inspect_web_page", {
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
allowed_origins: [browser.allowedOrigin],
observation_ms: 0,
include_websocket_shapes: true,
});
expect(mcpResponse.isError).not.toBe(true);
const mcpEvidence = parseEvidence(
toolContract("inspect_web_page").outputSchema.parse(
mcpResponse.structuredContent,
),
);
for (const evidence of [cliEvidence, mcpEvidence]) {
const page = webPageInspectionSchema.parse(evidence.normalized_result);
const frames = new Map(
page.network.websocket_connections.map((connection) => [
connection.request_id,
connection.events[0],
]),
);
expect(frames.get("missing-opcode")).toMatchObject({
opcode: null,
payload_bytes: null,
payload_shape: null,
});
expect(frames.get("wrong-type-opcode")).toMatchObject({
opcode: null,
payload_bytes: null,
payload_shape: null,
});
expect(frames.get("bad-width")).toMatchObject({
opcode: null,
payload_bytes: null,
payload_shape: null,
});
for (const requestId of [
"null-opcode",
"negative-opcode",
"fractional-opcode",
])
expect(frames.get(requestId)).toMatchObject({
opcode: null,
payload_bytes: null,
payload_shape: null,
});
expect(frames.get("missing-payload")).toMatchObject({
opcode: 1,
payload_bytes: null,
payload_shape: null,
});
expect(frames.get("observed-zero")).toMatchObject({
opcode: 0,
payload_bytes: 0,
payload_shape: { format: "binary", json_shape: null },
});
expect(frames.get("observed-text")).toMatchObject({
opcode: 1,
payload_bytes: 0,
payload_shape: { format: "text", json_shape: null },
});
expect(page.completeness.excluded).toContainEqual({
section: "websocket_frames",
reason: "invalid_protocol_value",
count: 7,
});
}
} finally {
await browser.close();
}
},
);