Dev.to Security 🔐 Cybersecurity 👁 0 📖 7 min read

CVE-2026-126192: CVE-2026-126192: Quadratic Algorithmic Complexity Denial of Service in Soup Sieve CSS Selector Engine

CVE-2026-126192: Quadratic Algorithmic Complexity Denial of Service in Soup Sieve CSS Selector Engine Vulnerability ID: CVE-2026-126192 CVSS Score: 6.3 Published: 2026-11-13 CVE-2026-126192 is an algorithmic complex

CVE-2026-126192: Quadratic Algorithmic Complexity Denial of Service in Soup Sieve CSS Selector Engine

Vulnerability ID: CVE-2026-126192
CVSS Score: 6.3
Published: 2026-11-13

CVE-2026-126192 is an algorithmic complexity vulnerability in Soup Sieve (soupsieve), the default CSS selector engine for Beautiful Soup 4 in Python. Evaluating a CSS selector that contains a general sibling combinator (~) nested inside a :has() relational pseudo-class against attacker-controlled HTML markup triggers O(n^2) quadratic evaluation complexity when the right-hand sibling target is absent. An unauthenticated attacker can supply small HTML documents containing thousands of sibling elements to exhaust CPU resources and stall application worker threads.

TL;DR

Soup Sieve versions prior to 2.10 exhibit O(n^2) quadratic time complexity when matching CSS selectors that combine the :has() pseudo-class with the general sibling combinator (~), such as div:has(a ~ b). Submitting a ~56 KB HTML payload containing 8,000 sibling elements without the terminating match locks a CPU core for approximately 60 seconds, enabling remote Denial of Service. Upgrading to soupsieve 2.10 resolves the issue via sibling index caching.

⚠️ Exploit Status: POC

Technical Details

  • CVE ID: CVE-2026-126192 (GHSA-4j63-6c4x-wfg4)
  • Affected Package: soupsieve (PyPI) < 2.10.0
  • Patched Version: soupsieve 2.10.0
  • Vulnerability Class: CWE-407: Inefficient Algorithmic Complexity / CWE-400
  • CVSS v4.0 Score: 6.3 Medium (AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N)
  • Attack Vector: Network (Untrusted HTML markup evaluated against :has(a ~ b) CSS selectors)
  • Primary Impact: Application Denial of Service (O(n^2) CPU exhaustion)
  • Exploit Status: Proof of Concept (PoC) publicly available; Not in CISA KEV

Affected Systems

  • Python applications using soupsieve < 2.10.0
  • Python applications using Beautiful Soup 4 (bs4) with soupsieve < 2.10.0
  • soupsieve: < 2.10.0 (0.4 through 2.9.2) (Fixed in: 2.10.0)

Code Analysis

Commit: ffb88cc

Introduce sib_cache and match_general_sibling() in CSSMatch to cache general sibling lookups and prevent quadratic traversal in :has() selectors.

--- a/soupsieve/css_match.py
+++ b/soupsieve/css_match.py
@@ -533,6 +533,7 @@ def __init__(
         self.enable_cache = not bool(self.flags & util.NOCACHE)
         self.iframe_restrict = False
         self.nth_cache: dict[Hashable, dict[Hashable, list[int]]] = {}
+        self.sib_cache: dict[Hashable, dict[Hashable, int]] = {}

         # Find the root element for the whole tree
         doc = scope
@@ -560,6 +561,7 @@ def reset(self) -> None:  # pragma: no cover
         """Reset."""

         self.nth_cache.clear()
+        self.sib_cache.clear()

     def supports_namespaces(self) -> bool:
         """Check if namespaces are supported in the HTML type."""
@@ -805,6 +807,75 @@ def match_tag(self, el: bs4.Tag, tag: ct.SelectorTag | None) -> bool:
                 match = False
         return match

+    def match_general_sibling(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
+        """Match general sibling combinator."""
+
+        found = False
+
+        if relation[0] is ct.Null:  # pragma: no cover
+            return found
+
+        pkey: tuple[str | None, int] | None = None
+        key: tuple[ct.SelectorList, int] | None = None
+
+        # Setup the cache by the parent if present
+        parent = self.get_parent(el)
+        if parent is None:  # pragma: no cover
+            return found
+
+        if parent:
+            pkey = (parent.name, id(parent))
+
+            # Initialize the cache if necessary
+            if pkey not in self.sib_cache:
+                self.sib_cache[pkey] = {}
+
+        # Check the cache to see if we already know where the first sibling is,
+        # and if we do, check if we are on the correct side of it.
+        # If we've previously searched and found no sibling, there is no sibling.
+        # Lastly, if this is our first time, setup the cache.
+        reverse = relation[0].rel_type == REL_HAS_SIBLING
+        start = len(parent) - 1 if reverse else 0
+        if pkey:
+            key = (relation, id(relation))
+            if key in self.sib_cache[pkey]:
+                index = self.sib_cache[pkey][key]
+                if index >= 0:
+                    a, b = (index, start) if reverse else (start, index)
+                    return not within(el, parent, a, b)
+                else:
+                    return False
+            self.sib_cache[pkey][key] = start
+
+        # Start at the furthest endpoint and walk back towards the element looking for siblings.
+        # The current element counts as a sibling, but will not cause a match.
+        passed = False
+        incr = -1 if reverse else 1
+        for child in self.get_children(parent, start=start, reverse=reverse):
+            start += incr
+            if not isinstance(child, bs4.Tag):
+                continue
+
+            # Flag that we are passing the element.
+            # Any siblings we find aren't valid for this element.
+            if child is el:
+                passed = True
+
+            # We found the furthest sibling.
+            if self.match_selectors(child, relation):
+                found = True
+                break
+
+        # Cache the index of the sibling or mark as there being no siblings.
+        if pkey and key:
+            self.sib_cache[pkey][key] = start if found else -1
+
+        # If we passed the current element and then found a sibling, it doesn't count as a match.
+        if passed:
+            found = False
+
+        return found
+
     def match_past_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
         """Match past relationship."""

@@ -814,23 +885,22 @@ def match_past_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:
             return found

         if relation[0].rel_type == REL_PARENT:
-            parent = self.get_parent(el, no_iframe=self.iframe_restrict)
-            while not found and parent:
-                found = self.match_selectors(parent, relation)
-                parent = self.get_parent(parent, no_iframe=self.iframe_restrict)
+            parent: bs4.Tag | None = el
+            while not found and parent and (parent := self.get_parent(parent, no_iframe=self.iframe_restrict)):
+                found = parent is not None and self.match_selectors(parent, relation)
         elif relation[0].rel_type == REL_CLOSE_PARENT:
             parent = self.get_parent(el, no_iframe=self.iframe_restrict)
-            if parent:
-                found = self.match_selectors(parent, relation)
+            found = parent is not None and self.match_selectors(parent, relation)
         elif relation[0].rel_type == REL_SIBLING:
-            sibling = self.get_previous_tag(el)
-            while not found and sibling:
-                found = self.match_selectors(sibling, relation)
-                sibling = self.get_previous_tag(sibling)
+            if self.enable_cache:
+                found = self.match_general_sibling(el, relation)
+            else:
+                sibling: bs4.Tag | None = el
+                while not found and sibling and (sibling := self.get_previous_tag(sibling)):
+                    found = sibling is not None and self.match_selectors(sibling, relation)
         elif relation[0].rel_type == REL_CLOSE_SIBLING:
             sibling = self.get_previous_tag(el)
-            if sibling and self.is_tag(sibling):
-                found = self.match_selectors(sibling, relation)
+            found = sibling is not None and self.match_selectors(sibling, relation)
         return found

     def match_future_child(self, parent: bs4.Tag, relation: ct.SelectorList, recursive: bool = False) -> bool:
@@ -842,8 +912,8 @@ def match_future_child(self, parent: bs4.Tag, relation: ct.SelectorList, recursi
         else:
             children = self.get_tag_children
         for child in children(parent, no_iframe=self.iframe_restrict):
-            match = self.match_selectors(child, relation)
-            if match:
+            if self.match_selectors(child, relation):
+                match = True
                 break
         return match

@@ -860,14 +930,15 @@ def match_future_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool
         elif relation[0].rel_type == REL_HAS_CLOSE_PARENT:
             found = self.match_future_child(el, relation)
         elif relation[0].rel_type == REL_HAS_SIBLING:
-            sibling = self.get_next_tag(el)
-            while not found and sibling:
-                found = self.match_selectors(sibling, relation)
-                sibling = self.get_next_tag(sibling)
+            if self.enable_cache:
+                found = self.match_general_sibling(el, relation)
+            else:
+                sibling: bs4.Tag | None = el
+                while not found and sibling and (sibling := self.get_next_tag(sibling)):
+                    found = self.match_selectors(sibling, relation)
         elif relation[0].rel_type == REL_HAS_CLOSE_SIBLING:
             sibling = self.get_next_tag(el)
-            if sibling and self.is_tag(sibling):
-                found = self.match_selectors(sibling, relation)
+            found = sibling is not None and self.match_selectors(sibling, relation)
         return found

     def match_relations(self, el: bs4.Tag, relation: ct.SelectorList) -> bool:

Mitigation Strategies

  • Upgrade the soupsieve Python package to version 2.10.0 or newer to enable sibling traversal caching.
  • Refactor application CSS selectors to replace general sibling combinators (~) inside :has() with adjacent sibling combinators (+) where functionally equivalent.
  • Use the ignore parameter introduced in soupsieve 2.10.0 to disallow the :has() pseudo-class when compiling untrusted or user-supplied CSS selectors.
  • Isolate HTML parsing and CSS selector evaluation in background worker processes with strict execution timeouts and payload size limits.

Remediation Steps:

  1. Audit project dependencies (requirements.txt, Pipfile.lock, poetry.lock) to identify versions of soupsieve prior to 2.10.0.
  2. Run 'pip install --upgrade "soupsieve>=2.10.0"' across all affected Python environments and container images.
  3. Verify the installed version at runtime using 'import soupsieve; assert soupsieve.version_info >= (2, 10, 0)'.
  4. Search application source code for calls to BeautifulSoup.select(), BeautifulSoup.select_one(), or soupsieve functions that utilize ':has(' combined with '~' or accept unvalidated user selector strings.

References

Read the full report for CVE-2026-126192 on our website for more details including interactive diagrams and full exploit analysis.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.