
Loading

Loading
We use strictly necessary cookies to run this site, and analytics cookies to understand how it's used. See our Privacy Policy for details.
A GitHub-reviewed security advisory, not necessarily under active exploitation the way our CISA KEV advisories are, but a real, disclosed vulnerability in an open-source dependency.
Affected packages
Vulnerable: >= 0.4, <= 2.10
Patched in 3.0.2
Details
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.
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
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.
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.
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.:is(:is(...a...))) survives tokenization and normalization unchanged.compile() reaches CSSParser.process_selectors() and then parse_selectors(), which dispatches each pseudo-class token through parse_pseudo_class() into parse_pseudo_open(), which calls parse_selectors() again, once per nesting level.SELECTOR_LIMIT token counter, which reaches about 330 at the failing depth versus its limit of 8192, so it never fires. No recursion-depth counter and no RecursionError handler exist anywhere in the package.RecursionError.RecursionError propagates to the caller, failing the request, task, or process invocation that parsed the selector, outside the parser's documented ValueError / SelectorSyntaxError error contract.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.
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 versionThe 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.
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.
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.
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.
Reported by zx (GitHub: @manus-pi).
EPSS, exploit probability
Low0.31%
estimated chance of real-world exploitation in the next 30 days, higher than 23.9% of every CVE FIRST.org scores
Refreshed 11/14/2026, via FIRST.org's EPSS model, not CVSS, this measures likelihood of exploitation, not how severe it would be.
More pip advisories
Talk to us about this
Find out whether this dependency (or others like it) is actually reachable in your codebase.
Get help patching, or building dependency-scanning into your pipeline going forward.
A secure code review or supply chain review finds this before an advisory does.