mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
|
||||
@@ -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]) =>
|
||||
|
||||
Reference in New Issue
Block a user