From 28108ab805818c832d9568142a99844fd95a0d39 Mon Sep 17 00:00:00 2001
From: facelessuser <faceless.shop@gmail.com>
Date: Sun, 24 May 2026 07:50:08 -0600
Subject: [PATCH] Limit excessive selectors

Reported by @mauriceng98
---
 docs/src/markdown/about/changelog.md |  1 +
 docs/src/markdown/api.md             |  8 ++++-
 soupsieve/css_parser.py              | 51 +++++++++++++++++++++++++++-
 soupsieve/css_types.py               |  9 +++--
 tests/test_api.py                    | 32 +++++++++++++++++
 5 files changed, 96 insertions(+), 5 deletions(-)

Index: soupsieve-2.6/soupsieve/css_parser.py
===================================================================
--- soupsieve-2.6.orig/soupsieve/css_parser.py
+++ soupsieve-2.6/soupsieve/css_parser.py
@@ -11,6 +11,8 @@ from typing import Match, Any, Iterator,
 
 UNICODE_REPLACEMENT_CHAR = 0xFFFD
 
+SELECTOR_LIMIT = 8192
+
 # Simple pseudo classes that take no parameters
 PSEUDO_SIMPLE = {
     ":any-link",
@@ -458,6 +460,13 @@ class CSSParser:
         self.flags = flags
         self.debug = self.flags & util.DEBUG
         self.custom = {} if custom is None else custom
+        self.count = 0
+
+    def check_count(self) -> None:
+        """Check the current selector count."""
+
+        if self.count > SELECTOR_LIMIT:
+            raise ValueError(f'Selector exceeds pseudo-class nesting limit of {SELECTOR_LIMIT}')
 
     def parse_attribute_selector(self, sel: _Selector, m: Match[str], has_selector: bool) -> bool:
         """Create attribute selector from the returned regex match."""
@@ -562,6 +571,9 @@ class CSSParser:
             ).process_selectors(flags=FLG_PSEUDO)
             self.custom[pseudo] = selector
 
+        self.count += selector.count
+        self.check_count()
+
         sel.selectors.append(selector)
         has_selector = True
         return has_selector
@@ -593,30 +605,56 @@ class CSSParser:
             elif pseudo == ':empty':
                 sel.flags |= ct.SEL_EMPTY
             elif pseudo in (':link', ':any-link'):
+                self.count += CSS_LINK.count
+                self.check_count()
                 sel.selectors.append(CSS_LINK)
             elif pseudo == ':checked':
+                self.count += CSS_CHECKED.count
+                self.check_count()
                 sel.selectors.append(CSS_CHECKED)
             elif pseudo == ':default':
+                self.count += CSS_DEFAULT.count
+                self.check_count()
                 sel.selectors.append(CSS_DEFAULT)
             elif pseudo == ':indeterminate':
+                self.count += CSS_INDETERMINATE.count
+                self.check_count()
                 sel.selectors.append(CSS_INDETERMINATE)
             elif pseudo == ":disabled":
+                self.count += CSS_DISABLED.count
+                self.check_count()
                 sel.selectors.append(CSS_DISABLED)
             elif pseudo == ":enabled":
+                self.count += CSS_ENABLED.count
+                self.check_count()
                 sel.selectors.append(CSS_ENABLED)
             elif pseudo == ":required":
+                self.count += CSS_REQUIRED.count
+                self.check_count()
                 sel.selectors.append(CSS_REQUIRED)
             elif pseudo == ":optional":
+                self.count += CSS_OPTIONAL.count
+                self.check_count()
                 sel.selectors.append(CSS_OPTIONAL)
             elif pseudo == ":read-only":
+                self.count += CSS_READ_ONLY.count
+                self.check_count()
                 sel.selectors.append(CSS_READ_ONLY)
             elif pseudo == ":read-write":
+                self.count += CSS_READ_WRITE.count
+                self.check_count()
                 sel.selectors.append(CSS_READ_WRITE)
             elif pseudo == ":in-range":
+                self.count += CSS_IN_RANGE.count
+                self.check_count()
                 sel.selectors.append(CSS_IN_RANGE)
             elif pseudo == ":out-of-range":
+                self.count += CSS_OUT_OF_RANGE.count
+                self.check_count()
                 sel.selectors.append(CSS_OUT_OF_RANGE)
             elif pseudo == ":placeholder-shown":
+                self.count += CSS_PLACEHOLDER_SHOWN.count
+                self.check_count()
                 sel.selectors.append(CSS_PLACEHOLDER_SHOWN)
             elif pseudo == ':first-child':
                 sel.nth.append(ct.SelectorNth(1, False, 0, False, False, ct.SelectorList()))
@@ -717,6 +755,8 @@ class CSSParser:
             else:
                 # Use default `*|*` for `of S`.
                 nth_sel = CSS_NTH_OF_S_DEFAULT
+                self.count += nth_sel.count
+                self.check_count()
             if pseudo_sel == ':nth-child':
                 sel.nth.append(ct.SelectorNth(s1, var, s2, False, False, nth_sel))
             elif pseudo_sel == ':nth-last-child':
@@ -923,6 +963,7 @@ class CSSParser:
         closed = False
         relations = []  # type: list[_Selector]
         rel_type = ":" + WS_COMBINATOR
+        count = self.count
 
         # Setup various flags
         is_open = bool(flags & FLG_OPEN)
@@ -970,6 +1011,10 @@ class CSSParser:
             while True:
                 key, m = next(iselector)
 
+                if key not in ('combine', 'pseudo_close'):
+                    self.count += 1
+                    self.check_count()
+
                 # Handle parts
                 if key == "at_rule":
                     raise NotImplementedError(f"At-rules found at position {m.start(0)}")
@@ -1089,7 +1134,7 @@ class CSSParser:
             selectors[-1].flags = ct.SEL_PLACEHOLDER_SHOWN
 
         # Return selector list
-        return ct.SelectorList([s.freeze() for s in selectors], is_not, is_html)
+        return ct.SelectorList([s.freeze() for s in selectors], is_not, is_html, self.count - count)
 
     def selector_iter(self, pattern: str) -> Iterator[tuple[str, Match[str]]]:
         """Iterate selector tokens."""
Index: soupsieve-2.6/soupsieve/css_types.py
===================================================================
--- soupsieve-2.6.orig/soupsieve/css_types.py
+++ soupsieve-2.6/soupsieve/css_types.py
@@ -351,24 +351,27 @@ class SelectorLang(Immutable):
 class SelectorList(Immutable):
     """Selector list."""
 
-    __slots__ = ("selectors", "is_not", "is_html", "_hash")
+    __slots__ = ("selectors", "is_not", "is_html", "count", "_hash")
 
     selectors: tuple[Selector | SelectorNull, ...]
     is_not: bool
     is_html: bool
+    count: int
 
     def __init__(
         self,
         selectors: Iterable[Selector | SelectorNull] | None = None,
         is_not: bool = False,
-        is_html: bool = False
+        is_html: bool = False,
+        count: int = 0,
     ) -> None:
         """Initialize."""
 
         super().__init__(
             selectors=tuple(selectors) if selectors is not None else (),
             is_not=is_not,
-            is_html=is_html
+            is_html=is_html,
+            count=count
         )
 
     def __iter__(self) -> Iterator[Selector | SelectorNull]:
Index: soupsieve-2.6/tests/test_api.py
===================================================================
--- soupsieve-2.6.orig/tests/test_api.py
+++ soupsieve-2.6/tests/test_api.py
@@ -580,6 +580,38 @@ class TestInvalid(util.TestCase):
         with self.assertRaises(TypeError):
             sv.filter('div', "not a tag", flags=flags)
 
+    def test_excessive_selectors(self):
+        """Test excessive selectors."""
+
+        # Build a 500 KB selector string: "a,a,a,...,a" (250,000 items)
+        count = 10000
+        selector = ",".join("a" for _ in range(count))
+
+        # Compile the selector
+        with self.assertRaises(ValueError):
+            sv.compile(selector)
+
+    def test_excessive_custom_selectors(self):
+        """Test excessive custom selectors."""
+
+        # Build a 500 KB selector string: "a,a,a,...,a" (250,000 items)
+        count = 10000
+        selector = ",".join("a" for _ in range(count))
+
+        # Compile the selector
+        with self.assertRaises(ValueError):
+            sv.compile('div:--custom', custom={':--custom': selector})
+
+    def test_excessive_custom_and_normal_selectors(self):
+        """Test excessive custom and normal selectors."""
+
+        count = 5000
+        selector = ",".join("a" for _ in range(count))
+
+        # Compile the selector
+        with self.assertRaises(ValueError):
+            sv.compile(f':is({selector}):--custom', custom={':--custom': selector})
+
 
 class TestSyntaxErrorReporting(util.TestCase):
     """Test reporting of syntax errors."""
