From b0a554cc3a197c6192e19401330c20a42348cf90 Mon Sep 17 00:00:00 2001 From: amyanger <57688398+amyanger@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:08:42 +0100 Subject: [PATCH] fix(browser): read SAZ archives with zero-byte HTTP members (#5453) Fiddler writes a zero-byte raw/NN_s.txt (or _c.txt) for a session that never got that message. The decoder passed it to the HTTP parser, which found no header/body separator and rejected the whole archive, so no session could be read even with --record. Report a zero-byte request or response member as null, like a missing member, keep it in location.members, and add a per-record limitation that says only that the stored member is empty. Non-empty malformed members are still rejected. Fixes #5411 --- docs/web-network-captures.md | 2 + src/browser/history/NodeCaptureAdapter.ts | 2 +- src/browser/history/SazCapture.ts | 31 +++++++++------ tests/boundary/browser/sazWebCapture.test.ts | 40 ++++++++++++++++++++ 4 files changed, 62 insertions(+), 13 deletions(-) diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index e37ea9669d..8b81e3f5b2 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -120,6 +120,8 @@ HTTP fields and ordered duplicate headers are retained, with stored body bytes in binary sidecars. Bodies are not dechunked or decompressed. Metadata is an XML node tree; optional `_w.txt` and `_g.txt` files retain opaque WebSocket and gRPC message bytes. Missing members remain null, distinct from empty bodies. +A zero-byte `_c.txt` or `_s.txt` member, which Fiddler writes for a session +without that message, is also null and adds a record limitation. See Telerik's [SAZ format reference](https://www.telerik.com/fiddler/fiddler-everywhere/documentation/knowledge-base/fiddler-archives). ## Protobuf and gRPC bodies diff --git a/src/browser/history/NodeCaptureAdapter.ts b/src/browser/history/NodeCaptureAdapter.ts index 8f33430ca6..a366459a41 100644 --- a/src/browser/history/NodeCaptureAdapter.ts +++ b/src/browser/history/NodeCaptureAdapter.ts @@ -25,7 +25,7 @@ export class NodeCaptureAdapter implements HistoricalCaptureFormatAdapter { format === "saz" ? [ "SAZ ordinals follow first session-member occurrence in ZIP directory order, not chronology or numeric session ID.", - "HTTP fields use byte-preserving Latin-1 text; start-line separators and header delimiters are not projected. Bodies retain stored bytes without transfer/content decoding. Absent members are null, distinct from empty bodies.", + "HTTP fields use byte-preserving Latin-1 text; start-line separators and header delimiters are not projected. Bodies retain stored bytes without transfer/content decoding. Absent and zero-byte HTTP members are null, distinct from empty bodies.", "XML preserves node order and attribute/text values, not its original byte serialization. WebSocket and gRPC members retain opaque message-file bytes, not decoded protocol events.", "Non-session archive members are inventoried but their content is not decoded.", ] diff --git a/src/browser/history/SazCapture.ts b/src/browser/history/SazCapture.ts index 054e5c19b0..fd2ca02f26 100644 --- a/src/browser/history/SazCapture.ts +++ b/src/browser/history/SazCapture.ts @@ -90,6 +90,7 @@ export const decodeSazCapture = async ( websocket: null, grpc: null, }; + const limitations: string[] = []; for (const entry of entries) { const stream = await reader.open(entry); const chunks: Buffer[] = []; @@ -114,17 +115,23 @@ export const decodeSazCapture = async ( "SAZ member does not match its declared size.", ); const bytes = Buffer.concat(chunks, length); - if (entry.path.endsWith("_c.txt")) - source.request = { - member: entry.path, - ...decodeSazHttp(bytes, true), - }; - else if (entry.path.endsWith("_s.txt")) - source.response = { - member: entry.path, - ...decodeSazHttp(bytes, false), - }; - else if (entry.path.endsWith("_m.xml")) + const http = entry.path.endsWith("_c.txt") + ? "request" + : entry.path.endsWith("_s.txt") + ? "response" + : null; + if (http !== null) { + // Fiddler stores a session without this message as a zero-byte member. + if (length === 0) + limitations.push( + `The stored ${http} member is empty (zero bytes); the ${http} is reported as null.`, + ); + else + source[http] = { + member: entry.path, + ...decodeSazHttp(bytes, http === "request"), + }; + } else if (entry.path.endsWith("_m.xml")) source.metadata = { member: entry.path, document: decodeSazMetadata(bytes), @@ -142,7 +149,7 @@ export const decodeSazCapture = async ( members: entries.map((entry) => entry.path), }, ...projectSazRecord(source, sensitiveValues), - limitations: [], + limitations, }); } const container = projectSazRecord({ members: inventory }, sensitiveValues); diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index de1bec10ed..ef4081a4da 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -627,6 +627,46 @@ it("keeps CRLF separators inside a body after LF-only HTTP headers", async () => expect(await readFile(path)).toEqual(bytes); }); +it("reports zero-byte SAZ HTTP members as null instead of rejecting the archive", async () => { + const bytes = await archive([ + ["raw/1_c.txt", Buffer.from("GET https://example.test/ HTTP/1.1\r\n\r\n")], + ["raw/1_s.txt", Buffer.alloc(0)], + ["raw/2_c.txt", Buffer.alloc(0)], + ["raw/2_s.txt", Buffer.from("HTTP/1.1 204 No Content\r\n\r\n")], + ["raw/2_w.txt", Buffer.alloc(0)], + ]); + const path = await fixture(bytes); + const capture = webNetworkCaptureSchema.parse( + (await cli(path)).normalized_result, + ); + expect(capture.total_records).toBe(2); + expect(capture.records[0]?.reported).toMatchObject({ + request: { method: "GET" }, + response: null, + }); + expect(capture.records[1]?.reported).toMatchObject({ + request: null, + response: { status: "204" }, + websocket: { member: "raw/2_w.txt" }, + }); + expect(capture.records[0]?.location).toEqual({ + kind: "archive-members", + members: ["raw/1_c.txt", "raw/1_s.txt"], + }); + expect(capture.records[0]?.limitations).toEqual([ + "The stored response member is empty (zero bytes); the response is reported as null.", + ]); + expect(capture.records[1]?.limitations).toEqual([ + "The stored request member is empty (zero bytes); the request is reported as null.", + ]); + const selected = webNetworkCaptureSchema.parse( + (await cli(path, ["--record", "0"])).normalized_result, + ); + expect(selected.records).toMatchObject([ + { ordinal: 0, reported: { response: null } }, + ]); +}); + it("validates SAZ XML declarations against archive member bytes at the CLI boundary", async () => { const withMetadata = (metadata: Buffer): [string, Buffer][] => members().map(([name, bytes]) =>