Skip to content

Limit how deep values and conditions nest - #260

Merged
FlorianRappl merged 1 commit into
AngleSharp:develfrom
michaelthyregod:feature/value-nesting-limit
Nov 2, 2026
Merged

FlorianRappl merged 1 commit into
AngleSharp:develfrom
michaelthyregod:feature/value-nesting-limit

Conversation

@michaelthyregod

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

Description

The follow-up to #258, for values and conditions. There is no separate issue.

Parentheses and math functions in values (calc(), min(), max(), clamp()) and parentheses in @media and @supports conditions are parsed recursively, once per level, also when a value is set through the CSSOM (e.g. SetProperty). A deeply nested value overflows the stack, and a StackOverflowException cannot be caught, so the whole process ends.

With no limit, the deepest nesting that still parses (a fresh process per case, so before the JIT optimizes):

net8.0, 1 MB stack net8.0, 256 KB stack net472, 1 MB stack net472, 256 KB stack
parentheses in calc() ~1,930 ~435 ~3,530 ~805
@supports condition ~3,730 ~835 ~5,200 ~1,120
@media condition ~3,460 ~715 ~5,660 ~1,200

The change

  • CssParserOptions.MaxValueNestingDepth, default 64 (CssParserOptions.DefaultMaxValueNestingDepth); 0 uses the default, as MaxNestingDepth does. A value or condition nested deeper is invalid, like any other invalid value.
  • How the option reaches the value parsers: they run inside the declaration converters, which do not see the parser options. So CssParser makes its limit current on the thread while it builds (an internal ValueNesting scope; building is synchronous). Values set through the CSSOM without a parser use the default. Making it configurable per browsing context or process-wide would need the options to reach the converters, a bigger change I left out.
  • Tests (Parsing/DeepNesting.cs) parse on a thread with a fixed 256 KB stack, as in Limit how deep the parser nests rules #258: depths that crashed before for each case, the limit itself, a custom limit per parser and through the browsing context, and that the scope is restored afterwards.
  • Benchmark: DeepStyleSheetBenchmarks gets calc() and @supports shapes at 10 and 60 levels.

Benchmarks

Paired builds of the same benchmark code, alternating which goes first each round. BenchmarkDotNet 0.15.8, net10.0, AngleSharp 1.5.0 for both, pinned to the performance cores of an Intel Core Ultra 7 165H. Median of 3 rounds against #258 (b7b5db8):

Case before this PR Change
CssParserBenchmarks, the 8 sample files −3.7% to +1.0%
calc-10 / calc-60 2.76 / 9.70 μs 2.77 / 9.26 μs +0.3% / −4.5%
supports-10 / supports-60 2.14 / 5.81 μs 2.18 / 5.88 μs +2.0% / +1.2%
media-10 / media-60 6.27 / 31.33 μs 6.12 / 31.55 μs −2.3% / +0.7%
style-10 / style-60 6.51 / 34.09 μs 6.74 / 35.45 μs +3.5% / +4.0%

Allocations are the same. CssCascadeBenchmarks.ParseInlineDeclarations, which parses many small declarations and so sets the scope most often, showed +3.9% in that run; a 6-round rerun put it at 408.9 μs against 408.2 μs (+0.2%), and two identical builds differ by up to ±5% in single rounds on this machine.

For #258, which was merged before I posted its numbers: against devel (cf08f08), the sample files were within −3.0% to +3.0% with sign changes between rounds, and the four nesting shapes within ±3% over 6 rounds, with the same allocations.

Mitigation, not a fix: the bug is still present

As in #258, the limit only stops the input before it gets deep enough to crash. The parsers still recurse once per level:

  • A raised limit brings it back. Anyone who sets MaxValueNestingDepth higher, or parses on a thread with a smaller stack, takes the risk back:

    var parser = new CssParser(new CssParserOptions { MaxValueNestingDepth = Int32.MaxValue });
    var css = "a{width:calc(" + String.Concat(Enumerable.Repeat("(1px + ", 5000)) + "1px" + new String(')', 5000) + ")}";
    parser.ParseStyleSheet(css);   // still a stack overflow with this PR (5,000 is beyond every depth in the table)

    The XML docs of the option say so.

  • Trees built through the DOM (rendering, GetInnerText, Find, DownloadResources) are still not covered, as noted in Limit how deep the parser nests rules #258.

Suggested CHANGELOG line

I left CHANGELOG.md out so you can word it; suggestion:

  • Improved the parser with a nesting limit for values and conditions (MaxValueNestingDepth) (#PR) @michaelthyregod

🤖 Generated with Claude Code

A value or condition nesting parentheses deeply ended the process with a
stack overflow, which no caller can catch: the parser recurses once per level
of parentheses and math functions in values (calc(), min(), max(), clamp())
and of parentheses in @media and @supports conditions, also when a value is
set through the CSSOM. On a 256 KB thread stack, about 430 parentheses in
calc() were enough (.NET 8, before the JIT optimizes); on a 1 MB stack about
1,900.

- Add CssParserOptions.MaxValueNestingDepth (default 64). A value or
  condition nested deeper is invalid. The value parsers run inside the
  converters, which do not see the options, so a parser makes its limit
  current while it builds (ValueNesting); values set through the CSSOM
  without a parser get the default
- Add tests at depths that crashed before and at the limit, parsing on a
  256 KB thread stack, and for the option per parser, per browsing context
  and its scope
- Add calc() and @supports shapes to the nesting benchmark

This is a mitigation, not a fix: parsing still recurses once per level, and
only the limit stops it. Raising the limit brings the crash back.
@FlorianRappl FlorianRappl added this to the v1.3.2 milestone Nov 2, 2026
@FlorianRappl
FlorianRappl merged commit 1a3f802 into AngleSharp:devel Nov 2, 2026
5 checks passed
@michaelthyregod
michaelthyregod deleted the feature/value-nesting-limit branch November 2, 2026 08:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants