java-topology/defects/bleach/patch/bleach-MOADX-0001-sanitize-css-gauntlet-redos.md
russell@unturf.com 9325c98470 moad-sweep: 9 initial findings — MOAD-0005 (Hungry Regex) x1, MOAD-0006 (Thundering Herd) x8
MOAD-0005 candidates (ReDoS):
- bleach-MOADX-0001: sanitize_css O(2^N) — 71-char input causes 12s hang HIGH

MOAD-0006 candidates (Thundering Herd / cache stampede):
- hibernate-orm-MOADX-0001: QueryInterpretationCacheStandardImpl HQL plan cache HIGH
- elasticsearch-MOADX-0001: EnrichCache "intentionally non-locking" enrich search HIGH
- hadoop-MOADX-0001: FederationJCache/FederationCaffeineCache YARN default config HIGH
- celery-MOADX-0001: BaseBackend.get_task_meta chord fan-in Redis stampede HIGH
- traefik-MOADX-0001: CNAMEFlatten data race + N*30s DNS stampede HIGH
- kafka-MOADX-0001: CachedConnectors.lookup classloader scan MEDIUM
- spring-MOADX-0001: AbstractFallbackCacheOperationSource @Cacheable cold start MEDIUM
- django-MOADX-0001: cached.Loader.get_template template compile MEDIUM

CLEAN: Consul (singleflight), Kubernetes (RWMutex), Prometheus (per-scraper),
Pulsar (AsyncLoadingCache), Cassandra (LoadingCache), Flink (CAS loop),
ZooKeeper (synchronized), Airflow (hand-rolled singleflight)
2026-03-29 21:09:07 -04:00

3.7 KiB
Raw Blame History

UNDF: (pending)

bleach-MOADX-0001: BleachSanitizerFilter.sanitize_css — ReDoS O(2^N) on style attribute

MOAD-0005 Candidate — The Hungry Regex

Field Value
ID bleach-MOADX-0001
Severity HIGH
Ecosystem bleach (Python HTML sanitizer)
File bleach/sanitizer.py
Lines 553558
Pattern ^([-/:,#%.'"\sa-zA-Z0-9!]|\w-\w|'[\s\w]+'\s*|"[\s\w]+"|\([\d,%\.\s]+\))*$
Trigger style="a-a-a-a-...-a-@" — 71 chars causes ~12-second hang
Input vector HTML style attribute value from user-submitted content
Input length limit none

Defect

bleach/sanitizer.py, function sanitize_css, lines 553558:

parts = style.split(';')
gauntlet = re.compile(
    r"""^([-/:,#%.'"\sa-zA-Z0-9!]|\w-\w|'[\s\w]+'\s*|"[\s\w]+"|\([\d,%\.\s]+\))*$"""
)

for part in parts:
    if not gauntlet.match(part):
        return ''

The pattern ^(A|B|C|D|E)*$ where:

  • A = [-/:,#%.'"\sa-zA-Z0-9!] — single char (broad set including -)
  • B = \w-\w — three chars: word-hyphen-word

The alternation A|B creates exponential ambiguity: a string like a-a-a-a- can be parsed as [a][-][a][-] (all via A) or [a-a][-][a-] (via B then A) or countless other combinations. For each prefix position, the regex engine must explore both interpretations.

When the input does NOT match (the trailing $ fails), Python's NFA backtracks through all O(2^N) parse trees, where N is the number of a- repetitions.

Measurement (Python 3.x, bleach 3.0.0):

Input length Time
41 chars (a-×20 + @) 0.011s
51 chars (a-×25 + @) 0.11s
61 chars (a-×30 + @) 0.97s
71 chars (a-×35 + @) 12.8s

Growth factor: ~10× per 10 chars = O(2^N).

Attack payload:

style="a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-a-@"

71-char style attribute causes a ~12-second Python thread hang.

Triggering characters for the non-matching terminal: @, $, ^, ~, `, |, \, {, }, <, > — all realistic HTML/CSS chars.

Input path: HTML user content → bleach.clean()BleachSanitizerFilter.sanitize_token()sanitize_css()gauntlet.match(part).

Fix

The ambiguity is between A matching - as a single character and B matching \w-\w as a three-character sequence. Eliminating the single-char - from group A and keeping it only in B removes the ambiguity, OR use atomic grouping (Python 3.11+ via re module possessive (?>...)), OR replace with a proper CSS tokenizer.

Minimal fix — remove - from the single-char group:

gauntlet = re.compile(
    r"""^([-/:,#%.'"\sa-zA-Z0-9!]|(?<!\w)-(?!\w)|\w-\w|'[\s\w]+'\s*|"[\s\w]+"|\([\d,%\.\s]+\))*$"""
)

Better fix — switch to a proper CSS tokenizer (e.g., tinycss2 or cssutils) instead of regex-based validation. Bleach already depends on html5lib; a small CSS tokenizer dependency is acceptable.

Best fix — impose input length limit before matching:

MAX_STYLE_LEN = 1000
for part in parts[:100]:     # limit number of semicolon-separated parts
    part = part[:MAX_STYLE_LEN]
    if not gauntlet.match(part):
        return ''

The second pattern on line 561 (^\s*([-\w]+\s*:[^:;]*(;\s*|$))*$) does NOT exhibit catastrophic backtracking — its character class [^:;]* is bounded by the : and ; delimiters.

References

  • bleach 3.0.0: bleach/sanitizer.py lines 540575
  • Pattern first introduced: commit history shows this predates 3.0.0
  • Note: bleach has a history of ReDoS issues (CVE-2020-6802 linkifier, CVE-2021-23980 cleaner) but the CSS gauntlet pattern has not been separately reported.