fix(apple): report __proto__ entries omitted from OpenStep property lists (#4105)

* fix(apple): report __proto__ entries omitted from OpenStep property lists

The pinned OpenStep decoder assigns a `__proto__` dictionary key to the
object's prototype, so the entry vanished before REA could count it and
OpenStep keyed archives reported no omission, unlike the same archive in
XML. The OpenStep pre-scan, which already walks every key for the depth
limit, now counts keys that decode to `__proto__`; quoted keys with
escapes are decoded by the pinned decoder itself. The rebuilt value still
drops any prototype a key replaced.

Fixes #3892

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(apple): count OpenStep compound keys that coerce to __proto__

Review follow-up. A compound dictionary key such as `(__proto__)` is
coerced by the decoder's `obj[key]` assignment to the property name
__proto__, but the scan only checked scalar keys. Containers opened in
key position are now decoded by the pinned decoder on close and counted
when their property-name coercion is __proto__.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-15 06:12:15 -07:00
committed by GitHub
parent 736aa946bc
commit fe7bcc3fbe
2 changed files with 85 additions and 11 deletions
+34
View File
@@ -99,6 +99,40 @@ it("preserves OpenStep dictionary and array autodetection", () => {
});
});
it("counts OpenStep __proto__ keys the decoder silently drops", () => {
// Unquoted, quoted, escape-spelled, and object-valued keys all decode to
// __proto__; values, other keys, and `\137` (decoded as "137") do not.
const result = parseXmlPropertyList(
'{ __proto__ = a; "__proto__" = b; "\\_\\_proto__" = c; ' +
'nested = { "__proto__" = { polluted = yes; }; }; ' +
'kept = __proto__; list = (__proto__); "\\137_proto__" = d; }',
);
expect(result).toEqual({
value: {
nested: {},
kept: "__proto__",
list: ["__proto__"],
"137_proto__": "d",
},
omittedPrototypeKeys: 4,
});
const nested = (result.value as { nested: object }).nested;
expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
});
it("counts OpenStep compound keys that coerce to __proto__", () => {
// `obj[key]` coerces an array key to its comma-joined string.
expect(
parseXmlPropertyList(
'{ (__proto__) = a; (("__proto__")) = b; ("__pro", "to__") = c; ' +
"list = (__proto__); }",
),
).toEqual({
value: { "__pro,to__": "c", list: ["__proto__"] },
omittedPrototypeKeys: 2,
});
});
/** The single-member chain of a nested dictionary, down to its deepest value. */
const deepestValue = (value: unknown): unknown => {
let cursor = value;
+51 -11
View File
@@ -4,7 +4,7 @@ import {
type Document,
type Node,
} from "@xmldom/xmldom";
import { parse } from "plist";
import { parse, parseOpenStep } from "plist";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
@@ -95,8 +95,14 @@ export const parseXmlPropertyList = (
// by app Info.plist resolution. Only XML needs the key-element substitution.
const first = text.trimStart()[0];
if (first === "{" || first === "(") {
assertOpenStepDepthWithinLimit(text);
return omitPrototypeKeys(parse(text));
// The pinned decoder assigns a `__proto__` key to the object prototype, so
// the entry is gone before the decoded value can be counted. Count keys in
// the source instead; the rebuild still drops any prototype it replaced.
const prototypeKeys = scanOpenStep(text);
return {
value: omitPrototypeKeys(parse(text)).value,
omittedPrototypeKeys: prototypeKeys,
};
}
// Normalize only the literal line endings the pinned decoder normalizes.
// Preserve character references in the source: serializing a DOM would turn
@@ -117,6 +123,8 @@ export const parseXmlPropertyList = (
interface OpenStepContainer {
end: string;
next: "key" | "equals" | "value" | "separator" | "after-value";
/** Source offset of a container that is itself a dictionary key. */
keyStart?: number;
}
/** Consume only the punctuation expected between the pinned decoder's values. */
@@ -143,8 +151,12 @@ const admitsOpenStepCloser = (
parent.next !== "equals" &&
!(parent.end === "}" && parent.next === "value");
/** Bound OpenStep value nesting before the pinned decoder recursively reads it. */
const assertOpenStepDepthWithinLimit = (text: string): void => {
/**
* Bound OpenStep value nesting before the pinned decoder recursively reads it,
* and count the dictionary keys that decode to `__proto__`.
*/
const scanOpenStep = (text: string): number => {
let prototypeKeys = 0;
const containers: OpenStepContainer[] = [];
const completeValue = (): void => {
const parent = containers.at(-1);
@@ -174,16 +186,21 @@ const assertOpenStepDepthWithinLimit = (text: string): void => {
// Stop at the first impossible token: only the pinned decoder owns syntax
// diagnostics. A later deep suffix cannot change that failure into a limit.
if (character === "}" || character === ")") {
if (!admitsOpenStepCloser(parent, character)) return;
containers.pop();
if (!admitsOpenStepCloser(parent, character)) return prototypeKeys;
const closed = containers.pop();
if (
closed?.keyStart !== undefined &&
isOpenStepPrototypeKey(text.slice(closed.keyStart, cursor + 1))
)
prototypeKeys += 1;
// parseOpenStep returns the first root value without reading trailing text.
if (containers.length === 0) return;
if (containers.length === 0) return prototypeKeys;
completeValue();
cursor += 1;
continue;
}
const separator = consumeOpenStepSeparator(parent, character);
if (separator === "invalid") return;
if (separator === "invalid") return prototypeKeys;
if (separator === "consumed") {
cursor += 1;
continue;
@@ -193,16 +210,39 @@ const assertOpenStepDepthWithinLimit = (text: string): void => {
containers.push({
end: character === "{" ? "}" : ")",
next: character === "{" ? "key" : "value",
// A compound key is coerced to a string, e.g. `(__proto__)`.
...(parent?.next === "key" ? { keyStart: cursor } : {}),
});
cursor += 1;
continue;
}
const end = skipOpenStepScalar(text, cursor);
if (end === cursor) return;
if (parent?.next !== "key") assertDepth();
if (end === cursor) return prototypeKeys;
if (parent?.next === "key") {
if (isOpenStepPrototypeKey(text.slice(cursor, end))) prototypeKeys += 1;
} else assertDepth();
cursor = end;
completeValue();
}
return prototypeKeys;
};
/**
* Whether one OpenStep key, scalar or compound, becomes the property name
* `__proto__` when the decoder assigns `obj[key]`. Plain strings compare
* directly; any other key is decoded by the pinned decoder itself and
* coerced to a property name the same way that assignment does.
*/
const isOpenStepPrototypeKey = (token: string): boolean => {
if (!/^["({<]/u.test(token)) return token === PROTOTYPE_KEY;
if (token.startsWith('"') && !token.includes("\\"))
return token === `"${PROTOTYPE_KEY}"`;
try {
return String(parseOpenStep(token)) === PROTOTYPE_KEY;
} catch {
// An unterminated token is the decoder's syntax error to report.
return false;
}
};
/** Skip grammar trivia without interpreting delimiter examples inside comments. */