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
This commit is contained in:
amyanger
2026-10-21 21:08:42 +01:00
committed by GitHub
parent 414ad52b20
commit b0a554cc3a
4 changed files with 62 additions and 13 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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.",
]
+19 -12
View File
@@ -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);
@@ -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]) =>