fix(captures): validate SAZ XML and classify expansion limits

This commit is contained in:
morluto 2026-10-14 09:47:56 +08:00
parent 88afcc738b
commit 29a2d13994
9 changed files with 126 additions and 11 deletions

View File

@ -2,7 +2,7 @@ REA npm dependency inventory
Prepared from the root package-lock.json; refresh this inventory when locked dependencies change.
License identifiers are upstream lockfile declarations, not a legal assessment.
Runtime includes transitive and optional platform packages; development includes build, test and website tooling.
Locked package entries: 719; runtime: 175.
Locked package entries: 721; runtime: 177.
Full runtime copyright/license/NOTICE texts: npm-runtime-notices.txt.
@algolia/abtesting@1.25.0 | development | MIT
@ -3000,6 +3000,11 @@ safer-buffer@2.1.2 | development | MIT
Package: https://www.npmjs.com/package/safer-buffer/v/2.1.2
Archive: https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz
Integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
saxes@6.0.0 | runtime | ISC
Lockfile path: node_modules/saxes
Package: https://www.npmjs.com/package/saxes/v/6.0.0
Archive: https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz
Integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==
search-insights@2.17.3 | development | MIT
Lockfile path: node_modules/search-insights
Package: https://www.npmjs.com/package/search-insights/v/2.17.3
@ -3580,6 +3585,11 @@ xmlbuilder@15.1.1 | runtime | MIT
Package: https://www.npmjs.com/package/xmlbuilder/v/15.1.1
Archive: https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz
Integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==
xmlchars@2.2.0 | runtime | MIT
Lockfile path: node_modules/xmlchars
Package: https://www.npmjs.com/package/xmlchars/v/2.2.0
Archive: https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz
Integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
yaml@2.9.0 | runtime | ISC
Lockfile path: node_modules/yaml
Package: https://www.npmjs.com/package/yaml/v/2.9.0

19
package-lock.json generated
View File

@ -38,6 +38,7 @@
"pino": "10.4.0",
"playwright-core": "1.58.2",
"plist": "5.0.0",
"saxes": "6.0.0",
"semver": "7.8.5",
"smol-toml": "1.9.0",
"stream-chain": "4.2.6",
@ -8672,6 +8673,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/search-insights": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz",
@ -10585,6 +10598,12 @@
"node": ">=8.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",

View File

@ -341,6 +341,7 @@
"pino": "10.4.0",
"playwright-core": "1.58.2",
"plist": "5.0.0",
"saxes": "6.0.0",
"semver": "7.8.5",
"smol-toml": "1.9.0",
"stream-chain": "4.2.6",

View File

@ -1,7 +1,12 @@
/** Expected malformed, unsupported or oversized producer data at a historical decoder boundary. */
export class CaptureFormatError extends Error {
constructor(
readonly reason: "format" | "unsupported" | "input-limit" | "limit",
readonly reason:
| "format"
| "unsupported"
| "input-limit"
| "expanded-limit"
| "limit",
message: string,
readonly pointer = "",
) {

View File

@ -51,6 +51,7 @@ const replySchema = z.discriminatedUnion("ok", [
"format",
"unsupported",
"input-limit",
"expanded-limit",
"resource-limit",
"decoder",
"limit",
@ -215,6 +216,18 @@ export class HistoricalCaptureDecoder {
},
},
]);
if (reply.reason === "expanded-limit")
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["capture_path", reply.pointer],
reason: "out_of_range",
message: reply.message,
expected: {
maximum_expanded_capture_bytes:
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
},
},
]);
if (reply.reason === "resource-limit")
throw captureMemoryFailure(input, reply.message);
if (reply.reason === "decoder")

View File

@ -52,7 +52,7 @@ export const decodeSazCapture = async (
}
if (declaredBytes > WEB_NETWORK_CAPTURE_LIMITS.inputBytes)
throw new CaptureFormatError(
"limit",
"expanded-limit",
"SAZ expanded members exceed the 32 MiB complete-evidence budget.",
);
inventory.push({
@ -161,7 +161,7 @@ export const decodeSazCapture = async (
if (cause instanceof CaptureFormatError) throw cause;
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
throw new CaptureFormatError(
"limit",
"expanded-limit",
"SAZ archive exceeds the ZIP reader's resource budget.",
);
throw new CaptureFormatError(

View File

@ -1,4 +1,5 @@
import { expect, it } from "vitest";
import { CaptureFormatError } from "./CaptureFormatError.js";
import { decodeSazMetadata } from "./SazMetadata.js";
it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => {
@ -14,6 +15,9 @@ it("retains U+FFFD in well-formed metadata as an ordinary XML character", () =>
});
it.each([
"<Session>a & b</Session>",
"<Session>a\u0000b</Session>",
"<Session>&#xD800;</Session>",
"<Session a=1><3E></Session>",
'<Session a="x"b="<22>"/>',
"<Session><Broken><3E></Session>",
@ -22,3 +26,24 @@ it.each([
"SAZ metadata is not well-formed XML.",
);
});
it("preserves valid comments, CDATA, declarations, and predefined entities", () => {
const xml =
'<?xml version="1.0"?><Session><!-- note & literal --><Value>A &amp; B</Value><C><![CDATA[C & D]]></C></Session>';
const decoded = JSON.stringify(decodeSazMetadata(Buffer.from(xml)));
expect(decoded).toContain("note & literal");
expect(decoded).toContain("A & B");
expect(decoded).toContain("C & D");
});
it("rejects DTDs without resolving entities", () => {
expect(() =>
decodeSazMetadata(
Buffer.from('<!DOCTYPE Session [<!ENTITY value "external">]><Session/>'),
),
).toThrow(
expect.objectContaining({
reason: "unsupported",
} satisfies Partial<CaptureFormatError>),
);
});

View File

@ -1,4 +1,5 @@
import { DOMParser, ParseError, type Node } from "@xmldom/xmldom";
import { SaxesParser } from "saxes";
import type { JsonValue } from "../../domain/jsonValue.js";
import { WEB_NETWORK_CAPTURE_LIMITS } from "../../domain/webNetworkCapture.js";
import { CaptureFormatError } from "./CaptureFormatError.js";
@ -20,6 +21,7 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
"SAZ metadata must use UTF-8 or BOM-marked UTF-16.",
);
}
validateSazXml(xml);
let parseFailed = false;
let document: ReturnType<DOMParser["parseFromString"]>;
try {
@ -44,13 +46,6 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
"SAZ metadata is not well-formed XML.",
);
}
// The pinned parser does not resolve external or DTD-defined entities.
// Inspect its parsed node; text inside comments/CDATA is ordinary evidence.
if (document.doctype !== null)
throw new CaptureFormatError(
"unsupported",
"SAZ metadata DTDs are unsupported.",
);
if (parseFailed)
throw new CaptureFormatError(
"format",
@ -89,3 +84,33 @@ export const decodeSazMetadata = (bytes: Buffer): JsonValue => {
};
return visit(document, 0);
};
/** Validate well-formedness before the DOM parser projects retained evidence. */
const validateSazXml = (xml: string): void => {
const parser = new SaxesParser({
defaultXMLVersion: "1.0",
forceXMLVersion: true,
});
let hasDoctype = false;
parser.on("doctype", () => {
hasDoctype = true;
});
try {
parser.write(xml).close();
} catch {
if (hasDoctype)
throw new CaptureFormatError(
"unsupported",
"SAZ metadata DTDs are unsupported.",
);
throw new CaptureFormatError(
"format",
"SAZ metadata is not well-formed XML.",
);
}
if (hasDoctype)
throw new CaptureFormatError(
"unsupported",
"SAZ metadata DTDs are unsupported.",
);
};

View File

@ -365,6 +365,7 @@ it("cancels after snapshot acquisition before launch and removes private input d
it.each([
{ reason: "input-limit", tag: "AnalysisInputError" },
{ reason: "expanded-limit", tag: "AnalysisInputError" },
{ reason: "resource-limit", tag: "AnalysisResourceConstraintError" },
{ reason: "decoder", tag: "ProviderAdapterError" },
{ reason: "limit", tag: "AnalysisOutputError" },
@ -425,6 +426,22 @@ it.each([
],
},
});
} else if (reason === "expanded-limit") {
expect(projected).toMatchObject({
category: "invalid_input",
details: {
issues: [
{
path: ["capture_path", "/extension/child"],
reason: "out_of_range",
expected: {
maximum_expanded_capture_bytes:
WEB_NETWORK_CAPTURE_LIMITS.inputBytes,
},
},
],
},
});
} else if (tag === "ProviderAdapterError") {
expect(projected).toMatchObject({
details: {