Skip to content

fix: bound object-graph expansion and detect reference cycles - #52

Merged
joeferner merged 1 commit into
joeferner:masterfrom
BrianWillows:fix/expansion-budget
Sep 10, 2026
Merged

joeferner merged 1 commit into
joeferner:masterfrom
BrianWillows:fix/expansion-budget

Conversation

@BrianWillows

Copy link
Copy Markdown

Summary

Binary plists address objects by index, so one stored object can be referenced
many times. maxObjectCount bounds how many objects are stored:

if (numObjects > maxObjectCount) {
  throw new Error("maxObjectCount exceeded");
}

…but nothing bounds how many are expanded. parseArray() / parseDictionary()
call parseObject(objRef) for each reference with no visited set and no budget, so
a tiny file can describe an enormous tree: if each level references the level below
twice, n objects expand to 2ⁿ nodes.

A 155-byte file (29 objects, 2²⁸ expansions) kills the process:

FATAL ERROR: Ineffective mark-compacts near heap limit -
JavaScript heap out of memory

That is not catchable — V8 aborts rather than throwing, so wrapping
parseFileSync() in try/catch does not save the caller. Measured on this
machine before the change:

file size expansions result
107 B 2¹⁶ 75 ms
123 B 2²⁰ 809 ms
139 B 2²⁴ 15.1 s
155 B 2²⁸ fatal OOM, core dumped

Separately, a 43-byte file whose object references itself recurses until
RangeError: Maximum call stack size exceeded.

Because the expansion is what blows up, maxObjectCount never fires — the PoC uses
only 29 objects, far under the 32768 limit.

Fix

Two additive guards inside parseObject:

  • maxExpandedObjectCount (default 1,000,000, with a setMaxExpandedObjectCount
    setter alongside the existing knobs) — caps total expansions, so the amplification
    case raises an ordinary Error.
  • a per-path cycle check — an object that transitively references itself now
    reports Circular reference detected at object #n instead of overflowing the stack.

Both raise catchable errors, so callers can handle a malicious file the same way they
already handle a malformed one.

Verification

  • Existing test suite passes: 11/11.
  • All 8 real plist fixtures in test/ parse identically — the change is additive
    and no valid document is affected.
  • The 155-byte bomb is now rejected in 311 ms with maxExpandedObjectCount exceeded
    (was: fatal OOM).
  • The 43-byte self-reference is rejected immediately with
    Circular reference detected at object #0 (was: stack overflow).

Notes

I picked the budget rather than memoising resolved objects deliberately: memoisation
would also collapse the DAG, but it makes repeated references return the same
instance, which is an observable change for anyone mutating the parsed result. The
budget changes nothing for valid input. Happy to switch to memoisation (or add it as
well, as a performance win) if you'd prefer.

The default of 1,000,000 is a guess at "comfortably above any real file" — say the
word if you'd like it higher, lower, or derived from maxObjectCount.

Found and fixed with AI assistance (Claude). Happy to add a regression test with the
two PoC files if useful.

Binary plists address objects by index, so one stored object can be
referenced many times. maxObjectCount bounds how many objects are
*stored*, not how many are *expanded*, so a tiny file can describe an
enormous tree: if each level references the level below twice, n objects
expand to 2^n nodes.

A 155-byte file built this way (29 objects, 2^28 expansions) exhausts the
heap and terminates the process with

    FATAL ERROR: Ineffective mark-compacts near heap limit -
    JavaScript heap out of memory

which a caller cannot catch, since V8 aborts rather than throwing. A
43-byte file whose object references itself recurses until the stack
overflows.

Add an expansion budget (maxExpandedObjectCount, default 1,000,000, with
a setter alongside the existing knobs) and a per-path cycle check, so both
cases raise an ordinary catchable Error instead of killing the process.

The change is additive: no valid document parses differently. All 8 real
plist fixtures in the test suite parse identically and the existing tests
pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@joeferner
joeferner merged commit 2d99e7e into joeferner:master Sep 10, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants