fix(captures): validate SAZ XML and classify expansion limits
This commit is contained in:
parent
88afcc738b
commit
29a2d13994
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>�</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 & 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>),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue