From 29a2d13994708789fa3104437f1f2f93c6a34c36 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Wed, 14 Oct 2026 09:47:56 +0800
Subject: [PATCH] fix(captures): validate SAZ XML and classify expansion limits
---
licenses/npm-dependencies.txt | 12 +++++-
package-lock.json | 19 +++++++++
package.json | 1 +
src/browser/history/CaptureFormatError.ts | 7 +++-
.../history/HistoricalCaptureDecoder.ts | 13 +++++++
src/browser/history/SazCapture.ts | 4 +-
src/browser/history/SazMetadata.test.ts | 25 ++++++++++++
src/browser/history/SazMetadata.ts | 39 +++++++++++++++----
.../browser/historicalCaptureDecoder.test.ts | 17 ++++++++
9 files changed, 126 insertions(+), 11 deletions(-)
diff --git a/licenses/npm-dependencies.txt b/licenses/npm-dependencies.txt
index 794d5287e..5026b01ff 100644
--- a/licenses/npm-dependencies.txt
+++ b/licenses/npm-dependencies.txt
@@ -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
diff --git a/package-lock.json b/package-lock.json
index 48764a796..62d4e8f48 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 45e73afc0..c60c1fa99 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/browser/history/CaptureFormatError.ts b/src/browser/history/CaptureFormatError.ts
index a31b3057d..c3e4da9b3 100644
--- a/src/browser/history/CaptureFormatError.ts
+++ b/src/browser/history/CaptureFormatError.ts
@@ -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 = "",
) {
diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts
index 85a91ef76..992527b88 100644
--- a/src/browser/history/HistoricalCaptureDecoder.ts
+++ b/src/browser/history/HistoricalCaptureDecoder.ts
@@ -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")
diff --git a/src/browser/history/SazCapture.ts b/src/browser/history/SazCapture.ts
index 0d597eb5a..054e5c19b 100644
--- a/src/browser/history/SazCapture.ts
+++ b/src/browser/history/SazCapture.ts
@@ -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(
diff --git a/src/browser/history/SazMetadata.test.ts b/src/browser/history/SazMetadata.test.ts
index a5dbbf783..4aa482a7a 100644
--- a/src/browser/history/SazMetadata.test.ts
+++ b/src/browser/history/SazMetadata.test.ts
@@ -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([
+ "a & b",
+ "a\u0000b",
+ "",
"�",
'',
"�",
@@ -22,3 +26,24 @@ it.each([
"SAZ metadata is not well-formed XML.",
);
});
+
+it("preserves valid comments, CDATA, declarations, and predefined entities", () => {
+ const xml =
+ 'A & B';
+ 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(']>'),
+ ),
+ ).toThrow(
+ expect.objectContaining({
+ reason: "unsupported",
+ } satisfies Partial),
+ );
+});
diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts
index f9d1b6733..a5dd239bd 100644
--- a/src/browser/history/SazMetadata.ts
+++ b/src/browser/history/SazMetadata.ts
@@ -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;
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.",
+ );
+};
diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts
index e02524f8d..240c04d43 100644
--- a/tests/boundary/browser/historicalCaptureDecoder.test.ts
+++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts
@@ -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: {