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: {