GitHub Advisoryadvisory · PyPI
GHSA-j934-xhv5-fg8f Soup Sieve: Polynomial-time ReDoS (O(n²)) in the whitespace/comment trimming regex `RE_WS_END` (triggers on VALID selectors)
MEDIUM
Official page ↗
- Aliases
- CVE-2026-85999
- Ecosystem
- PyPI
- Packages
- soupsieve
- Severity
- Moderate
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
- Weaknesses
- CWE-1333, CWE-400
- Reviewed
- 2026-09-17
Affected packages
• PyPI soupsieve: < 2.9.0 → fixed in 2.9.0
Description
Summary
Before tokenizing, `selector_iter` trims 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()`, the `soupsieve.select/iselect/match/filter` helpers, and BeautifulSoup's `soup.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.py`
```python
line 185-186
RE_WS_BEGIN = re.compile(fr'^{WSC}*') # anchored at start -> .search() only tries pos 0 -> linear (safe)
RE_WS_END = re.compile(fr'{WSC}*$') # NOT anchored at start -> .search() tries every offset
selector_iter, lines ~1322-1326
m = RE_WS_BEGIN.search(pattern)
index = m.end(0) if m else 0
m = RE_WS_END.search(pattern) # "` is O(n²) (2000→100 ms, 4000→448 ms, 8000→1622 ms, 16000→6719 ms), while the start-anchored `RE_WS_BEGIN` on `" "*n+"x"` stays linear (32000→1.5 ms). Profiling `compile` shows the entire wall time in 2 `re.Pattern.search` calls, not `.match`.
Evidence — HEAD 2.9 (verbatim `poc/evidence_redos_ws_trim.log`)
```
soupsieve 2.9
VALID selector 'a' + ' '*n + 'b' (descendant combinator, lots of whitespace):
n=2000 len=2002 112.3 ms [ok]
n=4000 len=4002 411.5 ms [ok]
n=8000 len=8002 1602.9 ms [ok]
n=16000 len=16002 6464.1 ms [ok]
VALID-looking 'a' + '/*x*/'*n + 'b' (CSS comment run):
n=1000 len=5002 48.9 ms [SelectorSyntaxError]
n=2000 len=10002 194.8 ms [SelectorSyntaxError]
n=4000 len=20002 780.2 ms [SelectorSyntaxError]
n=8000 len=40002 3145.3 ms [SelectorSyntaxError]
[+] Single call: compile('a' + ' '*20000 + 'b') (len=20002)
[+] wall time = 10.23 s [ok]
```
Evidence — published 2.8.4 (verbatim `poc/evidence_redos_ws_trim_PUBLISHED_2.8.4.log`)
```
soupsieve 2.8.4
VALID selector 'a' + ' '*n + 'b':
n=2000 len=2002 102.7 ms [ok]
n=4000 len=4002 404.3 ms [ok]
n=8000 len=8002 1618.2 ms [ok]
n=16000 len=16002 6457.9 ms [ok]
[+] Single call: compile('a' + ' '*20000 + 'b') wall time = 10.11 s [ok]
```
Impact — calibrated
• Confirmed: quadratic CPU per `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.
• Realistic exposure: services that accept user-supplied CSS selectors and feed them to BeautifulSoup/soupsieve.
• NOT claimed: exponential blowup, memory corruption, or code execution. Availability (DoS) only, and only where selectors are attacker-influenced.
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 in `re.Pattern.search`), whereas the IDENTIFIER/VALUE issue is adjacent-quantifier backtracking during token `.match()`. They can be fixed independently.
Remediation
• Anchor or de-loop the trailing-trim step: instead of `.search()` of `{WSC}*$`, scan trailing whitespace/comments from the end directly (e.g. reverse scan, or `re.compile(r'^{WSC}*').match` on a reversed-equivalent), so no per-offset retry occurs.
• Alternatively strip whitespace/comments in a single forward tokenizing pass rather than with a pre-pass `*$` search.
• Defense-in-depth: cap selector length before compiling.
Related
Alias · CVE (1)
- CVE-2026-85999 Soup Sieve is a CSS selector library designed to be used with Beautiful Soup 4. vulnerability
References
- https://github.com/facelessuser/soupsieve/security/advisories/GHSA-j934-xhv5-fg8f web
- https://nvd.nist.gov/vuln/detail/CVE-2026-85999 advisory
- https://github.com/facelessuser/soupsieve/commit/cf198fcddc9230f06ed39f974eba0ce076b85cda web
- https://github.com/facelessuser/soupsieve package
- https://github.com/facelessuser/soupsieve/releases/tag/2.9 web