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:
- Audit project dependencies (requirements.txt, Pipfile.lock, poetry.lock) to identify versions of soupsieve prior to 2.10.0.
- Run 'pip install --upgrade "soupsieve>=2.10.0"' across all affected Python environments and container images.
- Verify the installed version at runtime using 'import soupsieve; assert soupsieve.version_info >= (2, 10, 0)'.
- 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
- GitHub Security Advisory GHSA-4j63-6c4x-wfg4
- Fix Commit ffb88cca745c591224f74a8e86414a85e8f35fc0
- Soup Sieve Pull Request #304
- Soup Sieve 2.10 Release Notes
- Official CVE.org Record for CVE-2026-126192
- National Vulnerability Database (NVD) Record for CVE-2026-126192
- Soup Sieve Upstream Repository
- MITRE ATT&CK Search Reference
- CISA Known Exploited Vulnerabilities Catalog
Read the full report for CVE-2026-126192 on our website for more details including interactive diagrams and full exploit analysis.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.