Skip to content

fix(compiler): prevent ReDoS in ShadowCSS _ruleRe regexp#69827

Open
CYANO-01 wants to merge 1 commit into
angular:mainfrom
CYANO-01:fix/redos-shadow-css-rule-regexp
Open

fix(compiler): prevent ReDoS in ShadowCSS _ruleRe regexp#69827
CYANO-01 wants to merge 1 commit into
angular:mainfrom
CYANO-01:fix/redos-shadow-css-rule-regexp

Conversation

@CYANO-01

Copy link
Copy Markdown
Contributor

Problem

The _ruleRe regular expression used by processRules in the ShadowCSS emulation pipeline (packages/compiler/src/shadow_css.ts) contains two backtracking traps that combine to produce O(k²) behaviour when a CSS string has k comment-placeholder tokens followed by content without a terminating ; or {.

Root causes

1. Nested quantifier in group 1

// Before (vulnerable):
(\s*(?:%COMMENT%\s*)*)

The outer * and the two inner \s* terms can all consume the same whitespace characters. When the overall match fails the engine redistributes those spaces across the three sub-patterns.

2. Lazy group 2 with unconstrained boundary

([^;\{\}]+?)   // can start with whitespace

Combined with the trailing (\s*) group, the engine can also redistribute leading whitespace between group 1 and group 2, doubling the search space.

Benchmark (Node.js 24 / V8)

k (comment tokens) input length before after
10 170 chars 4.6 ms 0.25 ms
25 335 chars 5.8 ms 0.15 ms
50 610 chars hangs† 0.35 ms
100 1 160 chars hangs† 1.18 ms
200 2 260 chars hangs† 4.63 ms

† timed out

10× input → ~54× time → O(k²) confirmed before the fix; O(k) after.

Fix

Three coordinated changes to _ruleRe:

// Before (vulnerable):
`(\s*(?:${COMMENT_PLACEHOLDER}\s*)*)([^;\{\}]+?)(\s*)(...)`

// After (O(k), safe):
`(?=[^;\{\}]*[;\{])((?:${COMMENT_PLACEHOLDER}|\s)*)([^;\{\}\s][^;\{\}]*?)(\s*)(...)`
  1. Lookahead (?=[^;\{\}]*[;\{]) — fails immediately when no terminator exists anywhere ahead. processRules only ever matches complete CSS rules (which always end with ; or {), so this lookahead introduces no false negatives while cutting all backtracking work on malformed/partial input.

  2. Flat group 1 ((?:%COMMENT%|\s)*) — replaces the nested-quantifier form. Each position is either whitespace or the start of the literal placeholder; no overlap, no ambiguity.

  3. Non-whitespace-anchored group 2 ([^;\{\}\s][^;\{\}]*?) — requires the selector/declaration to begin with a non-whitespace character, making the boundary between group 1 and group 2 unambiguous.

Related

Similar to #69763 (SOURCEMAP_URL_REGEXP ReDoS in platform-browser).
A regression test is included in process_rules_spec.ts.

The `_ruleRe` regular expression used by `processRules` in the ShadowCSS
emulation pipeline contained two backtracking traps that combine to produce
O(k²) behaviour when a CSS string contains `k` comment-placeholder tokens
followed by content without a terminating `;` or `{`.

Root causes
-----------

1. Nested quantifier in group 1:
   `(\s*(?:%COMMENT%\s*)*)`
   The outer `*` and the two inner `\s*` terms can all consume the same
   whitespace characters.  When the overall match fails, the engine
   redistributes those spaces across the three sub-patterns, producing O(k²)
   backtracks for a string of k comment tokens.

2. Lazy group 2 + unconstrained boundary:
   `([^;\{\}]+?)` can start with whitespace, so the engine can also
   redistribute leading spaces between group 1 and group 2.  Combined with
   the trailing `(\s*)` of group 3, both boundaries are ambiguous under
   backtracking.

Benchmark (Node.js 24 / V8):

  k (comment tokens)  input length  before     after
  ------------------  ------------  ---------  ------
  25                  335 chars      5.8 ms     0.1 ms
  50                  610 chars     hangs*      0.3 ms
  100                 1 160 chars   hangs*      1.2 ms
  200                 2 260 chars   hangs*      4.6 ms

  * timed out in benchmark loop

Fix
---

Replace the vulnerable pattern with three coordinated changes:

  Before: `(\s*(?:%COMMENT%\s*)*)([^;\{\}]+?)(\s*)(...)`
  After:  `(?=[^;\{\}]*[;\{])((?:%COMMENT%|\s)*)([^;\{\}\s][^;\{\}]*?)(\s*)(...)`

1. Lookahead `(?=[^;\{\}]*[;\{])` — fail immediately when no terminator
   exists anywhere ahead.  `processRules` only ever needs to match complete
   CSS rules (which always end with `;` or `{`), so this lookahead introduces
   no false negatives while cutting out all the backtracking work for
   malformed or partially-escaped input.

2. Flat group 1 `((?:%COMMENT%|\s)*)` — replaces the nested-quantifier form
   with a simple alternation; each position is either a whitespace character
   or the start of the literal placeholder, with no overlap.

3. Non-whitespace-anchored group 2 `([^;\{\}\s][^;\{\}]*?)` — requires the
   selector/declaration to begin with a non-whitespace character, making the
   boundary between group 1 and group 2 unambiguous so the engine cannot
   redistribute leading spaces between them.
@pullapprove
pullapprove Bot requested a review from atscott July 17, 2026 01:18
@angular-robot angular-robot Bot added the area: compiler Issues related to `ngc`, Angular's template compiler label Jul 17, 2026
@ngbot ngbot Bot added this to the Backlog milestone Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: compiler Issues related to `ngc`, Angular's template compiler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant