diff --git a/src/browser/history/CaptureFormatError.ts b/src/browser/history/CaptureFormatError.ts index c3e4da9b3e..0fdc026909 100644 --- a/src/browser/history/CaptureFormatError.ts +++ b/src/browser/history/CaptureFormatError.ts @@ -9,7 +9,8 @@ export class CaptureFormatError extends Error { | "limit", message: string, readonly pointer = "", + cause?: unknown, ) { - super(message); + super(message, cause === undefined ? undefined : { cause }); } } diff --git a/src/browser/history/SazMetadata.test.ts b/src/browser/history/SazMetadata.test.ts index 4aa482a7a4..54126937c1 100644 --- a/src/browser/history/SazMetadata.test.ts +++ b/src/browser/history/SazMetadata.test.ts @@ -2,6 +2,50 @@ import { expect, it } from "vitest"; import { CaptureFormatError } from "./CaptureFormatError.js"; import { decodeSazMetadata } from "./SazMetadata.js"; +it.each([ + ["UTF-8", Buffer.from('')], + [ + "BOM-marked UTF-16LE", + Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from( + '', + "utf16le", + ), + ]), + ], +] as const)("accepts matching %s XML declarations", (_label, bytes) => { + expect(() => decodeSazMetadata(bytes)).not.toThrow(); +}); + +it.each([ + [ + "UTF-16 declaration on UTF-8 bytes", + Buffer.from(''), + "format", + ], + [ + "UTF-8 declaration on UTF-16 bytes", + Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from( + '', + "utf16le", + ), + ]), + "format", + ], + [ + "unsupported declared encoding", + Buffer.from(''), + "unsupported", + ], +] as const)("rejects %s", (_label, bytes, reason) => { + expect(() => decodeSazMetadata(bytes)).toThrow( + expect.objectContaining({ reason }), + ); +}); + it("retains U+FFFD in well-formed metadata as an ordinary XML character", () => { const decoded = JSON.stringify( decodeSazMetadata( diff --git a/src/browser/history/SazMetadata.ts b/src/browser/history/SazMetadata.ts index a5dd239bd0..5ae8d950b7 100644 --- a/src/browser/history/SazMetadata.ts +++ b/src/browser/history/SazMetadata.ts @@ -2,23 +2,43 @@ 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 { + decodeXmlPlistText, + XmlPlistTextDecodeError, +} from "../../domain/propertyListXmlText.js"; import { CaptureFormatError } from "./CaptureFormatError.js"; /** Preserve XML node order, attribute names and text without interpreting producer-specific flags. */ export const decodeSazMetadata = (bytes: Buffer): JsonValue => { let xml: string; try { - const encoding = - bytes[0] === 0xff && bytes[1] === 0xfe - ? "utf-16le" - : bytes[0] === 0xfe && bytes[1] === 0xff - ? "utf-16be" - : "utf-8"; - xml = new TextDecoder(encoding, { fatal: true }).decode(bytes); - } catch { + xml = decodeXmlPlistText(bytes); + } catch (cause: unknown) { + if ( + cause instanceof XmlPlistTextDecodeError && + cause.reason === "unsupported-encoding" + ) + throw new CaptureFormatError( + "unsupported", + `SAZ metadata declares unsupported XML encoding ${cause.declaredEncoding}.`, + "", + cause, + ); + if ( + cause instanceof XmlPlistTextDecodeError && + cause.reason === "encoding-mismatch" + ) + throw new CaptureFormatError( + "format", + "SAZ metadata encoding declaration disagrees with its bytes.", + "", + cause, + ); throw new CaptureFormatError( "unsupported", "SAZ metadata must use UTF-8 or BOM-marked UTF-16.", + "", + cause, ); } validateSazXml(xml); diff --git a/src/domain/propertyListXmlText.test.ts b/src/domain/propertyListXmlText.test.ts new file mode 100644 index 0000000000..8cae86b390 --- /dev/null +++ b/src/domain/propertyListXmlText.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; + +import { + decodeXmlPlistText, + XmlPlistTextDecodeError, +} from "./propertyListXmlText.js"; + +it.each([ + [ + Buffer.from(''), + "encoding-mismatch", + ], + [ + Buffer.from(''), + "unsupported-encoding", + ], + [Buffer.from([0xff, 0xfe, 0x3c]), "invalid-bytes"], +] as const)("tags XML decoding failures as %s", (bytes, reason) => { + expect(() => decodeXmlPlistText(bytes)).toThrow( + expect.objectContaining({ + name: "XmlPlistTextDecodeError", + reason, + } satisfies Partial), + ); +}); diff --git a/src/domain/propertyListXmlText.ts b/src/domain/propertyListXmlText.ts index 2d6820120f..8cf52d772d 100644 --- a/src/domain/propertyListXmlText.ts +++ b/src/domain/propertyListXmlText.ts @@ -1,4 +1,19 @@ -/** Decode BOM-aware XML plist text with fatal byte validation. */ +export class XmlPlistTextDecodeError extends TypeError { + constructor( + readonly reason: + | "invalid-bytes" + | "unsupported-encoding" + | "encoding-mismatch", + message: string, + readonly declaredEncoding?: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "XmlPlistTextDecodeError"; + } +} + +/** Decode BOM-aware XML plist text and verify any declaration against its bytes. */ export const decodeXmlPlistText = (bytes: Uint8Array): string => { const encoding = bytes[0] === 0xff && bytes[1] === 0xfe @@ -6,7 +21,17 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => { : bytes[0] === 0xfe && bytes[1] === 0xff ? "utf-16be" : "utf-8"; - const text = new TextDecoder(encoding, { fatal: true }).decode(bytes); + let text: string; + try { + text = new TextDecoder(encoding, { fatal: true }).decode(bytes); + } catch (cause: unknown) { + throw new XmlPlistTextDecodeError( + "invalid-bytes", + "XML text bytes are invalid for the detected encoding", + undefined, + { cause }, + ); + } const declaration = /^<\?xml\s[^?]*\bencoding\s*=\s*["']([^"']+)["']/u.exec( text, )?.[1]; @@ -15,14 +40,20 @@ export const decodeXmlPlistText = (bytes: Uint8Array): string => { declared !== undefined && !["utf-8", "utf-16", "utf-16le", "utf-16be"].includes(declared) ) - throw new TypeError( + throw new XmlPlistTextDecodeError( + "unsupported-encoding", `Unsupported XML encoding declaration: ${declaration}. Supported encodings are UTF-8 and BOM-marked UTF-16 in either byte order.`, + declaration, ); if ( declared !== undefined && declared !== encoding && !(encoding !== "utf-8" && declared === "utf-16") ) - throw new TypeError("XML encoding declaration disagrees with its bytes"); + throw new XmlPlistTextDecodeError( + "encoding-mismatch", + "XML encoding declaration disagrees with its bytes", + declaration, + ); return text; }; diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index d6192e2e69..3e16fac4c3 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -342,6 +342,51 @@ it("keeps CRLF separators inside a body after LF-only HTTP headers", async () => expect(await readFile(path)).toEqual(bytes); }); +it("validates SAZ XML declarations against archive member bytes at the CLI boundary", async () => { + const withMetadata = (metadata: Buffer): [string, Buffer][] => + members().map(([name, bytes]) => + name === "raw/0042_m.xml" ? [name, metadata] : [name, bytes], + ); + const utf8 = Buffer.from( + '', + ); + const utf16le = Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from( + '', + "utf16le", + ), + ]); + for (const metadata of [utf8, utf16le]) { + const result = await cli( + await fixture(await archive(withMetadata(metadata))), + ); + expect( + webNetworkCaptureSchema.parse(result.normalized_result).records, + ).toHaveLength(2); + } + const mismatch = Buffer.from( + '', + ); + const rejected = await cliResponse( + await fixture(await archive(withMetadata(mismatch))), + ["--record", "1"], + ); + expect(rejected.ok).toBe(false); + expect(JSON.parse(rejected.stdout)).toMatchObject({ + category: "invalid_input", + details: { + issues: [ + expect.objectContaining({ + message: expect.stringContaining( + "SAZ metadata encoding declaration disagrees with its bytes", + ), + }), + ], + }, + }); +}); + it("excludes explicit literals from XML, HTTP byte views, member coordinates and binary sidecars", async () => { const path = await fixture(await archive()); const result = await cli(path, [