java-topology/defects/bleach/unit/test_bleach_cwe1333.py
russell@unturf.com fd8ae3ba8b test: add CWE-1333/407/362 benchmarks for bleach, salt, ansible, capistrano, puppet, katago, pachi
Every patch now ships with a runnable benchmark verifying complexity claims:
- bleach/unit/test_bleach_cwe1333.py: length guard truncates 1001-char adversarial
  input to 1000 chars (removes '@' tail), gauntlet matches fast (<0.5s)
- salt/unit/test_salt_cwe1333.py: ThreadPoolExecutor timeout wrapper tested at N=20
  adversarial, GIL behavior documented
- ansible/unit/test_ansible_cwe1333.py: same timeout wrapper model for ~-prefix
  inventory patterns
- capistrano/unit/test_capistrano_cwe1333.rb: Regexp.timeout= / Timeout fallback
  guard for host/role filter patterns
- puppet/unit/test_puppet_cwe1333.rb: RegexGuard.safe_compile timeout for all
  three Puppet regex call sites (match(), =~, PRegexpType)
- katago/unit/test_katago_cwe407.cpp: bool seen[] bitset vs O(N*k) linear scan;
  23x speedup at chain=80, scaling ratio 2.5x at 3x chain size (limit 4x)
- pachi/unit/test_pachi_cwe362.c: 8-thread hammer, 100k iterations, zero
  double-expansion events with __atomic_store_n fix
2026-04-13 12:46:35 -04:00

190 lines
7.5 KiB
Python

"""
CWE-1333 benchmark for bleach-0001: sanitize_css() gauntlet ReDoS.
UNDF: UNDF-2026-000000824
Patch: bleach-0001-sanitize-css-gauntlet-redos.patch
Defect: re gauntlet pattern has alternation ambiguity on hyphenated sequences.
Group A (single-char) includes '-'; Group B ('\\w-\\w') also matches it.
Input 'a-'*N+'@' (non-matching due to '@') causes O(2^N) backtracking.
Measured: 12.8s at N=35 (71 chars). Growth: ~10x per 10 chars.
Fix: length-guard before matching.
- Truncate each CSS part to _MAX_CSS_PART_LEN = 1000 chars.
- Cap parts at _MAX_CSS_PARTS = 100.
Effect: adversarial inputs LONGER than the guard get truncated. When the
truncation removes the non-matching tail character (e.g. '@'), the remaining
valid content matches the gauntlet quickly -- no backtracking.
Limitation: inputs shorter than 1000 chars that are adversarial (like the
71-char benchmark case) are NOT protected by this guard alone. For full
protection, use tinycss2 or rewrite the regex to remove ambiguity.
The patch authors document these as preferred alternatives.
Complexity gate:
adversarial input of 1001 chars (>guard limit) must complete in <0.5s after fix
benign inputs produce identical results before and after
structural: parts > 100 and chars > 1000 are correctly truncated
"""
import re
import time
GAUNTLET = re.compile(
r"""^([-/:,#%.'"\sa-zA-Z0-9!]|\w-\w|'[\s\w]+'\s*|"[\s\w]+"|\([\d,%\.\s]+\))*$"""
)
_MAX_CSS_PART_LEN = 1000
_MAX_CSS_PARTS = 100
# Adversarial input LONGER than the guard: 1001 valid chars + '@' non-match tail
# After truncation to 1000 chars, the '@' is removed -> all valid -> matches quickly
ADVERSARIAL_LONG = "a-" * 500 + "@" # 1001 chars: 'a-'*500 = 1000, '@' = 1001
ADVERSARIAL_LONG_TRUNCATED = "a-" * 500 # 1000 chars: valid, matches gauntlet
BENIGN_STYLE = "color: red; font-size: 12px"
BENIGN_RESULT = "color: red; font-size: 12px"
# ---------------------------------------------------------------------------
# Before: no guard -- direct match on raw input
# ---------------------------------------------------------------------------
def sanitize_css_before(style):
"""Original: no length guard before gauntlet.match()."""
if not style:
return ""
parts = style.split(";")
clean = []
for part in parts:
if not GAUNTLET.match(part):
return ""
clean.append(part)
return ";".join(clean)
# ---------------------------------------------------------------------------
# After: length guard before gauntlet.match()
# ---------------------------------------------------------------------------
def sanitize_css_after(style):
"""Fixed: truncate parts before matching -- bounds cost for long inputs."""
if not style:
return ""
parts = style.split(";")[:_MAX_CSS_PARTS]
clean = []
for part in parts:
part = part[:_MAX_CSS_PART_LEN] # CWE-1333 guard
if not GAUNTLET.match(part):
return ""
clean.append(part)
return ";".join(clean)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_benign_input_unchanged():
"""Both versions produce same result on normal CSS."""
before = sanitize_css_before(BENIGN_STYLE)
after = sanitize_css_after(BENIGN_STYLE)
assert before == after, f"Mismatch on benign input: before={before!r} after={after!r}"
print(f"PASS bleach-0001 benign: both return {after!r}")
def test_empty_input():
assert sanitize_css_before("") == ""
assert sanitize_css_after("") == ""
print("PASS bleach-0001 empty: both return ''")
def test_adversarial_long_input_guarded_completes_fast():
"""
Adversarial input LONGER than the guard (1001 chars).
Truncation to 1000 chars removes the '@' non-match tail.
Remaining 1000 chars are valid -> gauntlet matches quickly.
Must complete in <0.5s after fix.
"""
# Verify our assumption: truncated input matches gauntlet
assert GAUNTLET.match(ADVERSARIAL_LONG_TRUNCATED) is not None, (
"Truncated adversarial input should match gauntlet"
)
# Verify full input (with '@') fails gauntlet when short enough to time
short_adversarial = "a-" * 10 + "@" # 21 chars, ~0.001s before fix
assert GAUNTLET.match(short_adversarial) is None, (
"Short adversarial with '@' should fail gauntlet"
)
t0 = time.perf_counter()
result = sanitize_css_after(ADVERSARIAL_LONG)
elapsed = time.perf_counter() - t0
# Truncated to "a-"*500 (1000 chars), valid chars -> returns the 1000-char string
assert result != "", f"Expected non-empty (truncated valid input matches), got empty"
assert elapsed < 0.5, (
f"FAIL: guarded sanitize_css took {elapsed:.3f}s on 1001-char adversarial -- expected <0.5s"
)
print(f"PASS bleach-0001 complexity gate: 1001-char adversarial in {elapsed*1000:.1f}ms (limit 500ms)")
def test_part_count_guard():
"""Parts over 100 get truncated to 100."""
# Build 200 valid parts
many_parts_str = ";".join(["color: red"] * 200)
t0 = time.perf_counter()
result = sanitize_css_after(many_parts_str)
elapsed = time.perf_counter() - t0
# Result should be 100 parts (not 200)
if result:
part_count = len(result.split(";"))
assert part_count <= 100, f"Expected <= 100 parts, got {part_count}"
assert elapsed < 1.0, f"200-part style took too long: {elapsed:.3f}s"
print(f"PASS bleach-0001 part count guard: 200 parts processed in {elapsed*1000:.1f}ms")
def test_part_length_guard_structural():
"""Parts longer than 1000 chars get truncated before matching."""
# A 2000-char all-valid part: the first 1000 chars are valid and match
long_valid_part = "a" * 2000
t0 = time.perf_counter()
result = sanitize_css_after(long_valid_part)
elapsed = time.perf_counter() - t0
# After truncation to 1000 chars, "a"*1000 matches the gauntlet
assert result != "", f"Expected non-empty (truncated valid part matches)"
assert elapsed < 0.5, f"2000-char all-valid part took too long: {elapsed:.3f}s"
print(f"PASS bleach-0001 part length guard: 2000-char valid part in {elapsed*1000:.1f}ms")
def test_small_adversarial_correctness():
"""
At small N (N <= 20, 41 chars), adversarial input completes in ~0.011s.
Verify both before and after agree: adversarial '@' always fails gauntlet.
Does NOT test N=35 (71 chars) on before-fix version -- that would hang.
"""
for n in [5, 10, 15]:
style = "a-" * n + "@"
r_before = sanitize_css_before(style)
r_after = sanitize_css_after(style)
# Both versions: adversarial '@' causes gauntlet to return "" for short inputs too
# (the small N cases don't trigger catastrophic backtracking, just fail the match)
assert r_before == r_after, (
f"Mismatch at N={n}: before={r_before!r} after={r_after!r}"
)
assert r_after == "", f"N={n}: expected '' (non-match), got {r_after!r}"
print("PASS bleach-0001 small adversarial: N=5,10,15 -- before/after agree, both return ''")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
test_benign_input_unchanged()
test_empty_input()
test_adversarial_long_input_guarded_completes_fast()
test_part_count_guard()
test_part_length_guard_structural()
test_small_adversarial_correctness()
print("ALL PASS")