fix(browser): decode SAZ XML through typed encoding validation

Reuse the format-aware XML text owner, preserve decoder causes, and reject contradictory declarations even in unselected records.

Refs: #3273
This commit is contained in:
morluto committed 2026-10-14 17:49:16 +08:00
1 parent 2b60e097f2
commit 1250aaedd8
6 files changed
+179 -13

No files matched your search

+2 -1
View File
@@ -9,7 +9,8 @@ export class CaptureFormatError extends Error {
| "limit",
message: string,
readonly pointer = "",
cause?: unknown,
) {
super(message);
super(message, cause === undefined ? undefined : { cause });
}
}
+44
View File
@@ -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('<?xml version="1.0" encoding="UTF-8"?><Session/>')],
[
"BOM-marked UTF-16LE",
Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from(
'<?xml version="1.0" encoding="UTF-16"?><Session/>',
"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('<?xml version="1.0" encoding="UTF-16"?><Session/>'),
"format",
],
[
"UTF-8 declaration on UTF-16 bytes",
Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from(
'<?xml version="1.0" encoding="UTF-8"?><Session/>',
"utf16le",
),
]),
"format",
],
[
"unsupported declared encoding",
Buffer.from('<?xml version="1.0" encoding="ISO-8859-1"?><Session/>'),
"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(
+28 -8
View File
@@ -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);
+25
View File
@@ -0,0 +1,25 @@
import { expect, it } from "vitest";
import {
decodeXmlPlistText,
XmlPlistTextDecodeError,
} from "./propertyListXmlText.js";
it.each([
[
Buffer.from('<?xml version="1.0" encoding="UTF-16"?><plist/>'),
"encoding-mismatch",
],
[
Buffer.from('<?xml version="1.0" encoding="ISO-8859-1"?><plist/>'),
"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<XmlPlistTextDecodeError>),
);
});
+35 -4
View File
@@ -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;
};
@@ -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(
'<?xml version="1.0" encoding="UTF-8"?><Session SID="0042"/>',
);
const utf16le = Buffer.concat([
Buffer.from([0xff, 0xfe]),
Buffer.from(
'<?xml version="1.0" encoding="UTF-16"?><Session SID="0042"/>',
"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(
'<?xml version="1.0" encoding="UTF-16"?><Session SID="0042"/>',
);
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, [