From fe7bcc3fbeafe73fed97f1d6d75f6ee3a0821d4a Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 06:12:15 -0700 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- src/domain/propertyListKeys.test.ts | 34 ++++++++++++++++ src/domain/propertyListKeys.ts | 62 ++++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index d95f648572..e98f8430c1 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -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; diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 2dcab952ce..47f65f35ac 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -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. */