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 <wunianze666@gmail.com>
This commit is contained in:
morlutoandwunianze666-netizen committed 2026-10-14 16:10:46 +08:00
1 parent 56128a8424
commit f14c2d4df3
5 files changed
+156 -50

No files matched your search

+7
View File
@@ -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:
+65
View File
@@ -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",
+9 -2
View File
@@ -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.",
+36 -48
View File
@@ -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;
};
@@ -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");