Skip to content

security: cap canvas pixel count and cloneNode's total node output - #276

Merged
tannevaled merged 1 commit into
mainfrom
fix-canvas-size-and-clonenode-memory
Oct 17, 2026
Merged

tannevaled merged 1 commit into
mainfrom
fix-canvas-size-and-clonenode-memory

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

Closes round 171's own open lead: a 25-iteration root.appendChild(root.cloneNode(true)) loop measured at ~21GB of real (not sparse) memory in under 9 seconds, and neither the existing JS heap watchdog nor round 171's own recursion-depth cap could stop it.

  • The heap watchdog's Interrupt() only takes effect at goja's next bytecode instruction; a single already-in-flight cloneNode(true) call is one uninterruptible native Go call, and that one call alone balloons memory once its input tree is already large (confirmed: setting MaxJSHeapBytes to 100MB produced identical total memory to the 768MB default).
  • The depth cap doesn't apply either: this construction builds a BUSHY tree (each clone nests a full copy of everything so far), so no single call's recursion exceeds the depth cap — the runaway dimension is total node count, not depth.

Fixed two ways:

  • cloneNode now carries a node-count budget (maxCloneNodes=200,000) shared across one call's whole recursion, converting exponential-per-call growth into linear.
  • newCanvas gains a total-pixel-count cap (maxCanvasPixels=200,000,000, 7× the tallest page in this engine's own 500-page bench corpus), clamping height rather than allocating unboundedly.

Together: the original reproduction drops from ~21GB to ~5.6GB at 25 iterations, confirmed linear (not exponential) in iteration count at 40 iterations (~7.8GB).

Test plan

  • go build ./...
  • go test ./... (all packages pass)
  • New tests: TestCloneNodeCapsTotalNodeCountPerCall, TestCloneNodeCapsTotalNodeCountInsideTemplateContent, TestNewCanvasCapsTotalPixelCount, TestNewCanvasOrdinaryHeightUnaffected, TestNewCanvasClampFloorHoldsWhenViewportAloneExceedsCap
  • newCanvas and cloneNodeDepth both at 100% coverage

🤖 Generated with Claude Code

Closes round 171's own open lead: a 25-iteration
root.appendChild(root.cloneNode(true)) loop measured at ~21GB of
REAL (not sparse) memory in under 9 seconds, and neither the
existing JS heap watchdog nor round 171's own recursion-depth cap
could stop it.

The heap watchdog's Interrupt() only takes effect at goja's next
bytecode instruction; a single already-in-flight cloneNode(true)
call is one uninterruptible native Go call, and that one call
alone balloons memory once its input tree is already large
(confirmed: setting MaxJSHeapBytes to 100MB produced identical
total memory to the 768MB default). The depth cap doesn't apply
either: this construction builds a BUSHY tree (each clone nests a
full copy of everything so far), so no single call's recursion
exceeds the depth cap -- the runaway dimension is total node
count, not depth.

Fixed two ways: cloneNode now carries a node-count budget
(maxCloneNodes=200,000) shared across one call's whole recursion,
converting exponential-per-call growth into linear; newCanvas
gains a total-pixel-count cap (maxCanvasPixels=200,000,000, 7x the
tallest page in this engine's own 500-page bench corpus), clamping
height rather than allocating unboundedly.

Together: the original reproduction drops from ~21GB to ~5.6GB at
25 iterations, confirmed linear (not exponential) in iteration
count at 40 iterations (~7.8GB).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit d35fcc8 into main Oct 17, 2026
7 checks passed
@tannevaled
tannevaled deleted the fix-canvas-size-and-clonenode-memory branch October 17, 2026 20:22
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.

1 participant