Vulnerability GHSA-7g44-g9rc-x53w
Summary
Soup Sieve: Uncontrolled recursion in CSS parser lets nested pseudo-class selectors exhaust the Python call stack
Details
Summary
The Soup Sieve CSS selector parser recurses once per level of nested pseudo-class selector (:is(), :not(), :where(), :has()) without any limit on nesting depth. An unauthenticated attacker who can supply a CSS selector string to soupsieve.compile(), soupsieve.select(), or Beautiful Soup's soup.select() / soup.select_one() can force the parser to exceed Python's recursion limit with a small crafted input (about 1.6 KB, roughly 330 nesting levels), raising an uncontrolled RecursionError. Because this exception is not part of the parser's documented error contract, applications that catch only ValueError or SelectorSyntaxError see an unexpected failure of the request or task that parsed the selector.
Root Cause
The parser dispatches each opening pseudo-class token to parse_pseudo_open, which calls back into parse_selectors to parse the pseudo-class body. Every nesting level therefore adds Python stack frames, and no counter, depth guard, or RecursionError handler bounds this descent. The token-count guard SELECTOR_LIMIT = 8192 (added for the earlier comma-separated-list memory issue) counts parsed tokens, not recursion depth; at the failing nesting depth the token count is only about 330, roughly four percent of that limit, so the guard never fires. The security invariant that untrusted selector input must not be able to exhaust interpreter resources is not enforced for nesting depth.
soupsieve/css_parser.py
CSSParser.parse_pseudo_open (line 985): sel.selectors.append(self.parse_selectors(iselector, index, flags))
CSSParser.parse_selectors (line 1137): recursive target; dispatches pseudo-class tokens back into parse_pseudo_class -> parse_pseudo_open
CSSParser.increment_count (lines 707-712): SELECTOR_LIMIT token guard; increments once per nesting level, cannot bound depth
Impact
Any application that compiles or evaluates untrusted CSS selectors through Soup Sieve (directly or via Beautiful Soup) is affected. No authentication, privilege, or user interaction is required; the failure occurs during selector parsing, before any document is matched. A single crafted selector of about 1.6 KB reliably raises RecursionError. The demonstrated capability is a transient denial of the individual request, task, or invocation that parsed the malicious selector: the exception propagates uncaught unless the caller handles RecursionError (or a broad except Exception). The RecursionError in CPython 3.x is a recoverable stack unwind, not an interpreter crash or persistent resource exhaustion, and it carries no confidentiality or integrity impact. Applications following the parser's documented error pattern (catching ValueError / SelectorSyntaxError) will not catch it, so a repeated stream of such selectors can plausibly cause repeated per-request failures; this per-request availability effect is the demonstrated consequence, not a service-wide or amplified outage.
Proof of Concept
Requires only an installed Soup Sieve (any release from 0.4 through 2.10) and a default CPython recursion limit of 1000. No Beautiful Soup document or network setup is needed.
import soupsieve as sv
# ~330 levels of nested :is() around a single tag: about 1.6 KB of input.
depth = 330
selector = ":is(" * depth + "a" + ")" * depth
print("selector length:", len(selector))
sv.compile(selector)
selector length: 1651
Traceback (most recent call last):
...
File ".../soupsieve/css_parser.py", line 985, in parse_pseudo_open
sel.selectors.append(self.parse_selectors(iselector, index, flags))
RecursionError: maximum recursion depth exceeded
The same failure was reproduced with :not(), :where(), and :has(), and through BeautifulSoup(...).select(selector). A negative control at depth 100 compiles successfully with a token count of 101, confirming the token guard is far from firing.
Attack Chain
- Exposure: The application exposes
soupsieve.compile()/select()or Beautiful Soup's.select()/.select_one()to attacker-influenced CSS selector input. These are the library's primary public API and ship enabled by default; Soup Sieve is an automatic dependency of Beautiful Soup 4. - Control: The attacker fully controls the selector string. The nested pseudo-class pattern (
:is(:is(...a...))) survives tokenization and normalization unchanged. - Path:
compile()reachesCSSParser.process_selectors()and thenparse_selectors(), which dispatches each pseudo-class token throughparse_pseudo_class()intoparse_pseudo_open(), which callsparse_selectors()again, once per nesting level. - Guard: The only resource guard is the
SELECTOR_LIMITtoken counter, which reaches about 330 at the failing depth versus its limit of 8192, so it never fires. No recursion-depth counter and noRecursionErrorhandler exist anywhere in the package. - Primitive: Python's recursion limit is exceeded during parsing, raising an uncontrolled
RecursionError. - Result: The unhandled
RecursionErrorpropagates to the caller, failing the request, task, or process invocation that parsed the selector, outside the parser's documentedValueError/SelectorSyntaxErrorerror contract.
Bypass Evidence
The relevant existing guard is the SELECTOR_LIMIT = 8192 token counter in increment_count(). It does not block this path: it increments once per nesting level, so at the failing depth of about 330 the count is roughly 330 (about four percent of 8192), verified by instrumenting increment_count and observing a maximum count of 329 at the point RecursionError was raised. A search of the package for RecursionError and RuntimeError handlers returns no matches, so nothing catches the error internally, and functools.lru_cache on the compile path does not cache exceptions. Attempts to invalidate the finding were made and failed: raising the interpreter recursion limit only shifts the required nesting depth (a proportionally larger but still small selector), and a broad except Exception in the caller does catch the error but the parser's documented contract raises only ValueError / SelectorSyntaxError, so callers coded to that contract remain exposed. This is a distinct weakness from the prior comma-separated-list memory-exhaustion advisory: that issue exhausts heap memory through linear object expansion and was addressed by the token count limit, whereas this issue exhausts the call stack through unbounded recursion depth and is not addressed by any released version, including 2.8.4 which was independently confirmed to still raise RecursionError on this input.
Affected Versions
Ecosystem: pip (PyPI)Package: soupsieveConfirmed affected range: >= 0.4, <= 2.10Latest release checked: soupsieve 2.10 (PyPI, latest published release as of 2026-09-25)Fix status: not fixed in any released version
The recursive parse_pseudo_open to parse_selectors structure is present in the earliest PyPI release (0.4) and in every subsequent release through the current 2.10, verified against the published PyPI source artifacts. Version 2.5, release 2.8.4 (which added that token-count limit), and 2.10 were each executed and confirmed to raise RecursionError on the crafted input. No released version adds a recursion-depth limit; the token-count SELECTOR_LIMIT introduced in 2.8.4 is architecturally unable to bound nesting depth. There is no registry or tag disagreement and no package or module path change.
Possible Solution
Python's RecursionError is sometimes a contested issue related to security. The Soup Sieve project does not consider it "uncontrolled" and allows it to be raised to indicate Python's maximum recursion depth was reached, and allows it to fail to prevent overflows, a much worse and real security vulnerability. With that said, the algorithm can be reworked to be non-recursive, allowing the selector limit check to now capture and raise an exception. In addition, the limit could be exposed to the user to give them better control.
Mitigation
For those on older versions where the algorithm is still recursive, a try/except block that captures RecursionError can be added around calls if accepting untrusted user input is allowed.
Note
This issue was fixed in version 3.0.0, which has been yanked from the PyPI registry. Use versions 3.0.2 and above to upgrade.
Related Vulnerabilities
Other vulnerabilities affecting the same packages