Skip to content

security: cap total node count in convertChildren (Parse/ParseFragment) - #277

Merged
tannevaled merged 1 commit into
mainfrom
fix-convertchildren-node-count-budget
Oct 17, 2026
Merged

tannevaled merged 1 commit into
mainfrom
fix-convertchildren-node-count-budget

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

Continuing round 172's own sweep into the one other place a script routinely hands this engine a whole subtree at once: innerHTML/outerHTML/insertAdjacentHTML, which all route through dom.ParseFragment (or dom.Parse for the top-level page), sharing one core: convertChildren.

golang.org/x/net/html's own parser refuses markup nested past 512 levels, but that guard is about depth — a WIDE document (millions of sibling elements, never nesting past a handful of levels) sails straight through it untouched.

Confirmed live, two ways:

  • root.innerHTML = "<div>x</div>".repeat(3000000) — one statement, no loop — allocated ~11.5GB of real memory in 15 seconds.
  • A 25-iteration root.innerHTML = root.outerHTML + root.outerHTML loop (the same doubling shape as round 172's cloneNode case, through serialize-then-reparse instead of direct cloning) reached ~6GB.

convertChildren now carries a shared node-count budget (maxParsedNodes=200000, matching js.maxCloneNodes's own value and reasoning) threaded through its own recursion and the <template> content-fragment branch. Past zero, remaining siblings/descendants are simply not converted rather than crashing or exhausting memory.

Measured effect: the flat repeat(3000000) case drops from ~11.5GB/15.4s to ~1.1GB/1.8s; the outerHTML-doubling loop drops from ~6GB/8.3s to ~1.4GB/3.5s.

Test plan

  • go build ./...
  • go test ./... (all packages pass)
  • New tests: TestParseFragmentCapsTotalNodeCount, TestParseCapsTotalNodeCount
  • convertChildren at 100% coverage; dom package 98.4%→98.5% (confirmed via git stash, no regression)

🤖 Generated with Claude Code

Continuing round 172's own sweep into the one other place a script
routinely hands this engine a whole subtree at once: innerHTML/
outerHTML/insertAdjacentHTML, which all route through
dom.ParseFragment (or dom.Parse for the top-level page), sharing
one core: convertChildren.

golang.org/x/net/html's own parser refuses markup nested past 512
levels, but that guard is about depth -- a WIDE document (millions
of sibling elements, never nesting past a handful of levels)
sails straight through it untouched.

Confirmed live, two ways: `root.innerHTML =
"<div>x</div>".repeat(3000000)` -- one statement, no loop --
allocated ~11.5GB of real memory in 15 seconds; a 25-iteration
`root.innerHTML = root.outerHTML + root.outerHTML` loop (the same
doubling shape as round 172's cloneNode case, through
serialize-then-reparse instead of direct cloning) reached ~6GB.

convertChildren now carries a shared node-count budget
(maxParsedNodes=200000, matching js.maxCloneNodes's own value and
reasoning) threaded through its own recursion and the <template>
content-fragment branch. Past zero, remaining siblings/descendants
are simply not converted rather than crashing or exhausting
memory.

Measured effect: the flat repeat(3000000) case drops from
~11.5GB/15.4s to ~1.1GB/1.8s; the outerHTML-doubling loop drops
from ~6GB/8.3s to ~1.4GB/3.5s.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 2cb6ea9 into main Oct 17, 2026
7 checks passed
@tannevaled
tannevaled deleted the fix-convertchildren-node-count-budget branch October 17, 2026 21:07
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