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:
1 parent
56128a8424
commit
f14c2d4df3
5 files changed
+156
-50
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user