Skip to content

security: cap :not()/:has()/:host() recursion depth and :is() output count - #274

Merged
tannevaled merged 1 commit into
mainfrom
fix-selector-recursion-depth-and-blowup
Oct 11, 2026
Merged

tannevaled merged 1 commit into
mainfrom
fix-selector-recursion-depth-and-blowup

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Summary

Continuing the same audit as the just-merged calc() fix (#273): two more text-level CSS parsers recurse (or expand) on attacker-controlled nesting depth with no guard.

  • :not()/:has()/:host(): parseSimple/parseSimpleSelectorList/parseHasSelectorList form one mutually recursive cycle with no depth check — the same shape as calc()'s, but each level's own string-scanning cost compounds (closer to quadratic than linear): a 500,000-deep :not(:not(...)) chain used to time out past 60s, now completes in ~2.5s. Fixed with a depth parameter threaded through all three, capped at maxSelectorNestingDepth = 256 (matching calc()'s own cap).
  • :is()/:where()/:matches() — a different, more severe failure mode: expandFunctionalPseudos doubles its OUTPUT COUNT at every level a comma-containing wrapper nests inside another one of itself (2^n alternatives from n levels of text nesting). 20 such levels — elaborate-looking, not obviously malicious — already produces 1,048,576 alternatives and takes several seconds. A depth cap alone wouldn't help since the depth stays small while the output explodes; fixed with a shared output budget (maxSelectorExpansions = 4096) threaded through the whole expansion, truncating once the TOTAL alternatives produced hits the cap.

Both fail the same way their parser already fails on any other malformed/too-much-to-handle input (truncate/drop, never crash), matching this codebase's existing "reduce, don't drop" convention.

Test plan

  • go build ./...
  • go test ./... (all packages pass)
  • New tests: TestParseSimpleDoesNotCrashOnPathologicallyDeepNotNesting, TestExpandFunctionalPseudosCapsCombinatorialBlowup (+ a direct white-box test for its budget-exhausted entry guard)
  • css package coverage unaffected (99.5% baseline); every new branch at 100%

🤖 Generated with Claude Code

…count

Continuing the same audit as the calc() fix in this branch: two more
text-level CSS parsers recurse on attacker-controlled nesting depth
with no guard.

parseSimple/parseSimpleSelectorList/parseHasSelectorList (the
:not()/:has()/:host() argument parsers) form one mutually recursive
cycle with no depth check -- the same shape as calc()'s, but each
level's own string-scanning cost compounds, making it closer to
quadratic than linear: a 500,000-deep :not(:not(...)) chain used to
time out past 60s and now completes in ~2.5s. Fixed with a depth
parameter threaded through all three, capped at
maxSelectorNestingDepth (256, matching calc()'s own cap).

expandFunctionalPseudos (:is()/:where()/:matches()) is a different,
more severe failure mode: it doubles its OUTPUT COUNT at every level
a comma-containing wrapper nests inside another one of itself -- n
levels of self-nesting give 2^n alternatives from a selector whose
own TEXT depth is just n. 20 such levels (elaborate-looking, not
obviously malicious) already produces 1,048,576 alternatives and
takes several seconds. A depth cap alone would not have been
enough, since the depth itself stays small while the output
explodes -- fixed instead with a shared output budget
(expandFunctionalPseudosBudget, maxSelectorExpansions = 4096)
threaded through the whole expansion, truncating the result once
the TOTAL alternatives produced hits the cap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit a638212 into main Oct 11, 2026
7 checks passed
@tannevaled
tannevaled deleted the fix-selector-recursion-depth-and-blowup branch October 11, 2026 16:44
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