fix(apple): preflight XML plist nesting before keyed archive decoding (#2161)

* fix(apple): preflight XML plist nesting before keyed archive decoding

inspect_keyed_archive documented a 128-level nesting limit, but the XML
plist path reached recursive decoding before any depth check: plist and
the prototype-key traversal in parseXmlPropertyList recurse once per
nesting level, so a ~75 KiB, 3000-level XML plist exhausted the call
stack and reported Maximum call stack size exceeded instead of the
bounded-resource limit that shallower over-limit documents (129, 200)
already produced. The CLI classified both as truncated, so no crash or
classification bypass was involved; the diagnostic was the defect.

Reject excessive nesting in parseXmlPropertyList before any recursive
pass, using the projection's own limit (MAX_PLIST_DEPTH) so the error is
identical to the one projectPlistValue raises: callers keep the
limit/truncated classification with no error-handler translation. Depth
counts values the way the projection does - container members nest one
level deeper, dictionary keys name members rather than nesting, and a
self-closing element is the value at its own level - so the guard
rejects exactly the documents the projection rejected, at the same
boundary, and every in-limit document decodes unchanged. The guard sits
at the shared decoder boundary, so Interface Builder, app Info.plist
resolution and native inspect_plist gain the same deterministic limit;
the binary plist path keeps its existing expansion preflight.

Binary plist behavior, ordinary XML decode, __proto__ omission counting,
and archive digest/Evidence provenance are unchanged. OpenStep
autodetection is untouched by this guard, as the existing Interface
Builder preflight also scopes its depth check to XML.

Regression coverage: decoder unit tests at and just past the limit for
dictionary and array nesting with self-closing and opaque markup,
module tests through decodeKeyedArchiveBytes including a valid
NSKeyedArchiver XML envelope at the limit, a production-boundary test
through inspectBundleKeyedArchive, and the compiled public CLI
reporting code truncated / reason limit for a 3000-level plist.

* fix(apple): check XML nesting against parsed element structure

Reuse the iterative XML decoder document for depth preflight and prototype-key lookup. Bound recursive plist wrappers alongside dictionaries and arrays without interpreting markup inside attributes or DOCTYPE declarations as values.

Validated with 104 focused tests including compiled CLI, MCP prototype-key, and Interface Builder coverage; npm run check and verify:test-discovery passed. No real-provider verification was run.

---------

Co-authored-by: Zburgers <Zburgers@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 23:12:10 +08:00
1 parent c9aecde580
commit 7711b00005
7 files changed
+360 -9

No files matched your search

+65 -1
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { build, buildBinary } from "plist";
import { build, buildBinary, type PlistValue } from "plist";
import { decodeKeyedArchiveBytes } from "./KeyedArchiveReader.js";
import { deeplyNestedXmlPlist } from "../../../tests/fixtures/keyedArchiveXmlNesting.js";
const archive = {
$archiver: "NSKeyedArchiver",
@@ -323,3 +324,66 @@ describe("binary plist reference preflight", () => {
);
});
});
describe("XML plist nesting preflight", () => {
const selection = { offset: 0, limit: 1 };
/** A valid XML NSKeyedArchiver envelope whose object 1 nests `depth` dictionaries. */
const deeplyNestedArchive = (depth: number) => {
let payload: PlistValue = "leaf";
for (let level = 0; level < depth; level += 1) payload = { k: payload };
return Buffer.from(
build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", payload],
$top: { root: { UID: 1 } },
}),
);
};
/** Follow the single-member chain of a decoded object to its deepest value. */
const deepestValue = (value: unknown): unknown => {
let cursor = value;
while (
cursor !== null &&
typeof cursor === "object" &&
!Array.isArray(cursor) &&
"k" in cursor
) {
cursor = (cursor as Record<string, unknown>).k;
}
return cursor;
};
it.each([129, 200, 3000, 4000])(
"rejects %i-level XML nesting as the documented resource limit",
(depth) => {
// Recursive decoding exceeds the call stack before the projection limit
// can report it, so the decoder must reject the nesting up front with
// the same bounded-resource error an in-reach document would produce.
expect(() =>
decodeKeyedArchiveBytes(
Buffer.from(deeplyNestedXmlPlist(depth)),
selection,
),
).toThrow(new RangeError("Plist exceeds 128 nesting levels"));
},
);
it("decodes a valid XML keyed archive exactly at the nesting limit", () => {
const graph = decodeKeyedArchiveBytes(deeplyNestedArchive(126), {
root: "root",
offset: 1,
limit: 1,
});
expect(graph.objects).toHaveLength(1);
expect(deepestValue(graph.objects[0]?.value)).toBe("leaf");
});
it("rejects one nesting level past the limit before decoding", () => {
expect(() =>
decodeKeyedArchiveBytes(deeplyNestedArchive(127), selection),
).toThrow(new RangeError("Plist exceeds 128 nesting levels"));
});
});
+6 -1
View File
@@ -2,7 +2,12 @@ import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
const MAX_PLIST_DEPTH = 128;
/**
* Maximum plist value nesting the JSON projection supports. The XML decode
* preflight shares this limit so oversized documents are rejected before any
* recursive pass rather than during one.
*/
export const MAX_PLIST_DEPTH = 128;
/** A decoded plist projected into JSON, with values JSON cannot express. */
export interface ProjectedPlistValue {
+113
View File
@@ -98,6 +98,119 @@ it("preserves OpenStep dictionary and array autodetection", () => {
});
});
/** The single-member chain of a nested dictionary, down to its deepest value. */
const deepestValue = (value: unknown): unknown => {
let cursor = value;
while (
cursor !== null &&
typeof cursor === "object" &&
!Array.isArray(cursor) &&
"k" in cursor
) {
cursor = (cursor as Record<string, unknown>).k;
}
return cursor;
};
/** The single-member chain of a nested array, down to its deepest value. */
const deepestEntry = (value: unknown): unknown => {
let cursor = value;
while (Array.isArray(cursor) && cursor.length === 1) cursor = cursor[0];
return cursor;
};
const nestedDictionaries = (depth: number) =>
plist(
"<dict><key>k</key>".repeat(depth) +
"<string>x</string>" +
"</dict>".repeat(depth),
);
const nestedArrays = (depth: number) =>
plist(
"<array>".repeat(depth) + "<string>x</string>" + "</array>".repeat(depth),
);
it("rejects XML nesting beyond the projection limit with the limit diagnostic", () => {
// `plist` and the prototype-key traversal recurse once per level, so the
// depth check must report the same bounded-resource error as the projection
// instead of letting those recursive passes exhaust the call stack.
const limit = new RangeError("Plist exceeds 128 nesting levels");
expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit);
expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow(limit);
});
it("decodes XML property lists exactly at the projection nesting limit", () => {
expect(
deepestValue(parseXmlPropertyList(nestedDictionaries(128)).value),
).toBe("x");
expect(deepestEntry(parseXmlPropertyList(nestedArrays(128)).value)).toBe("x");
expect(
parseXmlPropertyList(
plist("<array>".repeat(128) + "<array/>" + "</array>".repeat(128)),
).value,
).toBeDefined();
});
it("does not count markup inside comments, CDATA, or the doctype as nesting", () => {
const text = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd" [<!ENTITY deep "<dict/>">]>
<plist version="1.0"><dict>
<key>a</key><string><![CDATA[<dict><array><dict>]]></string>
<!-- <dict><key>k</key> -->
<key>b</key><array><!-- <array> --><string>x</string></array>
</dict></plist>`;
expect(parseXmlPropertyList(text)).toEqual({
value: { a: "<dict><array><dict>", b: ["x"] },
omittedPrototypeKeys: 0,
});
});
it.each(['"', "'"])(
"counts containers with quoted XML delimiters using %s attributes",
(quote) => {
const opening = `<dict note=${quote}/>${quote}><key>k</key>`;
const text = plist(
opening.repeat(3000) + "<string>x</string>" + "</dict>".repeat(3000),
);
expect(() => parseXmlPropertyList(text)).toThrow(
new RangeError("Plist exceeds 128 nesting levels"),
);
expect(
parseXmlPropertyList(
plist(
"<array>" + `<dict note=${quote}>${quote}/>`.repeat(130) + "</array>",
),
).value,
).toEqual(Array.from({ length: 130 }, () => ({})));
},
);
it("ignores complete DOCTYPE internal subsets when checking value depth", () => {
const text = `<!DOCTYPE plist [
<!ENTITY first "unused">
<!-- <dict><array> -->
<!ENTITY second "${"<dict>".repeat(130)}">
]><plist><string>x</string></plist>`;
expect(parseXmlPropertyList(text)).toEqual({
value: "x",
omittedPrototypeKeys: 0,
});
});
it("bounds nested plist wrappers before recursive decoding", () => {
const text =
"<plist>".repeat(3000) + "<string>x</string>" + "</plist>".repeat(3000);
expect(() => parseXmlPropertyList(text)).toThrow(
new RangeError("Plist exceeds 128 nesting levels"),
);
expect(
parseXmlPropertyList(plist("<plist><string>x</string></plist>")).value,
).toEqual(["x"]);
});
it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])(
"preserves unrelated source values around %j line endings",
(newline) => {
+60 -7
View File
@@ -1,6 +1,13 @@
import { DOMParser, normalizeLineEndings } from "@xmldom/xmldom";
import {
DOMParser,
normalizeLineEndings,
type Document,
type Node,
} from "@xmldom/xmldom";
import { parse } from "plist";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
/** The dictionary key REA results cannot carry: validation strips it. */
const PROTOTYPE_KEY = "__proto__";
@@ -88,18 +95,64 @@ export const parseXmlPropertyList = (
// by app Info.plist resolution. Only XML needs the key-element substitution.
const first = text.trimStart()[0];
if (first === "{" || first === "(") return omitPrototypeKeys(parse(text));
// Normalize only the literal line endings the pinned decoder normalizes.
// Preserve character references in the source: serializing a DOM would turn
// e.g. an encoded carriage return into a literal newline on the second parse.
const normalized = normalizeLineEndings(text);
// The pinned XML parser is iterative. Reuse its document for the depth
// preflight and key lookup so quoted attributes and declarations have the
// same meaning at both boundaries, before plist's recursive value decode.
const document = new DOMParser().parseFromString(normalized, "text/xml");
assertXmlPlistDepthWithinLimit(document);
const { substituted, placeholder } = substitutePrototypeKeys(
normalizeLineEndings(text),
normalized,
document,
);
return withoutKey(parse(substituted), placeholder);
};
/** Elements whose children the pinned plist value decoder visits recursively. */
const PLIST_CONTAINER_ELEMENTS = new Set(["plist", "dict", "array"]);
/**
* Reject XML plists nested deeper than the projection supports before any
* recursive value pass runs. `plist` and the prototype-key traversal recurse once
* per nesting level, so a deeply nested document exhausts the call stack
* before the projection limit can report the resource bound. The failure
* matches `projectPlistValue` so callers classify it as a limit failure.
*
* Depth counts values the way the projection does: the root value is level 0
* and every container member is one level deeper. A dictionary key names a
* member instead of nesting one, and a self-closing element is the value at
* its own level. The outer plist envelope is unwrapped by the decoder;
* nested plist elements decode as arrays and must count toward the bound.
*/
const assertXmlPlistDepthWithinLimit = (document: Document): void => {
const root = document.documentElement;
if (root === null) return;
const pending: Array<{ node: Node; depth: number }> = [
{ node: root, depth: -1 },
];
while (pending.length > 0) {
const entry = pending.pop();
if (entry === undefined) break;
const { node, depth } = entry;
// Schedule only the next sibling, keeping traversal storage proportional
// to depth rather than the number of members in a wide container.
if (node.nextSibling !== null)
pending.push({ node: node.nextSibling, depth });
if (node.nodeType !== node.ELEMENT_NODE) continue;
if (node.nodeName !== "key" && depth > MAX_PLIST_DEPTH)
throw new RangeError(
`Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`,
);
if (PLIST_CONTAINER_ELEMENTS.has(node.nodeName) && node.firstChild !== null)
pending.push({ node: node.firstChild, depth: depth + 1 });
}
};
/** Locate keys with the same XML parser and first-child semantics as plist. */
const substitutePrototypeKeys = (text: string) => {
// Normalize only the literal line endings the pinned decoder normalizes.
// Preserve character references in the source: serializing a DOM would turn
// e.g. an encoded carriage return into a literal newline on the second parse.
const document = new DOMParser().parseFromString(text, "text/xml");
const substitutePrototypeKeys = (text: string, document: Document) => {
const keys = Array.from(document.getElementsByTagName("key"));
const decodedKeys = new Set(keys.map((key) => key.firstChild?.nodeValue));
let placeholder = "__rea_prototype_key__";
@@ -0,0 +1,78 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { build } from "plist";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js";
import { cliTest } from "../../support/cli/cliFixture.js";
for (const [name, xml] of [
["dictionaries", deeplyNestedXmlPlist(3000)],
[
"quoted attributes",
deeplyNestedXmlPlist(3000).replaceAll("<dict>", '<dict note="/>">'),
],
[
"plist wrappers",
"<plist>".repeat(3000) + "<string>x</string>" + "</plist>".repeat(3000),
],
] as const) {
cliTest(
`compiled CLI reports deeply nested XML ${name} as the decode limit`,
async ({ cli }) => {
const root = await createTestTempDirectory("rea-keyed-cli-depth-");
const path = join(root, "Deep.plist");
await writeFile(path, xml);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(1);
expect(output.json).toMatchObject({
code: "truncated",
category: "truncated",
details: {
reason: "limit",
truncated: true,
detail:
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
},
});
},
);
}
cliTest(
"compiled CLI decodes a shallow archive with opaque DOCTYPE markup",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-keyed-cli-doctype-");
const path = join(root, "Shallow.plist");
const archive = build({
$archiver: "NSKeyedArchiver",
$version: 100000,
$objects: ["$null", "leaf"],
$top: { root: { UID: 1 } },
});
await writeFile(
path,
archive.replace(
/<!DOCTYPE[^>]*>/u,
`<!DOCTYPE plist [<!ENTITY first "unused"><!ENTITY second "${"<dict>".repeat(130)}">]>`,
),
);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
});
expect(output.exitCode, output.stderr).toBe(0);
expect(parseEvidence(output.json).normalized_result).toMatchObject({
archive_format: "xml-plist",
objects: expect.arrayContaining([
expect.objectContaining({ value: "leaf" }),
]),
});
},
);
@@ -0,0 +1,29 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js";
it.each([129, 3000])(
"reports XML nesting of %i levels as the keyed archive decode limit",
async (depth) => {
const root = await createTestTempDirectory("rea-keyed-depth-");
await writeFile(join(root, "Deep.plist"), deeplyNestedXmlPlist(depth));
const rejected = await inspectBundleKeyedArchive({
bundlePath: root,
targetSha256: "a".repeat(64),
parameters: { path: "Deep.plist" },
platform: "linux",
}).catch((cause: unknown) => cause);
// Recursive decoding exhausts the call stack before the projection limit
// can report it, so the limit diagnostic must come from the preflight.
expect(rejected).toMatchObject({
reason: "limit",
message:
"Cannot decode selected Foundation keyed archive: Plist exceeds 128 nesting levels",
});
},
);
+9
View File
@@ -0,0 +1,9 @@
/** A well-formed XML plist of N nested dictionaries, each with one key. */
export const deeplyNestedXmlPlist = (depth: number): string =>
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' +
'<plist version="1.0">\n' +
"<dict><key>k</key>".repeat(depth) +
"<string>x</string>" +
"</dict>".repeat(depth) +
"\n</plist>\n";