diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index 2268858b55..027bbcd3f0 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -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).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")); + }); +}); diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index dc6e4d0ea4..418688dd72 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -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 { diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index c6c157a014..ded36daff8 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -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).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( + "k".repeat(depth) + + "x" + + "".repeat(depth), + ); + +const nestedArrays = (depth: number) => + plist( + "".repeat(depth) + "x" + "".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("".repeat(128) + "" + "".repeat(128)), + ).value, + ).toBeDefined(); +}); + +it("does not count markup inside comments, CDATA, or the doctype as nesting", () => { + const text = ` +">]> + + a]]> + + bx +`; + expect(parseXmlPropertyList(text)).toEqual({ + value: { a: "", b: ["x"] }, + omittedPrototypeKeys: 0, + }); +}); + +it.each(['"', "'"])( + "counts containers with quoted XML delimiters using %s attributes", + (quote) => { + const opening = `${quote}>k`; + const text = plist( + opening.repeat(3000) + "x" + "".repeat(3000), + ); + expect(() => parseXmlPropertyList(text)).toThrow( + new RangeError("Plist exceeds 128 nesting levels"), + ); + expect( + parseXmlPropertyList( + plist( + "" + `${quote}/>`.repeat(130) + "", + ), + ).value, + ).toEqual(Array.from({ length: 130 }, () => ({}))); + }, +); + +it("ignores complete DOCTYPE internal subsets when checking value depth", () => { + const text = ` + +".repeat(130)}"> +]>x`; + expect(parseXmlPropertyList(text)).toEqual({ + value: "x", + omittedPrototypeKeys: 0, + }); +}); + +it("bounds nested plist wrappers before recursive decoding", () => { + const text = + "".repeat(3000) + "x" + "".repeat(3000); + expect(() => parseXmlPropertyList(text)).toThrow( + new RangeError("Plist exceeds 128 nesting levels"), + ); + expect( + parseXmlPropertyList(plist("x")).value, + ).toEqual(["x"]); +}); + it.each(["\n", "\r\n", "\r", "\u0085", "\u2028", "\u2029"])( "preserves unrelated source values around %j line endings", (newline) => { diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 778af9295e..35aa0889d9 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -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__"; diff --git a/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts new file mode 100644 index 0000000000..6e37acd239 --- /dev/null +++ b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts @@ -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("", ''), + ], + [ + "plist wrappers", + "".repeat(3000) + "x" + "".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( + /]*>/u, + `".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" }), + ]), + }); + }, +); diff --git a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts new file mode 100644 index 0000000000..e2dbe084dc --- /dev/null +++ b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts @@ -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", + }); + }, +); diff --git a/tests/fixtures/keyedArchiveXmlNesting.ts b/tests/fixtures/keyedArchiveXmlNesting.ts new file mode 100644 index 0000000000..77a1b3bc29 --- /dev/null +++ b/tests/fixtures/keyedArchiveXmlNesting.ts @@ -0,0 +1,9 @@ +/** A well-formed XML plist of N nested dictionaries, each with one key. */ +export const deeplyNestedXmlPlist = (depth: number): string => + '\n' + + '\n' + + '\n' + + "k".repeat(depth) + + "x" + + "".repeat(depth) + + "\n\n";