From a613bd59d5b8a49b47f0d0b363e6027a0ed75315 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 17 Oct 2026 22:20:51 +0200 Subject: [PATCH] security: cap canvas pixel count and cloneNode's total node output 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 --- FIDELITY.md | 19 ++++++++++++++ canvas_bomb_test.go | 61 +++++++++++++++++++++++++++++++++++++++++++++ engine.go | 26 +++++++++++++++++-- js/dom.go | 49 +++++++++++++++++++++++++++++------- js/dom_test.go | 50 +++++++++++++++++++++++++++++++++++++ 5 files changed, 194 insertions(+), 11 deletions(-) create mode 100644 canvas_bomb_test.go diff --git a/FIDELITY.md b/FIDELITY.md index 69564fa..95502e5 100644 --- a/FIDELITY.md +++ b/FIDELITY.md @@ -18,6 +18,25 @@ The committed PNGs under `testdata/renders/` back every claim here. Reproduce them with the commands at the bottom. The measured-vs-Chrome numbers live in [`bench/REPORT.md`](bench/REPORT.md). +## 2026-10-17 (round 172) — closing round 171's own open lead: 25 lines of ordinary-looking script allocated ~21GB of REAL memory, and neither an existing heap watchdog nor this round's own depth cap could stop it (security audit, patch) + +Round 171 flagged, but did not characterize, a 25-iteration `root.appendChild(root.cloneNode(true))` loop that rendered a 10.5-million-pixel-tall page without crashing — unclear at the time whether that was genuinely safe (sparse, unpainted memory) or just not yet pushed hard enough. Measured directly with `runtime.MemStats` before/after: **~19.75GB of real HeapAlloc, ~21GB of real Sys**, in under 9 seconds, from 25 ordinary DOM calls on a trivial page. Not sparse at all — `image.NewRGBA` eagerly allocates and zero-fills every byte, and `newCanvas`'s own fill pass then writes to all of it. + +Two independent gaps, found while root-causing this: + +- **The existing JS heap watchdog (`heapGuardedContext`, `MaxJSHeapBytes`, default 768MB) cannot stop this, regardless of how low its limit is set** — confirmed by setting it to 100MB and seeing IDENTICAL total memory usage to the 768MB default. Its `Interrupt()` only takes effect at the next bytecode instruction goja executes; a single `cloneNode(true)` call already in flight is one uninterruptible native Go call from goja's point of view, and that one call alone is what balloons memory once its input tree is already large. The watchdog is real and does fire (confirmed via `JSLog`), it is just structurally unable to abort a single native call once started. +- **Round 171's own `maxDOMWalkDepth` cap does not apply here either**: `root.appendChild(root.cloneNode(true))` builds a BUSHY tree (each clone nests a full copy of everything accumulated so far, one level deeper under root), so no single `cloneNode` call's own recursion ever exceeds the depth cap — the runaway dimension is TOTAL NODE COUNT, which doubles every iteration, not recursion depth, which stays modest. +- **Root cause of the actual memory number**: `newCanvas` (`engine.go`) sizes the output image from the laid-out content height alone, with no upper bound — a script can grow that height arbitrarily (here, to 13,288,016px), and `vpW × canvasH × 4 bytes` scales accordingly with nothing stopping it. + +**Fixed two ways, addressing both the tree-size and the canvas-size dimensions**: +- `cloneNode` now carries a node-count `budget` (`maxCloneNodes = 200,000`) alongside its existing depth cap, shared across one call's whole recursion (the same shape as round 170's `expandFunctionalPseudosBudget`). Verified directly: node-count growth that was exactly doubling every iteration (4, 8, 16, … 262,144) becomes linear (+200,000/iteration) exactly once the budget is first exhausted, confirmed node-by-node with a throwaway counting test run against the real doubling loop. +- `newCanvas` gains `maxCanvasPixels = 200,000,000` (200 megapixels — 7× the tallest page in this engine's own 500-page bench corpus, Wikipedia at ~29M pixels, while bounding worst-case allocation to ~800MB instead of tens of GB), clamping `canvasH` rather than failing the render. +- **Together**: the original 21GB reproduction now uses ~5.6GB (a ~4× reduction) at 25 iterations and ~7.8GB at 40 iterations — confirmed LINEAR in iteration count now, not exponential, meaning an attacker needs proportionally more explicit script statements for more damage rather than getting free exponential growth, and the existing JS script timeout (5s default) bounds how many such statements can run at all. +- **Test**: `js/dom_test.go`'s `TestCloneNodeCapsTotalNodeCountPerCall` and `TestCloneNodeCapsTotalNodeCountInsideTemplateContent` (the `