Merge pull request #885 from morluto/fix/plist-prototype-keys

fix(plist): read XML plists containing a __proto__ key
This commit is contained in:
Kaoru0822 authored and GitHub committed 2026-10-07 00:46:50 -07:00
commit faaee693d5
10 files changed
+278 -28

No files matched your search

+9 -14
View File
@@ -12,9 +12,10 @@ import { execFile } from "node:child_process";
import { extname, isAbsolute, join, resolve } from "node:path";
import { promisify } from "node:util";
import { parse as parsePlist } from "plist";
import { z } from "zod";
import { isPathWithinRoot } from "../domain/localPath.js";
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { err, ok, type Result } from "../domain/result.js";
@@ -268,21 +269,15 @@ const resolveAppBundle = async (
/** Decode the top-level executable name with an XML parser, not a pattern. */
const parseXmlPlistExecutable = (plist: string): string => {
const value = parsePlist(plist);
const executable =
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
!(value instanceof Date) &&
!(value instanceof Uint8Array) &&
Object.hasOwn(value, "CFBundleExecutable")
? value.CFBundleExecutable
: undefined;
if (typeof executable !== "string")
throw new Error("CFBundleExecutable is missing");
return executable;
// An unrelated `__proto__` entry must not make the bundle unreadable.
const { value } = parseXmlPropertyList(plist);
const executable = executableEntrySchema.safeParse(value);
if (!executable.success) throw new Error("CFBundleExecutable is missing");
return executable.data.CFBundleExecutable;
};
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
// `-n` strips only the newline plutil appends; it requires macOS 12+.
(
+3 -2
View File
@@ -1,7 +1,8 @@
import { createHash } from "node:crypto";
import { parse, parseBinary } from "plist";
import { parseBinary } from "plist";
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import {
decodeNibArchive,
type NibArchiveDocument,
@@ -373,6 +374,6 @@ const decodePlist = (bytes: Buffer): JsonValue => {
const parsed =
bytes.subarray(0, 8).toString("ascii") === "bplist00"
? parseBinary(bytes)
: parse(bytes.toString("utf8"));
: parseXmlPropertyList(bytes.toString("utf8")).value;
return jsonValueSchema.parse(parsed);
};
+14
View File
@@ -86,6 +86,20 @@ describe("inert keyed archive decoding", () => {
]);
},
);
it("reports an XML archive dictionary keyed __proto__ as omitted", () => {
const xml = build({ ...archive, $objects: ["$null", "value"] }).replace(
"<key>$top</key>",
"<key>__proto__</key><string>x</string><key>$top</key>",
);
const graph = decodeKeyedArchiveBytes(Buffer.from(xml), {
offset: 0,
limit: 2,
});
expect(graph.objects.map(({ value }) => value)).toEqual(["$null", "value"]);
expect(graph.limitations).toContain(
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
);
});
it("rejects missing roots, malformed plist, and non-keyed archives", () => {
expect(() =>
decodeKeyedArchiveBytes(Buffer.from("bplist00bad"), {
+16 -5
View File
@@ -1,9 +1,13 @@
import { createHash } from "node:crypto";
import { TextDecoder } from "node:util";
import { parse, parseBinary } from "plist";
import { parseBinary } from "plist";
import { z } from "zod";
import type { JsonValue } from "../domain/jsonValue.js";
import { projectPlistValue } from "../domain/plistValue.js";
import {
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../domain/propertyListKeys.js";
import {
keyedArchiveInputSchema,
keyedArchiveResultSchema,
@@ -22,12 +26,19 @@ export const decodeKeyedArchiveBytes = (
if (bytes.length > MAX_BYTES)
throw new RangeError("Keyed archive exceeds 64 MiB");
const binary = bytes.subarray(0, 8).toString("ascii") === "bplist00";
const parsed: unknown = binary
? parseBinary(bytes)
: parse(decodeXmlPlistText(bytes));
const parsed = binary
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
: parseXmlPropertyList(decodeXmlPlistText(bytes));
const graph = projectKeyedArchive(normalizePlist(parsed.value), selection);
return {
archive_format: binary ? ("binary-plist" as const) : ("xml-plist" as const),
...projectKeyedArchive(normalizePlist(parsed), selection),
...graph,
limitations: [
...graph.limitations,
...(parsed.omittedPrototypeKeys === 0
? []
: [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]),
],
};
};
+53
View File
@@ -0,0 +1,53 @@
import { expect, it } from "vitest";
import {
omitPrototypeKeys,
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "./propertyListKeys.js";
const plist = (body: string) =>
`<?xml version="1.0" encoding="UTF-8"?><plist version="1.0">${body}</plist>`;
it.each([
"<key>__proto__</key>",
"<key><![CDATA[__proto__]]></key>",
"<key>&#95;&#x5F;proto__</key>",
])("omits and counts a dictionary keyed %s", (key) => {
const parsed = parseXmlPropertyList(
plist(
`<dict><key>CFBundleExecutable</key><string>App</string>${key}<dict><key>polluted</key><true/></dict><key>list</key><array><dict>${key}<string>x</string><key>kept</key><integer>1</integer></dict></array></dict>`,
),
);
expect(parsed).toEqual({
value: { CFBundleExecutable: "App", list: [{ kept: 1 }] },
omittedPrototypeKeys: 2,
});
expect(Object.getPrototypeOf(parsed.value)).toBe(Object.prototype);
});
it("keeps __proto__ text in strings, comments, and similar keys", () => {
const text = plist(
"<dict><!-- <key>__proto__</key> --><key> __proto__ </key><string><![CDATA[<key>__proto__</key>]]></string><key>__rea_prototype_key__</key><string>__proto__</string></dict>",
);
expect(parseXmlPropertyList(text)).toEqual({
value: {
" __proto__ ": "<key>__proto__</key>",
__rea_prototype_key__: "__proto__",
},
omittedPrototypeKeys: 0,
});
});
it("omits own __proto__ entries that JSON decoding creates", () => {
const decoded: unknown = JSON.parse(
'{"__proto__":{"x":1},"a":[{"__proto__":2,"b":3}]}',
);
expect(omitPrototypeKeys(decoded)).toEqual({
value: { a: [{ b: 3 }] },
omittedPrototypeKeys: 2,
});
expect(omittedPrototypeKeysLimitation(1)).toBe(
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
);
});
+88
View File
@@ -0,0 +1,88 @@
import { parse } from "plist";
/** The dictionary key REA results cannot carry: validation strips it. */
const PROTOTYPE_KEY = "__proto__";
/** A decoded plist without `__proto__` entries, and how many were omitted. */
export interface PropertyListWithoutPrototypeKeys {
readonly value: unknown;
readonly omittedPrototypeKeys: number;
}
/** Limitation for results whose source plist held `__proto__` entries. */
export const omittedPrototypeKeysLimitation = (count: number): string =>
`${String(count)} dictionary ${count === 1 ? "entry" : "entries"} keyed __proto__ ${count === 1 ? "was" : "were"} omitted because REA results cannot represent that key.`;
const withoutKey = (
value: unknown,
key: string,
): PropertyListWithoutPrototypeKeys => {
let omitted = 0;
const strip = (item: unknown): unknown => {
if (Array.isArray(item)) return item.map(strip);
if (
item === null ||
typeof item !== "object" ||
item instanceof Date ||
item instanceof Uint8Array
)
return item;
return Object.fromEntries(
Object.entries(item).flatMap(([name, entry]) => {
if (name !== key) return [[name, strip(entry)]];
omitted += 1;
return [];
}),
);
};
const stripped = strip(value);
return { value: stripped, omittedPrototypeKeys: omitted };
};
/** Remove own `__proto__` entries, such as JSON.parse creates, and count them. */
export const omitPrototypeKeys = (
value: unknown,
): PropertyListWithoutPrototypeKeys => withoutKey(value, PROTOTYPE_KEY);
// Comments and CDATA text are consumed whole so only element keys match.
const KEY_TOKEN =
/<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<key>([^<]*)<\/key>|<key><!\[CDATA\[([\s\S]*?)\]\]><\/key>/gu;
const XML_ENTITIES: Readonly<Record<string, string>> = {
amp: "&",
apos: "'",
gt: ">",
lt: "<",
quot: '"',
};
const decodeXmlText = (text: string): string =>
text.replace(
/&(?:#x([\da-f]+)|#(\d+)|([a-z]+));/giu,
(entity, hex?: string, decimal?: string, name?: string) => {
if (hex !== undefined) return String.fromCodePoint(parseInt(hex, 16));
if (decimal !== undefined)
return String.fromCodePoint(parseInt(decimal, 10));
return (name === undefined ? undefined : XML_ENTITIES[name]) ?? entity;
},
);
/**
* Decode an XML plist whose dictionaries may use the legal key `__proto__`,
* which the `plist` decoder rejects outright. Those entries are omitted and
* counted so callers can report them instead of failing the whole document.
*/
export const parseXmlPropertyList = (
text: string,
): PropertyListWithoutPrototypeKeys => {
let placeholder = "__rea_prototype_key__";
while (text.includes(placeholder)) placeholder = `_${placeholder}`;
const substituted = text.replace(
KEY_TOKEN,
(token, plain?: string, cdata?: string) =>
(plain === undefined ? cdata : decodeXmlText(plain)) === PROTOTYPE_KEY
? `<key>${placeholder}</key>`
: token,
);
return withoutKey(parse(substituted), placeholder);
};
+2 -2
View File
@@ -8,7 +8,6 @@ import { observeNativeUi } from "./NativeUiObservation.js";
import { dirname, isAbsolute, resolve } from "node:path";
import { open, realpath, stat } from "node:fs/promises";
import { parse as parseXmlPlist } from "plist";
import { z } from "zod";
import {
@@ -35,6 +34,7 @@ import { BinaryTargetError } from "../domain/configurationErrors.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import {
demangleSwiftSchema,
inspectPlistSchema,
@@ -645,7 +645,7 @@ const parseEntitlements = (output: string): JsonValue | null => {
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start) return null;
return jsonValueSchema.parse(
parseXmlPlist(output.slice(start, end + "</plist>".length)),
parseXmlPropertyList(output.slice(start, end + "</plist>".length)).value,
);
};
+20 -5
View File
@@ -1,4 +1,3 @@
import { parse as parseXmlPlist } from "plist";
import { z } from "zod";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
@@ -7,6 +6,11 @@ import {
projectPlistValue,
type ProjectedPlistValue,
} from "../../domain/plistValue.js";
import {
omitPrototypeKeys,
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../../domain/propertyListKeys.js";
import { err, ok, type Result } from "../../domain/result.js";
const plistObject = z.record(z.string(), z.unknown());
@@ -99,9 +103,9 @@ export const parsePlistJson = (
ambiguousNumberCount += 1;
return item;
};
let value: unknown;
let decoded: ReturnType<typeof omitPrototypeKeys>;
try {
value = decodePlistJson(output, classify);
decoded = omitPrototypeKeys(decodePlistJson(output, classify));
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
@@ -111,10 +115,14 @@ export const parsePlistJson = (
),
);
}
const { value } = decoded;
return ok({
value,
bundle: projectPlistBundle(value),
limitations: numberLimitations(exactIntegerCount, ambiguousNumberCount),
limitations: [
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(decoded.omittedPrototypeKeys),
],
});
};
@@ -126,8 +134,11 @@ export const parsePlistXml = (
output: string,
): Result<ParsedPlist, AnalysisOutputError> => {
let projected: ProjectedPlistValue;
let omittedPrototypeKeys: number;
try {
projected = projectPlistValue(parseXmlPlist(output));
const decoded = parseXmlPropertyList(output);
omittedPrototypeKeys = decoded.omittedPrototypeKeys;
projected = projectPlistValue(decoded.value);
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
@@ -168,6 +179,7 @@ export const parsePlistXml = (
`${String(projected.unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the XML decoder does not distinguish NaN from infinity.`,
]),
...numberLimitations(exactIntegerCount, ambiguousNumberCount),
...prototypeKeyLimitations(omittedPrototypeKeys),
],
});
};
@@ -201,6 +213,9 @@ const xmlNumberLiterals = (xml: string): XmlNumberLiterals => {
return { integers, reals };
};
const prototypeKeyLimitations = (count: number): string[] =>
count === 0 ? [] : [omittedPrototypeKeysLimitation(count)];
const numberLimitations = (
exactIntegerCount: number,
ambiguousNumberCount: number,
@@ -0,0 +1,53 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect } from "vitest";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
const OMITTED =
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.";
describe.skipIf(process.platform !== "darwin")(
"native plist CLI with a __proto__ key",
() => {
cliTest.for([
["JSON-expressible", ""],
["data-bearing", "<key>Blob</key><data>AAEC</data>"],
] as const)(
"inspects a $0 plist and reports the omitted key",
async ([, extra], { cli }) => {
const directory = await createTestTempDirectory("rea-plist-proto-");
const app = join(directory, "Proto.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "MacOS/App"),
thinMach(0xfeedfacf, 0x100000c),
);
await writeFile(
join(contents, "Info.plist"),
`<plist><dict><key>CFBundleExecutable</key><string>App</string><key>__proto__</key><dict><key>polluted</key><true/></dict>${extra}</dict></plist>`,
);
const result = await cli.run({
arguments: ["inspect-plist", app, "--json"],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
value: { CFBundleExecutable: "App" },
bundle: { executable: "App" },
limitations: expect.arrayContaining([OMITTED]),
},
});
expect(JSON.stringify(result.json)).not.toContain("polluted");
},
);
},
);
@@ -243,6 +243,26 @@ describe("app executable filename fidelity", () => {
});
});
it.each([
"<key>__proto__</key><dict><key>CFBundleExecutable</key><string>Forged</string></dict>",
"<key>&#95;_proto__</key><string>x</string>",
])("opens an app whose XML plist also holds %s", async (entry) => {
const directory = await createTestTempDirectory("rea-app-xml-proto-");
const app = join(directory, "Proto.app");
const contents = join(app, "Contents");
const executable = join(contents, "MacOS", "App");
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "Info.plist"),
`<plist><dict>${entry}<key>CFBundleExecutable</key><string>App</string></dict></plist>`,
);
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
const result = await parseBinaryTarget(app, directory, "arm64");
expect(result.ok && result.value).toMatchObject({
path: await realpath(executable),
});
});
it.skipIf(process.platform !== "darwin")(
"preserves executable filename whitespace from native binary plists",
async () => {