Repository navigation
Update dependency soupsieve to v4 [SECURITY] - #1486
Open
renovate[bot] wants to merge 1 commit into
Open
renovate[bot] wants to merge 1 commit into
renovate[bot] wants to merge 1 commit into
Conversation
renovate
Bot
force-pushed
the
renovate/pypi-soupsieve-vulnerability
branch
from
November 13, 2026 18:30
bee5a26 to
cdabe7d
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Note
This PR body was truncated due to platform limits.
This PR contains the following updates:
2.8.3→4.0Soup Sieve has Memory Exhaustion via Large Comma-Separated Selector Lists
CVE-2026-49476 / GHSA-2wc2-fm75-p42x
More information
Details
Summary
The CSS selector parser in soupsieve (the CSS selector engine for Beautiful Soup 4) allocates unbounded memory when compiling large comma-separated selector lists. An attacker who can supply a crafted CSS selector string to
soupsieve.compile()or Beautiful Soup's.select()/.select_one()can cause the application to allocate hundreds of megabytes of heap memory from a relatively small input, leading to memory exhaustion and denial of service.To be completely transparent, AI tools helped surface this issue. However, it was independently reproduced and carefully validated. Researchers follow responsible disclosure practices and originally shared this report privately.
A 500 KB selector string triggers allocation of approximately 244 MB of heap memory - a 488x— amplification ratio**.
Details
Affected code:
soupsieve/css_parser.py, lines ~204, 925, 1106The soupsieve CSS parser splits comma-separated selector lists and creates one
CSSSelectorobject per list item. EachCSSSelectorobject contains parsed selector data structures includingSelectorList,Selector, and associated tag/attribute/pseudo-class metadata.When a selector string such as
a,a,a,...(with 250,000 comma-separated items) is passed tosv.compile(), the parser:Selectorobject with all associated metadata (line ~925)SelectorList(line ~204)Root cause: No limit is enforced on the number of selectors in a comma-separated list. The parser will attempt to parse and store an arbitrary number of selectors, with each selector object consuming approximately 976 bytes of heap memory. The total allocation scales linearly with the number of list items, but the amplification ratio (output memory / input bytes) is extremely high because each single-character selector like
aexpands into a complex object graph.Attack surface: Any application that passes user-supplied CSS selectors to
soupsieve.compile()or Beautiful Soup's.select()/.select_one().Proof of Concept
Impact
Severity: High
An attacker can exhaust available memory on any server-side Python application that compiles user-supplied CSS selectors via soupsieve. This can cause:
MemoryErrorexception if the system runs out of addressable memoryScalability of attack: The memory allocation scales linearly - doubling the selector count doubles memory usage. An attacker can tune the payload to exactly exhaust a target's memory limits. Multiple concurrent requests multiply the effect.
Downstream exposure: soupsieve is an automatic dependency of
beautifulsoup4, one of the most widely installed Python packages. Any web application accepting CSS selectors from users (e.g., web scraping APIs, content filtering tools, CMS preview features) is potentially affected.Credit
Discovered by a security research team from the University of Sydney, focused on detecting open source software vulnerabilities.
Liyi Zhou: https://lzhou1110.github.io/
Ziyue Wang: https://zyy0530.github.io/
Strick: https://str1ckl4nd.github.io/
Maurice: https://maurice.busystar.org/
Chenchen Yu: https://7thparkk.github.io/
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Soup Sieve: Regular Expression Denial of Service (ReDoS) via Selector Parser
CVE-2026-49477 / GHSA-836r-79rf-4m37
More information
Details
Summary
The CSS selector parser in soupsieve (the CSS selector engine for Beautiful Soup 4) contains a regular expression vulnerable to catastrophic backtracking. When processing an attribute selector with an unterminated quoted value, the
VALUEregex pattern incss_parser.pyenters exponential backtracking. A payload of only 300 bytes causes the regex engine to hang for over 3 seconds, enabling a trivial Regular Expression Denial of Service (ReDoS) attack.To be completely transparent, AI tools helped surface this issue. However, this was independently reproduced and carefully validated.
Any application that passes untrusted CSS selector strings to
soupsieve.compile()or Beautiful Soup's.select()/.select_one()is affected.Details
Affected code:
soupsieve/css_parser.py, line ~121 -RE_VALUES/VALUEregex patternThe soupsieve CSS parser uses a compiled regular expression to tokenise attribute selector values. This pattern matches both quoted strings (
"value"or'value') and unquoted identifiers. The regex contains alternation branches for:"[^"\\]*(?:\\.[^"\\]*)*"'[^'\\]*(?:\\.[^'\\]*)*'When an attribute selector contains an unterminated quoted value - e.g.,
[a="xxxx...(opening"but no closing") -” the regex engine attempts to match the quoted-string branch. After that branch fails (no closing quote), the engine backtracks and attempts to match the remaining input against subsequent alternation branches and parent patterns. The structure of the pattern causes catastrophic backtracking where the number of backtracking steps grows exponentially with the length of the content between the opening quote and the end of the string.Root cause: The regex pattern does not anchor or guard against the case where a quoted string is never terminated. The overlapping character classes across alternation branches create exponential backtracking when the quoted-string branch fails on long input.
Key characteristics:
Proof of Concept
Safe testing variant with timeout:
Impact
Severity: High
An attacker can cause CPU exhaustion on any server-side Python application that compiles user-supplied CSS selectors via soupsieve. The attack is particularly dangerous because:
[a="xxx...)Deployment impact: In threaded or async web applications, a single malicious request blocks a worker thread for the duration of the backtracking. An attacker can submit multiple concurrent requests to exhaust all available workers, causing complete service denial. The small payload size makes the attack easy to deliver and difficult to detect via request size limits.
Downstream exposure: soupsieve is an automatic dependency of
beautifulsoup4, one of the most widely installed Python packages. Any web application, API, or service that accepts CSS selectors from users is potentially affected.Credit
The vulnerability was discovered by a security research team from the University of Sydney, whose focus is detecting open source software vulnerabilities.
Liyi Zhou: https://lzhou1110.github.io/
Ziyue Wang: https://zyy0530.github.io/
Strick: https://str1ckl4nd.github.io/
Maurice: https://maurice.busystar.org/
Chenchen Yu: https://7thparkk.github.io/
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Soup Sieve: Polynomial-time ReDoS (O(n²)) in the whitespace/comment trimming regex
RE_WS_END(triggers on VALID selectors)CVE-2026-85999 / GHSA-j934-xhv5-fg8f
More information
Details
Summary
Before tokenizing,
selector_itertrims leading/trailing whitespace and comments by running two regexes over the whole raw selector with.search(). The trailing one,RE_WS_END = re.compile(r'{WSC}*$'), is anchored only at the end ($), not the start. Because.search()retries the pattern at every offset, a long run of whitespace or CSS comments that is not sitting exactly at the end of the string makes each retry greedily consume the run and then fail$, producing O(n²) time. This triggers on perfectly valid selectors — e.g. a descendant combinator with a long whitespace gap,a+" "*n+b— so no malformed input is required. A single valid ~20 KB selector stalls the interpreter for ~10 s of CPU.Trust model (Q0)
The selector string is the input, reaching this code via
soupsieve.compile(), thesoupsieve.select/iselect/match/filterhelpers, and BeautifulSoup'ssoup.select(selector)/soup.select_one(selector). Exploitable wherever an application passes a user-controlled CSS selector to BeautifulSoup/soupsieve. Applications using only hard-coded selectors are unaffected.Root cause (exact anchors) —
src/soupsieve/css_parser.pyWSC = (?:{WS}|{COMMENTS}). ForRE_WS_END = (?:WS|COMMENTS)*$,.search()walks start offsets 0..n. Whenever the offset lands inside a long whitespace/comment run,(?:WS|COMMENTS)*greedily consumes to the run's end, then$fails (a non-whitespace char follows), the engine backtracks the whole run, the offset advances by one, and the work repeats — O(n) offsets × O(n) per attempt = O(n²).RE_WS_BEGINavoids this because^pins it to a single start offset.The intent (trim trailing whitespace/comments) can be met with an anchored/loopless approach; the current unanchored
.search()of a*$pattern is the defect.Reproduction environment (discipline #12 — published artifact)
751c57b(2.9,PYTHONPATH=src):cd src && python3 ../poc/poc_redos_ws_trim.py.soupsieve 2.8.4(freshuv pip install soupsieve beautifulsoup4):cd poc && ../.venv-published/bin/python poc_redos_ws_trim.py→ same O(n²) (evidence:poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log).PoC (
poc/poc_redos_ws_trim.py)Isolated confirmation that the cost is in
RE_WS_END.searchspecifically (poc/isolate_ws_trim.py):RE_WS_ENDon"div"+" "*n+">"is O(n²) (2000→100 ms, 4000→448 ms, 8000→1622 ms, 16000→6719 ms), while the start-anchoredRE_WS_BEGINon" "*n+"x"stays linear (32000→1.5 ms). Profilingcompileshows the entire wall time in 2re.Pattern.searchcalls, not.match.Evidence — HEAD 2.9 (verbatim
poc/evidence_redos_ws_trim.log)Evidence — published 2.8.4 (verbatim
poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log)Impact — calibrated
compile()/select()call on an attacker-controlled selector, triggered by a long internal whitespace or CSS-comment run. ~8 KB → ~1.6 s; ~20 KB → ~10 s; scaling ~×4 per input doubling. Notably fires on WELL-FORMED selectors, so it does not depend on a parser error path.Distinction from the IDENTIFIER/VALUE ReDoS
This is a separate root cause and a separate fix: the cost here is entirely in the
RE_WS_END = {WSC}*$trim step run with.search()before tokenizing (measured inre.Pattern.search), whereas the IDENTIFIER/VALUE issue is adjacent-quantifier backtracking during token.match(). They can be fixed independently.Remediation
.search()of{WSC}*$, scan trailing whitespace/comments from the end directly (e.g. reverse scan, orre.compile(r'^{WSC}*').matchon a reversed-equivalent), so no per-offset retry occurs.*$search.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Soup Sieve: Polynomial-time ReDoS (O(n²)) in the
IDENTIFIER/VALUEselector sub-patternsCVE-2026-86000 / GHSA-gjv8-xp57-g29c
More information
Details
Summary
soupsieve compiles CSS selector strings with a set of hand-written regular expressions. The shared
IDENTIFIERsub-pattern (also embedded inVALUE, and therefore in attribute selectors) places two adjacent quantified groups over overlapping character classes:(?:[classA]|ESC)+(?:[classB]|ESC)*, where both classes match ordinary identifier characters such asa. When a selector contains a long identifier/value run that must ultimately fail to match (e.g. an attribute value with no closing], or an identifier followed by an invalid character), the regex engine backtracks across all O(n) ways to split the run between the+group and the*group, giving O(n²) parse time. A single attacker-controlled selector of a few kilobytes stalls the interpreter for many seconds of CPU; tens of kilobytes reach minutes.Trust model (Q0)
The selector string is the input. It reaches this code via
soupsieve.compile(),soupsieve.select/iselect/match/filter, and — most commonly — BeautifulSoup'ssoup.select(selector)/soup.select_one(selector), which delegate to soupsieve. This is exploitable in any application that passes a user-controlled CSS selector to BeautifulSoup/soupsieve (scrapers that accept selectors, no-code extraction tools, admin/query UIs). Applications that only use hard-coded selectors are not affected.Root cause (exact anchors) —
src/soupsieve/css_parser.py[^\x00-\x2f\x30-\x40\x5B-\x5E\x60\x7B-\x9f]excludes digits (0x30-0x39); classB[^\x00-\x2c\x2e\x2f\x3A-\x40\x5B-\x5E\x60\x7B-\x9f]allows digits. The intent is "first char not a digit, remaining chars may be digits."a= 0x61). The construct is therefore effectively(?:C)+(?:C)*over an overlapping class C — the canonical adjacent-quantifier shape that backtracks quadratically on a failing match.The quadratic only manifests when the overall match must fail.
IDENTIFIERmatched greedily on"a"*nsucceeds in linear time (~1 ms at n=32000). Anchoring it so a following element is mandatory and fails (IDENTIFIER + "$"against"a"*n + "!") reproduces the O(n²) directly: n=2000 → 44 ms, 4000 → 257 ms, 8000 → 743 ms, 16000 → 2944 ms (~×4 per ×2). Profilingcompile("[a=" + "a"*4000)shows only 12re.matchcalls consuming 2.685 s — i.e. the cost is inside a single regex match, confirming regex backtracking (not loop overhead).Reproduction environment (discipline #12 — published artifact)
751c57b(2.9,PYTHONPATH=src):cd src && python3 ../poc/poc_redos_compile.py.soupsieve 2.8.4(freshuv pip install soupsieve beautifulsoup4):cd poc && ../.venv-published/bin/python poc_redos_compile.py→ same O(n²) (evidence:poc/evidence_redos_compile_PUBLISHED_2.8.4.log).PoC (
poc/poc_redos_compile.py)End-to-end note:
bs4.BeautifulSoup(html).select(payload)reaches the samecompile()path, so the stall is triggerable directly through BeautifulSoup with a user-supplied selector. Verified on bs4 4.15.0 + soupsieve 2.8.4:soup.select("[a=" + "a"*6000)took ~5.0 s for one call (evidence:poc/evidence_bs4_select_PUBLISHED_2.8.4.log).Evidence — HEAD 2.9 (verbatim
poc/evidence_redos_compile.log)Evidence — published 2.8.4 (verbatim
poc/evidence_redos_compile_PUBLISHED_2.8.4.log)Impact — calibrated
compile()/select()call on an attacker-controlled selector. ~8 KB → ~8 s; ~12 KB → ~17 s; scaling ~×4 per input doubling. A handful of such requests exhausts a worker/thread and degrades or stalls the service (single-threaded regex holds the GIL).Remediation
IDENTIFIER: match a single leading non-digit character then the remaining class once, e.g.(?:-?(?:[classA]|ESC)(?:[classB]|ESC)*|--(?:[classB]|ESC)*), so no+/*pair spans the same characters.(?>...),*+) to forbid backtracking into the identifier run.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Soup Sieve: General sibling combinator inside :has() matches untrusted HTML in quadratic time
CVE-2026-126192 / GHSA-4j63-6c4x-wfg4
More information
Details
Summary
Matching a selector that puts a general-sibling combinator inside
:has(), such asdiv:has(a ~ b), against untrusted HTML runs in O(n²) time in the number of sibling elements. A single small document with a few thousand siblings ties the process up for a minute of CPU, so an application that runs a fixed selector of this shape over HTML it doesn't control can be stalled by one request.:hasis powerful, but much in the same way poor regular expressions can be crafted to cause catastrophic backtracking with greedy capturing, has can be used to create non-ideal scenarios that can cause large portions of the tree to be evaluated for one or many elements if not used thoughtfully.This behavior is not exclusive to Soup Sieve, but browsers make note of these potential performance issues themselves: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Selectors/:has#performance_considerations.
With all of that said, there is room for improvements that can in some cases greatly improve performance.
Details
:has(a ~ b)asks "does this element contain anathat has a later siblingb?". When nobis present, soupsieve can't short-circuit, and it answers the question with a nested scan:match_future_childwalks every candidateaunder the scope, and for each one the general-sibling branch ofmatch_future_relationswalks all of thata's following siblings looking forb. With n siblings that is (n-1) + (n-2) + ... + 1 comparisons, i.e. O(n²).The adjacent-sibling form
div:has(a + b)stays linear, because its branch checks only the one immediately following sibling per candidate. So the cost is specific to the general-sibling~(and~nested inside:has), and it shows up on the common case where the right-hand side simply isn't there.Proof of concept
Installed from PyPI:
pip install soupsieve==2.9.2 beautifulsoup4.Measured:
Time quadruples for each doubling of n. The n=8000 input is a ~56 KB HTML fragment.
Impact
Availability. Any caller that evaluates a fixed selector containing a general sibling under
:has()over attacker-controlled markup can be forced to spend minutes of CPU on a small input. The selector is developer-authored and ordinary; the untrusted part is the HTML, which is soupsieve's normal input.A single-pass evaluation of the sibling run (remember whether an
ahas been seen while scanning the siblings once) would make this case linear.Mitigation
Quadratic time for
:hasin some circumstances is expected and not a bug. While we've made optimizations and there may be more in the future, the way:hasis designed to operate will never perform in linear time in all circumstances. Users should thoughtfully apply the use of:has.The anchor selector (the
AinA:has(B)) should not be an element that has too many children. Additionally, too general an anchor, such as*, can cause:has()to be applied to every element.Further, it is recommended that the inner selector (the
BinA:has(B)) should use combinators like>or+to limit traversal. When the selector inside:has()is not tightly constrained, Soup Sieve might need to traverse the entire subtree of the anchorelement to check the condition.
If the risk for a given project is too great, user input can be scanned for usage of
:hasand be denied. Calls can also be wrapped in a timeout to prevent long-running processes as well.Severity
CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:NReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Soup Sieve: Polynomial-time denial of service in the descendant combinator
CVE-2026-126190 / GHSA-mmhr-3c5m-f64f
More information
Details
Summary
Matching a selector that uses the descendant combinator (
a b) takes time that grows polynomially with how deeply the document is nested, and each extra descendant combinator in the selector raises the degree by one. A selector withkdescendant combinators takesO(n^(k+1))time on a chain ofnnested elements.An attacker who controls the HTML or XML that an application passes to Soup Sieve (directly, or through Beautiful Soup's
select(),select_one(),css.select(), etc.) can send a very small, deeply nested document that keeps a CPU busy for seconds to minutes against an ordinary selector such assection div div.Details
To match
a b, Soup Sieve checks each element againstband then walks up through its ancestors looking for one that matchesa. When the selector has more descendant combinators, the rest of the selector is tried again from each ancestor on that walk, and so on for every combinator. Nothing was reused between walks, so the same ancestors were matched against the same part of the selector over and over.The worst case is when the leftmost part of the selector never matches, as in
section div divagainst a document of nested<div>with no<section>: every walk goes all the way to the root and no search can stop early.Timings
Example timings with
html.parseron Soup Sieve 3.0.4 and 4.0, using a document of nested<div>(5 bytes each) and no matches:section divsection divsection div divsection div divsection div div divsection div div divNone of these selectors use unusual syntax.
Proof of concept
On affected versions, doubling the nesting depth multiplies the time by about 2^(k+1), where
kis the number of descendant combinators: about 4x forsection div, 8x forsection div div, and 16x forsection div div div.Impact
Applications are affected if they run selectors with descendant combinators against documents that untrusted users can influence, such as scrapers, HTML sanitizers or filters, and services that process uploaded or fetched markup. Descendant combinators are among the most common parts of CSS selectors, and a document of about 1 KB is enough to tie up a worker for close to a minute. Applications that only run selectors against trusted documents are not affected.
Patches
Fixed in 4.0 by facelessuser/soupsieve#351. Whether an element is, or has an ancestor that is, a match for part of a selector depends only on its parent, so Soup Sieve now caches that result per element and relation for every element visited on the walk. Each descendant combinator is then linear in the size of the document. Results are unchanged, and ordinary documents are no slower.
Caching is enabled by default, so no changes are needed beyond upgrading. The
NOCACHEflag is a temporary fallback for users who run into problems with caching, and it is planned for removal. It uses the old uncached matching, so selectors compiled withNOCACHEare still subject to polynomial time and shouldn't be used with descendant combinators on untrusted input.Workarounds
If users can't upgrade:
document is known, the child combinator (
a > b) only looks at the parent and is not affected.Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:HReferences
This data is provided by the GitHub Advisory Database (CC-BY 4.0).
Soup Sieve: Uncontrolled recursion in CSS parser lets nested pseudo-class selectors exhaust the Python call stack
CVE-2026-126193 / GHSA-7g44-g9rc-x53w
More information
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 tosoupsieve.compile(),soupsieve.select(), or Beautiful Soup'ssoup.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 uncontrolledRecursionError. Because this exception is not part of the parser's documented error contract, applications that catch onlyValueErrororSelectorSyntaxErrorsee 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 intoparse_selectorsto parse the pseudo-class body. Every nesting level therefore adds Python stack frames, and no counter, depth guard, orRecursionErrorhandler bounds this descent. The token-count guardSELECTOR_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.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 handlesRecursionError(or a broadexcept Exception). TheRecursionErrorin 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 (catchingValueError/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.
The same failure was reproduced with
:not(),:where(), and:has(), and throughBeautifulSoup(...).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
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()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.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.RecursionError.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 = 8192token counter inincrement_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 instrumentingincrement_countand observing a maximum count of 329 at the pointRecursionErrorwas raised. A search of the package forRecursionErrorandRuntimeErrorhandlers returns no matches, so nothing catches the error internally, andfunctools.lru_cacheon 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 broadexcept Exceptionin the caller does catch the error but the parser's documented contract raises onlyValueError/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 raiseRecursionErroron 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 versionThe recursive
parse_pseudo_opentoparse_selectorsstructure 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 raiseRecursionErroron the crafted input. No released version adds a recursion-depth limit; the token-countSELECTOR_LIMITintroduced 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
RecursionErroris 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
RecursionErrorcan 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.
Reported by zx (GitHub: @manus-pi).
Severity
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LReferences