Skip to content

HTMLRewriter: fix quadratic selector-VM stack scans on hostile nesting - #35930

Open
robobun wants to merge 2 commits into
mainfrom
farm/829c41a5/lolhtml-selectors-vm-quadratic
Open

HTMLRewriter: fix quadratic selector-VM stack scans on hostile nesting#35930
robobun wants to merge 2 commits into
mainfrom
farm/829c41a5/lolhtml-selectors-vm-quadratic

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes two O(depth^2) paths in lol-html's selectors_vm open-element stack that let sub-MB hostile HTML pin a CPU core through HTMLRewriter. Applied as a vendor patch to cloudflare/lol-html.

Repro

// 1) stray end tags: O(depth) reverse scan per end tag
const doc = "<a></b>".repeat(100_000); // 684 KB
await new HTMLRewriter().on("nomatch", { element() {} })
  .transform(new Response(doc)).text();
// before: ~25s, after: ~50ms

// 2) descendant combinator over deep nesting: O(depth) ancestor walk per start tag
const deep = "<div>".repeat(40_000) + "</div>".repeat(40_000); // 200 KB
await new HTMLRewriter().on("div div", { element() {} })
  .transform(new Response(deep)).text();
// before: ~30s, after: ~30ms

Any .on(selector, ...) registration (even one that never matches) enables the selector VM, so any surface that feeds untrusted HTML to HTMLRewriter was exposed.

Cause

selectors_vm/stack.rs pop_up_to did items.iter().rposition(|i| i.local_name == name) for every end tag. A stray end tag that matches nothing still scanned the entire open-element stack: N unclosed opens plus N stray closes is N full scans.

selectors_vm/mod.rs try_exec_hereditary_jumps_without_attrs walked every ancestor for every start tag, re-executing the same hereditary-jump address range once per matching ancestor. For div div over N nested <div>, every item carries the same range so the walk is O(depth) per tag.

Fix

Stack now maintains:

  • open_name_counts: HashMap<LocalName, usize> so pop_up_to rejects a name not on the stack in O(1). When the name is present the rposition scan length equals the subsequent drain length, so total scan work over a document is bounded by total pushes.
  • active_hereditary_jumps: Vec<(AddressRange, usize)>, the distinct hereditary-jump ranges currently contributed by any open item, each paired with the shallowest depth that introduced it. Descendant matching iterates this flat list (bounded by program size, not depth); pop_up_to prunes it by depth. The per-item has_ancestor_with_hereditary_jumps flag and the HereditaryJumpPtr.stack_offset resume coordinate are gone.

Re-executing the same address range against the same start tag was already a no-op after payload dedup in add_execution_branch, so dedup-once execution is observationally equivalent.

Verification

  • USE_SYSTEM_BUN=1 bun test test/js/workerd/html-rewriter.test.js -t "pathological" times out on both cases (>5s default).
  • bun bd test test/js/workerd/html-rewriter.test.js passes: 439ms and 1.4s under debug+ASAN; all 71 existing HTMLRewriter tests pass.
  • cargo test --lib in vendor/lolhtml: 147 passed. cargo test --features integration_test: 3 passed including the selector-matching fixture suite.

Note: the fix lives in patches/ + scripts/, not src/, so a git stash -- src/ packages/ fail-before check won't revert it. The USE_SYSTEM_BUN=1 run above is the fail-before proof.


[stamp-90s] gate passed · iteration 1 · 3 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/workerd/html-rewriter.test.js'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/workerd/html-rewriter.test.js
bun test v1.4.0 (e96793baa)

test/js/workerd/html-rewriter.test.js:
(pass) HTMLRewriter > error handling [10.29ms]
(pass) HTMLRewriter > error inside element handler [5.84ms]
(pass) HTMLRewriter > error inside element handler (string) [4.41ms]
(pass) HTMLRewriter > fast async error inside element handler [20.47ms]
(pass) HTMLRewriter > slow async error inside element handler [18.12ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement [116.11ms]
(pass) HTMLRewriter > HTMLRewriter handles Symbol invalid type error [12.24ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > control: .text() on the untransformed response rejects [333.68ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .text() on the transformed response rejects [58.12ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .arrayBuffer() on the transformed response rejects [33.74ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .body on the transformed response is an errored stream [48.15ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > a read already pending on .body when the upstream fails rejects [35.45ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > .clone() of a failed transformed body is also failed [43.14ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > does not invoke onDocument end for a document that never completed [38.30ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > transform() of a body that already failed throws the upstream error [61.88ms]
(pass) HTMLRewriter > transform rejects when the upstream body fails > transform() of an aborted body throws the abort reason [57.38ms]
(pass) HTMLRewriter > HTMLRewriter: async replacement using fetch + Bun.serve [157.07ms]
(pass) H
... (truncated)
Exit: 0
diff hotspot
patches/lolhtml/selectors-vm-quadratic.patch | 368 +++++++++++++++++++++++++++
 scripts/build/deps/lolhtml.ts                |   6 +
 test/js/workerd/html-rewriter.test.js        |  51 ++++
 3 files changed, 425 insertions(+)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                          reads  edits  tests
patches/lolhtml/selectors-vm-quadratic.patch      0      0      0
scripts/build/deps/lolhtml.ts                     1      1      0
test/js/workerd/html-rewriter.test.js             1      3      0

lol-html's selectors_vm open-element stack had two O(depth) per-token
scans that go quadratic on crafted input:

- pop_up_to() did a full rposition scan for every end tag; a stray end
  tag that matches nothing still walked the whole stack. A ~700KB doc
  of '<a></b>'.repeat(100k) pinned a core for ~25s.
- Descendant-combinator matching walked every ancestor's hereditary
  jumps for every start tag, re-executing the same address range once
  per matching ancestor. 'div div' over '<div>'.repeat(40k) took ~30s.

Fixes, shipped as a vendor patch until they land upstream:

- Stack now keeps a per-name open count so pop_up_to rejects a
  non-present name in O(1). When the name is present the rposition
  scan length equals the drain length, so the hit path was already
  amortised linear.
- Stack now keeps a flat, deduplicated list of active hereditary jump
  ranges tagged with the shallowest depth that introduced each.
  Descendant matching iterates that list (bounded by program size)
  instead of every ancestor, and pop_up_to prunes it by depth.
  Matching semantics are unchanged: re-running the same range against
  the same start tag is a no-op after payload dedup.

All 147 lol_html lib tests and the integration selector-matching
fixtures pass.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 52e98220-fcca-432b-8951-ff7e87d569a2

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and e96793b.

📒 Files selected for processing (3)
  • patches/lolhtml/selectors-vm-quadratic.patch
  • scripts/build/deps/lolhtml.ts
  • test/js/workerd/html-rewriter.test.js

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:24 AM PT - Jul 26th, 2026

@robobun, your commit e96793b has 1 failures in Build #82441 (All Failures):

  • 📦 Binary size — 12 over 0.50 MB
  • targetthis build canary: main #79916
    sizeΔ
    bun-darwin-aarch6458.13 MB57.58 MB+564.9 KB
    bun-darwin-x6463.48 MB62.95 MB+544.5 KB
    bun-linux-aarch6470.98 MB70.42 MB+576.0 KB
    bun-linux-x6472.48 MB71.95 MB+544.0 KB
    bun-linux-aarch64-musl64.88 MB64.32 MB+576.0 KB
    bun-linux-x64-musl66.98 MB66.45 MB+544.0 KB
    bun-linux-aarch64-android78.47 MB77.97 MB+512.0 KB
    bun-linux-x64-android80.62 MB80.10 MB+529.2 KB
    bun-freebsd-x6483.07 MB82.56 MB+528.0 KB
    bun-freebsd-aarch6484.84 MB84.31 MB+544.0 KB
    bun-windows-x6480.26 MB79.70 MB+573.0 KB
    bun-windows-aarch6470.86 MB70.34 MB+536.0 KB

    Add [skip size check] to the commit message if this increase is intentional.


🧪   To try this PR locally:

bunx bun-pr 35930

That installs a local version of the PR into your bun-35930 executable, so you can run:

bun-35930 --bun

Comment thread patches/lolhtml/selectors-vm-quadratic.patch
The open-element-stack presence map added in the previous commit keys on
LocalName, whose PartialEq compares the Bytes variant case-insensitively
while the derived Hash did not. That made <My-Widget>...</my-widget>
miss the presence check and skip the pop, so onEndTag never fired for
mixed-case custom elements (and any name that falls off the packed
LocalNameHash path).

Give LocalName a manual Hash that ASCII-lowercases the Bytes arm so
Hash agrees with Eq. This also fixes the same latent contract violation
in the existing TypedChildCounterMap used for :nth-of-type.

Adds a guard test for the mixed-case end-tag match.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Hash/Eq issue from my earlier review is fixed in e96793bLocalName's manual Hash now case-folds the Bytes arm, and cross-variant PartialEq is always false so hashing the discriminant is sound. I didn't find further issues, but this is a substantive algorithmic rewrite of lol-html's descendant-combinator matching applied as a vendor patch, so it's worth a human look.

What was reviewed:

  • active_hereditary_jumps prune-by-depth: the stored depth is always the shallowest contributor and pop_up_to drains a suffix, so retain d < index cannot drop a range that a surviving item still needs.
  • Bailout recovery: HereditaryJumpPtr.instr_set_idx indexes active_hereditary_jumps, which is stable between the try-without-attrs capture and the with-attrs recovery (no push/pop in between).
  • open_name_counts: push_item updates only after the fallible items.push, so the map never desyncs on MemoryLimitExceededError; RawEntryMut/HashMap are already imported from hashbrown in stack.rs.
Extended reasoning...

Overview

Vendor patch to cloudflare/lol-html fixing two O(depth²) hot loops in the CSS selector VM (selectors_vm/{mod,stack}.rs) plus a case-folding Hash impl for LocalName, wired via scripts/build/deps/lolhtml.ts (existing patches: mechanism, same pattern as lsquic/libuv/zlib). Three regression tests added to test/js/workerd/html-rewriter.test.js including one guarding the Hash/Eq fix from my earlier review.

Security risks

The motivation is itself a DoS-class fix (sub-MB hostile HTML → CPU pin). I checked that the new bookkeeping doesn't introduce a replacement DoS: active_hereditary_jumps length is bounded by distinct address ranges in the compiled selector program (attacker doesn't control selectors), and open_name_counts size is bounded by concurrently-open distinct names, itself bounded by the memory-limited items stack. No untrusted input reaches an allocation size or index computation.

Level of scrutiny

High. This replaces the per-ancestor hereditary-jump walk with a deduplicated flat set and relies on the argument that re-executing an address range against the same start tag is idempotent through add_execution_branch. I traced add_execution_branch at the pinned upstream commit: matched_payload is HashSet-deduped so match_handler fires once per payload regardless of execution count, and the additive pushes to ctx.stack_item.{jumps,hereditary_jumps} only feed future matching where the same idempotence applies. That argument holds for the shapes I walked (div div, div div div, div div > span), but it's subtle enough — and the patch will need carrying across upstream bumps — that someone familiar with lol-html's compiler output should confirm it for the full selector grammar.

Other factors

My previous finding (case-sensitive derived Hash vs. case-insensitive PartialEq on LocalName::Bytes) is resolved and covered by both a Rust unit test (bytes_variant_hash_matches_case_insensitive_eq) and a JS test (<My-Widget>…</MY-WIDGET> fires onEndTag). PR reports cargo test in the vendored crate (147 lib tests + integration selector fixtures) and the full 71-test HTMLRewriter suite pass under debug+ASAN. The new perf tests use Buffer.alloc(n, fill) per repo convention and run ~1.4s under ASAN.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green except for package-binary-size, which is comparing against a stale canary baseline (main #79916 at ae4b17d). The reported +528 to +576 KB deltas are identical to the byte on unrelated PR builds 82419/82415/82414 and come from the 12 main commits between that canary and this PR's base (notably #31827 node:repl, #32602 node:quic, #31823 node:inspector), not from the ~370-line vendor patch here.

test/js/workerd/html-rewriter.test.js passed on every lane. The remaining annotations are [flaky] (passed on retry) and unrelated to HTMLRewriter.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun submit this as a PR to lol-html instead.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Opened upstream as cloudflare/lol-html#331 (rebased onto their v3.0.0 main, 180 lib tests + integration fixtures + clippy all pass).

Happy to close this vendor-patch PR if you'd rather wait for the upstream release and bump LOLHTML_COMMIT, or keep it as the interim fix until that lands. Let me know which you prefer.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun simplify your PR in cloudflare/lol-html#331. Remove comments that are noise. Reduce the non-test LOC in the PR.

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Simplified cloudflare/lol-html#331 in e1daebc: trimmed comments to one line each, collapsed the raw_entry boilerplate in push_item/pop_up_to, and tightened the Hash impl. Diff is now +128/-83 (was +160/-82). 180 lib tests + integration fixtures + clippy still pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants