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:
1 parent
2b60e097f2
commit
1250aaedd8
6 files changed
+179
-13
No files matched your search
@@ -9,7 +9,8 @@ export class CaptureFormatError extends Error {
|
||||
| "limit",
|
||||
message: string,
|
||||
readonly pointer = "",
|
||||
cause?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
super(message, cause === undefined ? undefined : { cause });
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>),
|
||||
);
|
||||
});
|
||||
@@ -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, [
|
||||
|
||||
Reference in new issue
Block a user