fix(har): accept Chrome DevTools bracketed IPv6 server addresses (#7225)

Chrome DevTools HAR exports declare creator WebInspector 537.36 and
report IPv6 serverIPAddress values in brackets: its HAR writer strips
the port with lastIndexOf(':') but its bracket removal,
replace(/\[\]/g, ''), only matches the literal pair '[]'. The pinned
schema then rejects the whole archive at the first such entry.

Extend the existing Playwright 1.58.2 adaptation to this declared
producer. Brackets are removed only in the disposable validation copy
and only around a valid IPv6 literal; reported spellings are retained
and the per-entry limitation names the declared producer.
This commit is contained in:
Jeremy Levartovsky authored and GitHub committed 2026-11-03 14:46:10 +08:00
1 parent f251009e0a
commit 5ed283d168
4 files changed
+89 -24

No files matched your search

+6 -2
View File
@@ -97,9 +97,13 @@ 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).
Chromium DevTools exports (declared as `WebInspector` `537.36`, the frozen
AppleWebKit version in Chromium's user agent) report the same bracketed form:
[its HAR writer](https://github.com/ChromeDevTools/devtools-frontend/blob/63739e8ec45ca01b9e4be9b8b05946acdadd8f1f/front_end/models/har/Log.ts)
strips the port but not the brackets.
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
only from its validation copy, only for these declared producers 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
+34
View File
@@ -805,12 +805,46 @@ it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])(
},
);
it.each(["[2620:0:860:ed1a::1]", "[::1]"])(
"retains Chromium DevTools 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: "WebInspector", version: "537.36" },
entries: [
{ ...first, serverIPAddress: address },
structuredClone(first),
],
},
};
const decoded = decodeHarCapture(JSON.stringify(input), []);
expect(decoded.records[0]).toMatchObject({
reported: { serverIPAddress: address },
});
expect(decoded.records[0]?.limitations).toContain(
"This capture declares Chromium DevTools (WebInspector 537.36) 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]" },
{ name: "WebInspector", version: "605.1.15", address: "[2001:db8::1]" },
{ name: "WebInspector", version: "537.36", address: "[192.0.2.1]" },
{ name: "WebInspector", version: "537.36", address: "[2001:db8::1]:443" },
])(
"rejects unrecognized bracketed HAR address input: $name $version $address",
({ name, version, address }) => {
+9 -8
View File
@@ -33,9 +33,14 @@ export const decodeHarCapture = (
): DecodedCapture => {
const raw = parseCaptureJson(text, "HAR");
const validation = projectHar(raw, [], "validation");
const adaptedPlaywrightEntries = new Set(
createHarValidator()(validation.reported),
);
const ipv6Adaptation = createHarValidator()(validation.reported);
const adaptedIpv6Entries = new Set(ipv6Adaptation?.entries);
const ipv6Limitations =
ipv6Adaptation === null
? []
: [
`This capture declares ${ipv6Adaptation.producer} 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.`,
];
if (
!captureObject(raw) ||
!captureObject(raw.log) ||
@@ -76,11 +81,7 @@ export const decodeHarCapture = (
...projection,
limitations: [
"HAR text without an encoding declaration is producer-decoded Unicode. Unsupported declared body encodings remain opaque; 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.",
]
: []),
...(adaptedIpv6Entries.has(ordinal) ? ipv6Limitations : []),
...(recordsExcluded
? [
"This record's payload and sidecars are excluded because its structural ancestor property was explicitly marked sensitive.",
+40 -14
View File
@@ -30,10 +30,28 @@ const timingsSchema = {
},
};
// Observed producers that copy Chromium's bracketed IPv6 remote address into
// serverIPAddress. Chromium DevTools declares WebInspector with the frozen
// AppleWebKit version from its user agent.
const BRACKETED_IPV6_PRODUCERS = [
{ name: "Playwright", version: "1.58.2", label: "Playwright 1.58.2" },
{
name: "WebInspector",
version: "537.36",
label: "Chromium DevTools (WebInspector 537.36)",
},
] as const;
/** Entries whose bracketed IPv6 address was adapted, and the declared producer. */
interface BracketedIpv6Adaptation {
readonly producer: string;
readonly entries: readonly number[];
}
/** Validate with pinned HAR 1.2 draft-06 schemas and the owned schema corrections. */
export const createHarValidator = (): ((
value: unknown,
) => readonly number[]) => {
) => BracketedIpv6Adaptation | null) => {
const require = createRequire(import.meta.url);
const schemas = z
.record(z.string(), z.record(z.string(), z.unknown()))
@@ -78,8 +96,7 @@ export const createHarValidator = (): ((
if (validate === undefined)
throw new Error("Pinned HAR root schema is unavailable");
return (value) => {
const adaptedPlaywrightEntries =
allowObservedProducerRepresentations(value);
const adaptations = allowObservedProducerRepresentations(value);
if (!validate(value)) {
const error = validate.errors?.[0];
// AJV messages name the violated constraint, such as the missing
@@ -92,36 +109,43 @@ export const createHarValidator = (): ((
error?.instancePath ?? "",
);
}
return adaptedPlaywrightEntries;
return adaptations;
};
};
/** Name the declared producer when it is observed to bracket IPv6 addresses. */
const bracketedIpv6ProducerOf = (
creator: Record<string, unknown>,
): string | null =>
BRACKETED_IPV6_PRODUCERS.find(
({ name, version }) => creator.name === name && creator.version === version,
)?.label ?? null;
/** Adapt only observed producer representations in the disposable validation copy. */
const allowObservedProducerRepresentations = (
value: unknown,
): readonly number[] => {
if (!captureObject(value) || !captureObject(value.log)) return [];
): BracketedIpv6Adaptation | null => {
if (!captureObject(value) || !captureObject(value.log)) return null;
const log = value.log;
if (!captureObject(log.creator) || !Array.isArray(log.entries)) return [];
if (!captureObject(log.creator) || !Array.isArray(log.entries)) return null;
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[] = [];
const bracketedIpv6Producer = bracketedIpv6ProducerOf(log.creator);
if (!mitmproxy && bracketedIpv6Producer === null) return null;
const adaptedEntries: number[] = [];
for (const [ordinal, entry] of log.entries.entries()) {
const candidate: unknown = entry;
if (!captureObject(candidate)) continue;
const address = candidate.serverIPAddress;
if (
playwright &&
bracketedIpv6Producer !== null &&
typeof address === "string" &&
address.startsWith("[") &&
address.endsWith("]") &&
isIP(address.slice(1, -1)) === 6
) {
candidate.serverIPAddress = address.slice(1, -1);
adaptedPlaywrightEntries.push(ordinal);
adaptedEntries.push(ordinal);
}
if (!mitmproxy) continue;
const request = candidate.request;
@@ -129,7 +153,9 @@ const allowObservedProducerRepresentations = (
const body = request.postData;
if (captureObject(body) && body.text === null) delete body.text;
}
return adaptedPlaywrightEntries;
return bracketedIpv6Producer === null
? null
: { producer: bracketedIpv6Producer, entries: adaptedEntries };
};
const HAR_REQUEST_URL_FORMAT = "absolute-url";