diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index 1310b2d578..ada3ab10fd 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -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 diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 6e58f32367..19e7b68aa3 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -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 }) => { diff --git a/src/browser/history/HarCapture.ts b/src/browser/history/HarCapture.ts index 91c67f9efe..4353e9b6de 100644 --- a/src/browser/history/HarCapture.ts +++ b/src/browser/history/HarCapture.ts @@ -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.", diff --git a/src/browser/history/HarSchema.ts b/src/browser/history/HarSchema.ts index f50d91d392..43d5422a0d 100644 --- a/src/browser/history/HarSchema.ts +++ b/src/browser/history/HarSchema.ts @@ -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 | 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";