mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
7 files changed
+360
-9
No files matched your search
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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";
|
||||
Reference in new issue
Block a user