diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index e1e58275bc..95df9b8e88 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -146,6 +146,14 @@ Automatic binary Protobuf admission validates reserved MIME parameters: absent. Illegal profiles retain the source body, add a limitation, and leave protobuf coverage partial. Ordinary non-encoding parameters remain accepted; an explicit framing choice interprets bytes independently of the MIME profile. +Admission follows HTTP token and quoted-string syntax: unterminated quotes and +conflicting repeated reserved parameters remain invalid. Repeated headers with +implicit binary encoding and `encoding=binary` agree without rewriting either +header. Invalid declarations remain visible even with explicit framing, and +declared gRPC bodies still receive credential checks before a MIME refusal. +An excluded HAR `mimeType` remains unknown for automatic interpretation even +when a visible Content-Type header exists. That header can still identify the +transport for credential checks; explicit wire framing remains independent. Native gRPC admits only data-frame flags 0/1; gRPC-web additionally admits its terminal trailer frame. `grpc-web-text` supports independently padded base64 diff --git a/src/browser/history/protobuf/ProtobufCapture.test.ts b/src/browser/history/protobuf/ProtobufCapture.test.ts index 29fc6f5c37..4b48d29747 100644 --- a/src/browser/history/protobuf/ProtobufCapture.test.ts +++ b/src/browser/history/protobuf/ProtobufCapture.test.ts @@ -56,11 +56,15 @@ const captureWithBody = ( ], }); -const decode = async (capture: WebNetworkCapture) => { +const decode = async ( + capture: WebNetworkCapture, + framing: "auto" | "raw" = "auto", +) => { const input = inspectWebNetworkCaptureInputSchema.parse({ capture_path: capture.artifact.path, format: capture.format, record_ordinals: [0], + protobuf: { framing }, }); const resources = new ArtifactResourceScope(); try { @@ -128,6 +132,138 @@ it("keeps capture coverage partial for a malformed gRPC-web trailer", async () = expect(report.records[0]?.binary_fields[2]).toEqual(retainedBody); }); +it.each([false, true])( + "admits equivalent default binary profiles in repeated byte headers (reverse=%s)", + async (reverse) => { + const types = [ + "Application/Protobuf;type=left", + 'application/protobuf;ENCODING="binary";type="right;=value"', + ]; + if (reverse) types.reverse(); + const report = captureWithBody(types[0]!, Buffer.from([8, 1])); + const record = report.records[0]!; + record.reported = { + response: { + headers: [ + [null, null], + [null, null], + ], + content: null, + }, + }; + record.binary_fields.push( + binary("/response/headers/1/0", Buffer.from("Content-Type")), + binary("/response/headers/1/1", Buffer.from(types[1]!)), + ); + const retained = structuredClone(record.binary_fields); + await decode(report); + expect(report.protobuf_coverage?.status).toBe("complete"); + expect(record.protobuf?.[0]).toMatchObject({ + content_type: types[0], + framing: "raw", + fields: [{ number: 1, value: "1" }], + }); + expect(record.binary_fields).toEqual(retained); + }, +); + +it.each([false, true])( + "keeps incompatible repeated MIME headers unknown (reverse=%s)", + async (reverse) => { + const types = [ + "application/protobuf", + "application/protobuf;encoding=json", + ]; + if (reverse) types.reverse(); + const report = captureWithBody(types[0]!, Buffer.from([8, 1])); + const record = report.records[0]!; + record.reported = { + response: { + headers: [ + [null, null], + [null, null], + ], + content: null, + }, + }; + record.binary_fields.push( + binary("/response/headers/1/0", Buffer.from("Content-Type")), + binary("/response/headers/1/1", Buffer.from(types[1]!)), + ); + const retained = structuredClone(record.binary_fields); + await decode(report); + expect(report.protobuf_coverage?.status).toBe("partial"); + expect(record.protobuf).toBeUndefined(); + expect(record.limitations).toContainEqual( + expect.stringContaining( + "MIME metadata is excluded, conflicting or unavailable", + ), + ); + expect(record.binary_fields).toEqual(retained); + }, +); + +it.each(["auto", "raw"] as const)( + "preserves declared gRPC credential exclusions after MIME refusal with %s framing", + async (framing) => { + const invalid = 'application/grpc-web;note="unterminated'; + const valid = "application/grpc-web"; + for (const types of [ + [invalid], + [invalid, invalid], + [valid, invalid], + [invalid, valid], + ]) { + const report = captureWithBody( + types[0]!, + grpcWebTrailerFrame( + Buffer.from("authorization: Bearer fixture-only\r\n"), + ), + ); + const record = report.records[0]!; + if (types.length === 2) { + record.reported = { + response: { + headers: [ + [null, null], + [null, null], + ], + content: null, + }, + }; + record.binary_fields.push( + binary("/response/headers/1/0", Buffer.from("Content-Type")), + binary("/response/headers/1/1", Buffer.from(types[1]!)), + ); + } + const headers = structuredClone( + record.binary_fields.filter(({ pointer }) => + pointer.includes("/headers/"), + ), + ); + await decode(report, framing); + expect(report.protobuf_coverage?.status, types.join(" / ")).toBe( + "partial", + ); + expect(record.protobuf).toBeUndefined(); + expect(record.binary_fields[2]).toMatchObject({ + state: "redacted", + content_base64: null, + sha256: null, + }); + expect(record.redactions).toContainEqual({ + pointer: "/response/content", + reason: "transport-credential", + }); + expect( + record.binary_fields.filter(({ pointer }) => + pointer.includes("/headers/"), + ), + ).toEqual(headers); + } + }, +); + it.each([ "application/protobuf; encoding=json", "application/protobuf; encoding=unknown", diff --git a/src/browser/history/protobuf/ProtobufCapture.ts b/src/browser/history/protobuf/ProtobufCapture.ts index 6ed87c3380..877ebc8b81 100644 --- a/src/browser/history/protobuf/ProtobufCapture.ts +++ b/src/browser/history/protobuf/ProtobufCapture.ts @@ -1,4 +1,3 @@ -import { MIMEType } from "node:util"; import { isAbsolute } from "node:path"; import { CaptureFormatError } from "../CaptureFormatError.js"; import { @@ -24,6 +23,7 @@ import { type WebNetworkCaptureRecord, } from "../../../domain/webNetworkCapture.js"; import type { JsonValue } from "../../../domain/jsonValue.js"; +import { err, ok, type Result } from "../../../domain/result.js"; import { ProtobufBudget } from "./ProtobufWire.js"; import type { ProtobufSchema } from "./ProtobufSchema.js"; import { @@ -94,39 +94,91 @@ const excludeBody = ( record.redactions.push({ pointer, reason }); }; -/** Admit reserved binary-Protobuf parameters before selecting wire framing. */ -const protobufMediaType = (value: string | undefined) => { - if (value === undefined) - return { framing: undefined, issue: undefined, key: undefined }; - let mime: MIMEType; - try { - mime = new MIMEType(value); - } catch { - return { - framing: undefined, - issue: - "Selected body has an invalid MIME declaration; automatic protobuf applicability is unknown.", - key: undefined, - }; +type ProtobufMimeAdmission = Result< + { + readonly framing: ProtobufDecoding["framing"] | undefined; + readonly key: string | undefined; + }, + { + readonly _tag: "InvalidProtobufMime"; + readonly issue: string; + readonly declaredFraming: ProtobufDecoding["framing"] | undefined; } - const framing = Object.hasOwn(PROTOBUF_FRAMING, mime.essence) - ? PROTOBUF_FRAMING[mime.essence as keyof typeof PROTOBUF_FRAMING] - : undefined; - const encoding = mime.params.get("encoding"); - const charset = mime.params.get("charset"); - const version = mime.params.get("version"); - const invalid = +>; +const mediaType = + /^[\t ]*([!#$%&'*+.^_`|~0-9A-Za-z-]+)\/([!#$%&'*+.^_`|~0-9A-Za-z-]+)/; +// RFC 9110 token / quoted-string, including quoted-pair and obs-text. +const mimeParameter = + /([!#$%&'*+.^_`|~0-9A-Za-z-]+)=([!#$%&'*+.^_`|~0-9A-Za-z-]+|"(?:[\t \x21\x23-\x5b\x5d-\x7e\x80-\xff]|\\[\t \x21-\x7e\x80-\xff])*")/y; + +/** Strict admission keeps malformed/duplicate parameters visible instead of repairing them. */ +const protobufMediaType = ( + value: string | undefined, +): ProtobufMimeAdmission => { + if (value === undefined) return ok({ framing: undefined, key: undefined }); + const type = mediaType.exec(value); + const essence = + type === null + ? undefined + : `${type[1]!.toLowerCase()}/${type[2]!.toLowerCase()}`; + const framing = + essence !== undefined && Object.hasOwn(PROTOBUF_FRAMING, essence) + ? PROTOBUF_FRAMING[essence as keyof typeof PROTOBUF_FRAMING] + : undefined; + const invalid = (reason: string): ProtobufMimeAdmission => + err({ + _tag: "InvalidProtobufMime", + issue: `Selected body has an invalid MIME declaration (${reason}); automatic protobuf applicability is unknown.`, + declaredFraming: framing, + }); + if (type === null) return invalid("malformed media type"); + const parameters = new Map(); + let at = type[0].length; + while (at < value.length) { + while (value[at] === " " || value[at] === "\t") at++; + if (at === value.length) break; + if (value[at++] !== ";") return invalid("malformed media-type parameters"); + while (value[at] === " " || value[at] === "\t") at++; + // RFC 9110 permits empty parameter slots. + if (at === value.length || value[at] === ";") continue; + mimeParameter.lastIndex = at; + const match = mimeParameter.exec(value); + if (match === null) return invalid("malformed media-type parameters"); + at = mimeParameter.lastIndex; + const name = match[1]!.toLowerCase(); + if (!["encoding", "charset", "version"].includes(name)) continue; + const raw = match[2]!; + const decoded = raw.startsWith('"') + ? raw.slice(1, -1).replace(/\\([\s\S])/g, "$1") + : raw; + if (parameters.has(name) && parameters.get(name) !== decoded) + return invalid(`conflicting ${name} parameters`); + parameters.set(name, decoded); + } + const encoding = parameters.get("encoding"); + const charset = parameters.get("charset"); + const version = parameters.get("version"); + if ( framing === "raw" && - ((encoding !== null && encoding !== "binary") || - charset !== null || - version !== null); - return { - framing: invalid ? undefined : framing, - issue: invalid - ? "Selected binary Protobuf MIME profile has an illegal encoding, charset or reserved version parameter; retained bytes were not automatically interpreted. Select explicit framing to interpret the bytes independently of that declaration." - : undefined, - key: JSON.stringify([mime.essence, encoding, charset, version]), - }; + ((encoding !== undefined && encoding !== "binary") || + charset !== undefined || + version !== undefined) + ) + return err({ + _tag: "InvalidProtobufMime", + issue: + "Selected binary Protobuf MIME profile has an illegal encoding, charset or reserved version parameter; retained bytes were not automatically interpreted. Select explicit framing to interpret the bytes independently of that declaration.", + declaredFraming: framing, + }); + return ok({ + framing, + key: JSON.stringify([ + essence, + framing === "raw" ? (encoding ?? "binary") : (encoding ?? null), + charset ?? null, + version ?? null, + ]), + }); }; /** Format-aware owner of body metadata; callers never reinterpret producer JSON. */ @@ -155,6 +207,8 @@ const bodyMetadata = ( const message = object(object(record.reported)?.[side]); if (message === undefined) return undefined; const headers = new Map(); + let headerProfile = protobufMediaType(undefined); + let headerDeclaredFraming: ProtobufDecoding["framing"] | undefined; let headerNamesKnown = Array.isArray(message.headers); if (Array.isArray(message.headers)) { for (const [index, item] of message.headers.entries()) { @@ -180,17 +234,38 @@ const bodyMetadata = ( const key = name.toLowerCase(); const knownValue = typeof value === "string" ? value : undefined; const previous = headers.get(key); - // Repeated declarations agree only when known and equal; media types by essence. + if (key === "content-type") { + const nextProfile = protobufMediaType(knownValue); + const nextDeclaredFraming = nextProfile.ok + ? nextProfile.value.framing + : nextProfile.error.declaredFraming; + const agrees = + previous !== undefined && + knownValue !== undefined && + headerProfile.ok && + nextProfile.ok && + headerProfile.value.key === nextProfile.value.key; + const first = !headers.has(key); + headerDeclaredFraming = + first || headerDeclaredFraming === nextDeclaredFraming + ? nextDeclaredFraming + : undefined; + headers.set(key, first ? knownValue : agrees ? previous : undefined); + headerProfile = first + ? nextProfile + : agrees + ? headerProfile + : protobufMediaType(undefined); + continue; + } + // Other repeated declarations agree only when known and equal. headers.set( key, !headers.has(key) ? knownValue : previous !== undefined && knownValue !== undefined && - (key === "content-type" - ? protobufMediaType(previous).key === - protobufMediaType(knownValue).key - : previous === knownValue) + previous === knownValue ? previous : undefined, ); @@ -209,12 +284,19 @@ const bodyMetadata = ( const mimeType = object( message[side === "request" ? "postData" : "content"], )?.mimeType; - const contentType = typeof mimeType === "string" ? mimeType : header; + const contentType = typeof mimeType === "string" ? mimeType : undefined; const profile = protobufMediaType(contentType); + // An independent header still identifies transport sanitization, not the + // excluded producer MIME field's automatic interpretation. return { contentType, - automaticFraming: profile.framing, - mimeProfileIssue: profile.issue, + mimeProfile: profile, + declaredFraming: + contentType === undefined + ? headerDeclaredFraming + : profile.ok + ? profile.value.framing + : profile.error.declaredFraming, contentTypeKnown: contentType !== undefined, httpIdentityKnown: true, httpCodingKnown: true, @@ -222,11 +304,10 @@ const bodyMetadata = ( } if (pointer !== `/${side}/body` && pointer !== `/${side}/content`) return undefined; - const profile = protobufMediaType(header); return { contentType: header, - automaticFraming: profile.framing, - mimeProfileIssue: profile.issue, + mimeProfile: headerProfile, + declaredFraming: headerDeclaredFraming, contentTypeKnown: header !== undefined || (headerNamesKnown && !headers.has("content-type")), @@ -314,26 +395,25 @@ export const prepareCaptureProtobuf = async ( incomplete = true; continue; } - const { contentType, automaticFraming: automatic } = metadata; + const { contentType, mimeProfile, declaredFraming } = metadata; + const automatic = mimeProfile.ok ? mimeProfile.value.framing : undefined; const automaticSelected = options?.framing === undefined || options.framing === "auto"; - if (automaticSelected && metadata.mimeProfileIssue !== undefined) { + if (!mimeProfile.ok) { + if (automaticSelected) incomplete = true; + record.limitations.push(mimeProfile.error.issue); + } + if (automaticSelected && !metadata.contentTypeKnown && field.bytes > 0) { incomplete = true; - record.limitations.push(metadata.mimeProfileIssue); - continue; + record.limitations.push( + "Selected body's MIME metadata is excluded, conflicting or unavailable, so automatic protobuf framing applicability is unknown; its retained bytes were not interpreted. Select explicit framing to decode them.", + ); } const framing = options?.framing === undefined || options.framing === "auto" - ? automatic + ? (automatic ?? declaredFraming) : options.framing; if (framing === undefined) { - // An empty body has no protobuf content under any framing. - if (!metadata.contentTypeKnown && field.bytes > 0) { - incomplete = true; - record.limitations.push( - "Selected body's MIME metadata is excluded, conflicting or unavailable, so automatic protobuf framing applicability is unknown; its retained bytes were not interpreted. Select explicit framing to decode them.", - ); - } continue; } if ( @@ -374,9 +454,9 @@ export const prepareCaptureProtobuf = async ( // Caller overrides do not turn off exclusions for the transport declared by the source. const declared = prepared.excluded === undefined && - automatic !== undefined && - automatic !== framing - ? await prepareProtobufBody(body, automatic, exclusions, signal) + declaredFraming !== undefined && + declaredFraming !== framing + ? await prepareProtobufBody(body, declaredFraming, exclusions, signal) : prepared; const excluded = prepared.excluded ?? declared.excluded; if (excluded !== undefined) { @@ -407,6 +487,8 @@ export const prepareCaptureProtobuf = async ( ); continue; } + if (automaticSelected && (!mimeProfile.ok || !metadata.contentTypeKnown)) + continue; bodies.push({ field, body, contentType, framing, prepared }); } } diff --git a/tests/acceptance/tools/protobufMimeAdmission.test.ts b/tests/acceptance/tools/protobufMimeAdmission.test.ts new file mode 100644 index 0000000000..c9d2066314 --- /dev/null +++ b/tests/acceptance/tools/protobufMimeAdmission.test.ts @@ -0,0 +1,273 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import { parseEvidence } from "../../../src/domain/evidence.js"; +import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js"; +import { protobufFrame, protobufHar } from "../../fixtures/protobufCapture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const body = Buffer.from([8, 1]); +const bodySha = createHash("sha256").update(body).digest("hex"); +const hiddenMime = "application/json;type=excluded-declaration"; +const automaticProfiles = [ + ['application/protobuf;encoding="binary', "malformed media-type parameters"], + [ + "application/protobuf;encoding=binary;encoding=json", + "conflicting encoding parameters", + ], + ['Application/Protobuf;ENCODING="b\\inary";type="name;=value"', undefined], +] as const; + +cliTest( + "admits complete MIME facts without repairing malformed protobuf parameters through CLI/MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-protobuf-mime-admission-"); + const call = await cli.sharedMcpCall!(); + for (const [index, [mime, problem]] of automaticProfiles.entries()) { + const path = join(root, `${index}.har`); + const original = protobufHar(body, mime); + await writeFile(path, original); + const output = await cli.sharedCliRun!({ + arguments: [ + "inspect-web-network-capture", + path, + "har", + "--record", + "0", + "--json", + ], + }); + expect(output.exitCode, output.stdout + output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + const report = webNetworkCaptureSchema.parse(evidence.normalized_result); + expect(report.artifact).toMatchObject({ + path, + sha256: createHash("sha256").update(original).digest("hex"), + }); + expect(report.protobuf_coverage).toEqual({ + status: problem === undefined ? "complete" : "partial", + selected_records: 1, + inspected_records: 1, + }); + if (problem === undefined) + expect(report.records[0]?.protobuf?.[0]).toMatchObject({ + content_type: mime, + framing: "raw", + status: "complete", + fields: [{ number: 1, value: "1" }], + }); + else { + expect(report.records[0]?.protobuf).toBeUndefined(); + expect(report.records[0]?.limitations).toContainEqual( + expect.stringContaining(problem), + ); + } + expect(report.records[0]?.reported).toMatchObject({ + response: { + content: { mimeType: mime, text: body.toString("base64") }, + }, + }); + expect(report.records[0]?.binary_fields).toContainEqual( + expect.objectContaining({ + pointer: "/response/content/text", + state: "retained", + content_base64: body.toString("base64"), + bytes: 2, + sha256: bodySha, + }), + ); + const reply = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + record_ordinals: [0], + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect(parseEvidence(reply.structuredContent).evidence_id).toBe( + evidence.evidence_id, + ); + expect(await readFile(path, "utf8")).toBe(original); + } + }, +); + +cliTest( + "keeps an excluded HAR MIME unknown beside a visible contradictory header through CLI/MCP", + async ({ cli }) => { + const root = await createTestTempDirectory( + "rea-protobuf-har-mime-unknown-", + ); + const path = join(root, "capture.har"); + const original = protobufHar( + body, + hiddenMime, + [], + [{ name: "Content-Type", value: "application/protobuf" }], + ); + await writeFile(path, original); + const call = await cli.sharedMcpCall!(); + for (const framing of ["auto", "raw"] as const) { + const output = await cli.sharedCliRun!({ + arguments: [ + "inspect-web-network-capture", + path, + "har", + "--record", + "0", + "--protobuf-framing", + framing, + "--sensitive-value", + "excluded-declaration", + "--json", + ], + }); + expect(output.exitCode, output.stdout + output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + const report = webNetworkCaptureSchema.parse(evidence.normalized_result); + expect(report.records[0]?.reported).toMatchObject({ + response: { + headers: [{ name: "Content-Type", value: "application/protobuf" }], + content: { mimeType: null }, + }, + }); + expect(report.records[0]?.binary_fields).toContainEqual( + expect.objectContaining({ + state: "retained", + bytes: 2, + sha256: bodySha, + }), + ); + if (framing === "auto") { + expect(report.protobuf_coverage?.status).toBe("partial"); + expect(report.records[0]?.protobuf).toBeUndefined(); + expect(report.records[0]?.limitations).toContainEqual( + expect.stringContaining( + "MIME metadata is excluded, conflicting or unavailable", + ), + ); + } else { + expect(report.protobuf_coverage?.status).toBe("complete"); + expect(report.records[0]?.protobuf?.[0]).toMatchObject({ + content_type: null, + framing: "raw", + fields: [{ number: 1, value: "1" }], + }); + } + const reply = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + record_ordinals: [0], + sensitive_values: ["excluded-declaration"], + protobuf: { framing }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect(parseEvidence(reply.structuredContent).evidence_id).toBe( + evidence.evidence_id, + ); + } + expect(await readFile(path, "utf8")).toBe(original); + }, +); + +cliTest( + "sanitizes visible HAR gRPC transport while its authoritative MIME is excluded through MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-protobuf-har-transport-"); + const path = join(root, "capture.har"); + const trailer = protobufFrame( + Buffer.from("authorization: Bearer fixture-only\r\n"), + 128, + ); + const original = protobufHar( + trailer, + hiddenMime, + [], + [{ name: "Content-Type", value: "application/grpc-web" }], + ); + await writeFile(path, original); + const call = await cli.sharedMcpCall!(); + for (const framing of ["auto", "raw"] as const) { + const reply = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + record_ordinals: [0], + sensitive_values: ["excluded-declaration"], + protobuf: { framing }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + const report = webNetworkCaptureSchema.parse( + parseEvidence(reply.structuredContent).normalized_result, + ); + expect(report.protobuf_coverage?.status).toBe("partial"); + expect(report.records[0]?.protobuf).toBeUndefined(); + expect(report.records[0]?.reported).toMatchObject({ + response: { + headers: [{ name: "Content-Type", value: "application/grpc-web" }], + content: { mimeType: null, text: null }, + }, + }); + expect(report.records[0]?.binary_fields).toContainEqual( + expect.objectContaining({ + pointer: "/response/content/text", + state: "redacted", + content_base64: null, + sha256: null, + }), + ); + expect(report.records[0]?.redactions).toContainEqual({ + pointer: "/response/content/text", + reason: "transport-credential", + }); + } + expect(await readFile(path, "utf8")).toBe(original); + }, +); + +cliTest( + "retains caller-selected raw interpretation and the invalid recorded MIME through CLI/MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-protobuf-mime-explicit-"); + const path = join(root, "capture.har"); + const mime = 'application/protobuf;encoding="binary'; + const original = protobufHar(body, mime); + await writeFile(path, original); + const output = await cli.sharedCliRun!({ + arguments: [ + "inspect-web-network-capture", + path, + "har", + "--record", + "0", + "--protobuf-framing", + "raw", + "--json", + ], + }); + expect(output.exitCode, output.stdout + output.stderr).toBe(0); + const evidence = parseEvidence(output.json); + const report = webNetworkCaptureSchema.parse(evidence.normalized_result); + expect(report.protobuf_coverage?.status).toBe("complete"); + expect(report.records[0]?.protobuf?.[0]).toMatchObject({ + content_type: mime, + framing: "raw", + fields: [{ number: 1, value: "1" }], + }); + expect(report.records[0]?.limitations).toContainEqual( + expect.stringContaining("invalid MIME declaration"), + ); + const call = await cli.sharedMcpCall!(); + const reply = await call("inspect_web_network_capture", { + capture_path: path, + format: "har", + record_ordinals: [0], + protobuf: { framing: "raw" }, + }); + expect(reply.isError, JSON.stringify(reply)).not.toBe(true); + expect(parseEvidence(reply.structuredContent).evidence_id).toBe( + evidence.evidence_id, + ); + expect(await readFile(path, "utf8")).toBe(original); + }, +); diff --git a/tests/fixtures/protobufCapture.ts b/tests/fixtures/protobufCapture.ts index 183bacecd6..6893cedd93 100644 --- a/tests/fixtures/protobufCapture.ts +++ b/tests/fixtures/protobufCapture.ts @@ -42,6 +42,10 @@ export const protobufHar = ( body: Buffer = protobufCaptureBody(), mimeType = "application/grpc-web+proto", additionalBodies: readonly Buffer[] = [], + responseHeaders: readonly { + readonly name: string; + readonly value: string; + }[] = [], ) => JSON.stringify({ log: { @@ -65,7 +69,7 @@ export const protobufHar = ( statusText: "OK", httpVersion: "HTTP/1.1", cookies: [], - headers: [], + headers: responseHeaders, redirectURL: "", headersSize: -1, bodySize: body.length,