← back to lessons

Discourse full-surface differential IDOR/BAC: WALK CLEAN (the 3rd cash-gated blue-chip)

Date: 2026-06-23 Target: Discourse (bitnamilegacy/discourse:latest), self-hosted docker, disposable Tool: fuzzer-arm v0.3 (new adapter targets/discourse.py + run_discourse.py + run_discourse_curated.py) Cash-gate: Discourse runs a PAYING HackerOne bounty (bounty table) + source is 100% OSS & explicitly in-scope -> a source-reproducible IDOR on a self-hosted instance is cash-eligible (GitLab posture).

Setup (banked recipe - Discourse now a supported fuzzer-arm target)

Result: CLEAN (0 confirmed IDOR, 0 real BAC)

Focused re-run (run_discourse_curated.py) over the 24 high-value pivots with VALID A=200 baselines: every private pivot A=200 (alice) / B=403-or-404 (bob): /t/{id}, /raw/{id}, /t/{id}/posts, /posts/{id}, /posts/{id}/raw, /c/{id}/show (private category), PM topic+post, /topics/private-messages/{user}, /u/{user}/emails. Write-BAC (bob DELETE alice's topic/post/PM) -> B=403/422 (blocked). Benign non-flags: public profile/group/summary (anon=200, no marker). FLAGGED=0.

METHODOLOGY GOTCHA banked (the load-bearing lesson of this run)

The FIRST full run (932 endpoints, no throttle) was INVALIDATED by Discourse's rate limiter: alice herself got A=429 on most private pivots -> 506/932 inconclusive (no clean ownership baseline). A flood-fuzz against a rate-limited target produces A!=200 and a FALSE "clean" (you never actually tested the pivot). Discourse's max_reqs_per_ip_mode/max_admin_api_reqs_per_minute are BOOT-TIME GlobalSettings read from the conf file (NOT the DISCOURSE_* env -> setting env had no effect; flipping them needs a ~10min asset re-precompile). FIX THAT WORKED: provision -> redis-cli FLUSHALL (resets the sliding-window counters) -> probe ONLY the curated pivots, throttled 0.3s -> 72 calls fit under the ~100/min budget -> clean baselines. RULE: on a rate-limited target, do NOT blast the full surface; run a FOCUSED throttled probe of the high-value pivots, and treat a high A!=200 (esp A=429) inconclusive count as "coverage failed", not "target clean".

ENGINE FIX shipped this run (differ.probe mutating branch)

anon-succeeds-on-write -> classify public-ok (benign), NOT BAC. The first Discourse run flagged 8 "BAC" that were PUBLIC-by-design writes (anon=200): inbound email webhooks (mailjet/postmark/sendgrid/ sparkpost), signup (/users,/u), public /categories/search, /pageview analytics beacon. The status-based write heuristic must check anon FIRST: if anon can do it too, it's public, not cross-tenant BAC. Validated: vulnapp positive control still 8/8 (its planted writes have anon=401 -> still flag). This + the earlier follow/star + /self|/me|/current allowlist = the write-pass FP set is now tight.

Reflection

Session net: GitLab + Mattermost + Discourse = 3 cash-gated blue-chips, ALL swept clean. The fresh tier is credit-only. Stop-condition met -> strategic readout to operator. NO auto-submit.

Generated 2026-07-02 13:15:04 UTC | auto-sync /15min