From f14c2d4df398dbcd3353ea15ab5a4e31be7a5920 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:00:19 +0800 Subject: [PATCH] fix(browser): retain Playwright IPv6 capture representations Adapt observed Playwright 1.58.2 bracketed IPv6 addresses only in the disposable HAR validation copy. Preserve the producer spelling and per-entry limitation through public CLI and MCP workflows, retaining existing safe-number admission policy. Reviewed-PR: #3161 Co-authored-by: wunianze666-netizen --- docs/web-network-captures.md | 7 ++ src/browser/history/HarCapture.test.ts | 65 ++++++++++++++ src/browser/history/HarCapture.ts | 11 ++- src/browser/history/HarSchema.ts | 84 ++++++++----------- .../browser/historicalWebCapture.test.ts | 39 +++++++++ 5 files changed, 156 insertions(+), 50 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 2d5bdcae1..6e29731fd 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -69,6 +69,13 @@ The observed mitmproxy 12.2.3 `SaveHar` profile can emit `postData.text: null` for a missing request body. REA preserves that reported null, omitting the optional field only from its validation copy for this exact producer profile; it does not invent an empty string or body bytes. +The observed Playwright 1.58.2 profile can report a bracketed IPv6 +`serverIPAddress`, forwarded from Chromium's `remoteIPAddress` through +[Playwright's HAR tracer](https://github.com/microsoft/playwright/blob/v1.58.2/packages/playwright-core/src/server/har/harTracer.ts). +REA preserves the original spelling in reported Evidence and removes brackets +only from its validation copy, only for this declared producer and a valid IPv6 +literal. Invalid addresses, bracketed IPv4, ports and zone identifiers remain +validation failures. Producer declarations do not authenticate origin. Native mitmproxy decoding requires a caller-supplied **mitmdump 12.2.3 on Linux**. Set its absolute executable path: diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 60cf99e1b..c1e933eb3 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -480,6 +480,71 @@ it("preserves observed SaveHar null post-data without broadening arbitrary HAR p ).toThrow("HAR schema validation failed"); }); +it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])( + "retains Playwright 1.58.2 bracketed IPv6 spelling in reported Evidence: %s", + (address) => { + const fixture = historicalHar(); + const first = fixture.log.entries[0]; + if (first === undefined) throw new Error("fixture missing"); + const input = { + log: { + ...fixture.log, + creator: { name: "Playwright", version: "1.58.2" }, + entries: [ + { + ...first, + serverIPAddress: address, + _extension: { serverIPAddress: address }, + }, + structuredClone(first), + ], + }, + }; + const decoded = decodeHarCapture(JSON.stringify(input), []); + expect(decoded.records[0]).toMatchObject({ + reported: { + serverIPAddress: address, + _extension: { serverIPAddress: address }, + }, + }); + expect(decoded.records[0]?.limitations).toContain( + "This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + ); + expect( + decoded.records[1]?.limitations.some((limitation) => + limitation.includes("bracketed IPv6 serverIPAddress"), + ), + ).toBe(false); + }, +); + +it.each([ + { name: "other", version: "1.58.2", address: "[2001:db8::1]" }, + { name: "Playwright", version: "1.58.1", address: "[2001:db8::1]" }, + { name: "Playwright", version: "1.58.2", address: "[192.0.2.1]" }, + { name: "Playwright", version: "1.58.2", address: "[2001:db8::1]:443" }, + { name: "Playwright", version: "1.58.2", address: "[fe80::1%eth0]" }, +])( + "rejects unrecognized bracketed HAR address input: $name $version $address", + ({ name, version, address }) => { + const fixture = historicalHar(); + const first = fixture.log.entries[0]; + if (first === undefined) throw new Error("fixture missing"); + expect(() => + decodeHarCapture( + JSON.stringify({ + log: { + ...fixture.log, + creator: { name, version }, + entries: [{ ...first, serverIPAddress: address }], + }, + }), + [], + ), + ).toThrow("HAR schema validation failed"); + }, +); + it.each([ "c2VjcmV0", "c2Vj", diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index d23083b6b..c0339bd80 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -25,14 +25,16 @@ type Projection = Pick< "reported" | "numeric_literals" | "binary_fields" | "redactions" >; -/** Decode a retained HAR with exact numeric lexemes and unchanged upstream schema validation. */ +/** Decode a retained HAR, validating a disposable projection of source facts. */ export const decodeHarCapture = ( text: string, sensitiveValues: readonly string[], ): DecodedCapture => { const raw = parseCaptureJson(text, "HAR"); const validation = projectHar(raw, [], false); - createHarValidator()(validation.reported); + const adaptedPlaywrightEntries = new Set( + createHarValidator()(validation.reported), + ); if ( !captureObject(raw) || !captureObject(raw.log) || @@ -73,6 +75,11 @@ export const decodeHarCapture = ( ...projection, limitations: [ "HAR text is producer-decoded Unicode. Original body bytes remain unknown unless content explicitly uses valid base64; declared sizes are preserved independently of retained bytes. mitmproxy 12.2.3 SaveHar may report null optional postData.text; that null is retained and omitted only from the upstream validation copy.", + ...(adaptedPlaywrightEntries.has(ordinal) + ? [ + "This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + ] + : []), ...(recordsExcluded ? [ "This record's payload and sidecars are excluded because its structural ancestor property was explicitly marked sensitive.", diff --git a/src/browser/history/HarSchema.ts b/src/browser/history/HarSchema.ts index add8c301e..cb4a55a57 100644 --- a/src/browser/history/HarSchema.ts +++ b/src/browser/history/HarSchema.ts @@ -1,10 +1,14 @@ import { createRequire } from "node:module"; +import { isIP } from "node:net"; import { Ajv } from "ajv"; import { z } from "zod"; import { CaptureFormatError } from "./CaptureFormatError.js"; +import { captureObject } from "./CaptureRedaction.js"; /** Validate using the unchanged, pinned upstream HAR 1.2 draft-06 schemas. */ -export const createHarValidator = (): ((value: unknown) => void) => { +export const createHarValidator = (): (( + value: unknown, +) => readonly number[]) => { const require = createRequire(import.meta.url); const schemas = z .record(z.string(), z.record(z.string(), z.unknown())) @@ -20,7 +24,8 @@ export const createHarValidator = (): ((value: unknown) => void) => { if (validate === undefined) throw new Error("Pinned HAR root schema is unavailable"); return (value) => { - allowMitmproxyAbsentPostData(value); + const adaptedPlaywrightEntries = + allowObservedProducerRepresentations(value); if (!validate(value)) { const error = validate.errors?.[0]; // AJV messages name the violated constraint, such as the missing @@ -33,59 +38,42 @@ export const createHarValidator = (): ((value: unknown) => void) => { error?.instancePath ?? "", ); } + return adaptedPlaywrightEntries; }; }; -/** Observed SaveHar 12.2.3 compatibility: validate omitted optional text while retaining reported null. */ -const allowMitmproxyAbsentPostData = (value: unknown): void => { - const parsed = z - .object({ - log: z.object({ - creator: z.object({ - name: z.literal("mitmproxy"), - version: z.literal("12.2.3"), - }), - entries: z.array(z.unknown()), - }), - }) - .safeParse(value); - if ( - !parsed.success || - typeof value !== "object" || - value === null || - !("log" in value) - ) - return; +/** Adapt only observed producer representations in the disposable validation copy. */ +const allowObservedProducerRepresentations = ( + value: unknown, +): readonly number[] => { + if (!captureObject(value) || !captureObject(value.log)) return []; const log = value.log; - if ( - typeof log !== "object" || - log === null || - !("entries" in log) || - !Array.isArray(log.entries) - ) - return; - for (const entry of log.entries) { + if (!captureObject(log.creator) || !Array.isArray(log.entries)) return []; + const mitmproxy = + log.creator.name === "mitmproxy" && log.creator.version === "12.2.3"; + const playwright = + log.creator.name === "Playwright" && log.creator.version === "1.58.2"; + if (!mitmproxy && !playwright) return []; + const adaptedPlaywrightEntries: number[] = []; + for (const [ordinal, entry] of log.entries.entries()) { const candidate: unknown = entry; + if (!captureObject(candidate)) continue; + const address = candidate.serverIPAddress; if ( - typeof candidate !== "object" || - candidate === null || - !("request" in candidate) - ) - continue; + playwright && + typeof address === "string" && + address.startsWith("[") && + address.endsWith("]") && + isIP(address.slice(1, -1)) === 6 + ) { + candidate.serverIPAddress = address.slice(1, -1); + adaptedPlaywrightEntries.push(ordinal); + } + if (!mitmproxy) continue; const request = candidate.request; - if ( - typeof request !== "object" || - request === null || - !("postData" in request) - ) - continue; + if (!captureObject(request)) continue; const body = request.postData; - if ( - typeof body === "object" && - body !== null && - "text" in body && - body.text === null - ) - delete body.text; + if (captureObject(body) && body.text === null) delete body.text; } + return adaptedPlaywrightEntries; }; diff --git a/tests/boundary/browser/historicalWebCapture.test.ts b/tests/boundary/browser/historicalWebCapture.test.ts index 815810a9e..ee0b9d14c 100644 --- a/tests/boundary/browser/historicalWebCapture.test.ts +++ b/tests/boundary/browser/historicalWebCapture.test.ts @@ -104,6 +104,45 @@ it.each([ }, ); +it("retains Playwright IPv6 spelling through public CLI and MCP workflows", async () => { + const root = await createTestTempDirectory("rea-har-ipv6-"); + const path = join(root, "capture.har"); + const original = JSON.stringify(historicalHar()) + .replace( + '"name":"rea-source-owned-fixture","version":"1"', + '"name":"Playwright","version":"1.58.2"', + ) + .replace( + '"startedDateTime":', + '"serverIPAddress":"[2001:db8::1]","startedDateTime":', + ); + await writeFile(path, original); + const { call } = await connectLocalToolsMcp(); + const fromCli = await cli(path, "har"); + expect(fromCli.ok, fromCli.stdout).toBe(true); + const cliEvidence = parseEvidence(JSON.parse(fromCli.stdout)); + const response = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const mcpEvidence = parseEvidence(response.structuredContent); + expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id); + expect(mcpEvidence.normalized_result).toEqual(cliEvidence.normalized_result); + const capture = webNetworkCaptureSchema.parse(cliEvidence.normalized_result); + expect(capture.records[0]).toMatchObject({ + reported: { + serverIPAddress: "[2001:db8::1]", + }, + }); + expect( + capture.records[0]?.limitations.some((limitation) => + limitation.includes("bracketed IPv6 serverIPAddress"), + ), + ).toBe(true); + expect(await readFile(path, "utf8")).toBe(original); +}); + it("excludes declared binary lengths through CLI, MCP and retained capture pages", async () => { const root = await createTestTempDirectory("rea-har-byte-length-"); const path = join(root, "capture.har");