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.