# SESSION-0026.4 — OHM security-audit subsession (newest endpoints, rate-limiting/abuse, frontend posture) - **Role:** subsession 0026.4 of OHM security-audit driver session 0026. - **Parent transcript:** SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md - **Mode:** READ-ONLY. No mutations. No secret bytes printed. - **Target:** rfc-app @ /Users/benstull/git/rfc-app, tag v0.24.0 (HEAD 28015ed). - **Window:** 2026-05-28T13-46 → 13-50 (America/Los_Angeles). ## Surface assigned Newest endpoints from session 0022 work (proposed_use_cases writes #26, PUT /api/me/last-state #29, Claude Haiku tag suggestions #27); rate limiting / abuse on OTC + beta-request + suggest-tags + propose; frontend token/secret handling, VITE_ public-vs-secret discipline, token storage, CSP/security headers, npm audit. ## Pre-state Fresh read-only checkout at v0.24.0. No prior findings in this subsession. ## Turn-by-turn arc 1. Enumerated backend/app + frontend/src layout, confirmed v0.24.0 tag. 2. Read tag_suggest.py in full, useLastState.js, main.py (full). - tag_suggest: output strictly constrained to the corpus tag universe (parse_reply maps replies back to canonical spellings, drops invented tags). Prompt-injection into the Haiku call can't escape the tag set; worst case is a junk/empty suggestion list. Per-user in-process rate limit (TAG_SUGGEST_RATE_MAX default 30/60s) + require_contributor gate + draft truncation (title 300, pitch/use_case 4000) + universe cap 200 bound the LLM cost surface. SOLID. 3. Located + read the #26 use_case writes (api.py propose, api_prs.py open-pr), the #29 last-state route, the suggest-tags route. - last-state: keyed on user.user_id from the session in BOTH read (/api/auth/me) and write (PUT /api/me/last-state). No user-supplied id anywhere → no cross-user read/write (IDOR). require_user gate. resume_enabled opt-out respected. route bounded 2048, state is a dict serialized as JSON. SOLID on authz. 4. Checked propose + open-pr authz: both require_contributor (granted, non-muted, authenticated). use_case bounded max_length=8000. SOLID. 5. Frontend rendering of proposed_use_case: grep found ProposalView.jsx:172 renders it via marked.parse() into dangerouslySetInnerHTML. No DOMPurify anywhere in the project (confirmed by grep across frontend/src + package.json). 6. KILLED a refutation attempt: verified marked default does NOT sanitize. Ran node one-liner: marked.parse('') emits the raw tag, and [a](javascript:alert(2)) emits a live javascript: href. So this is a real stored-XSS sink, not theoretical. 7. Confirmed the XSS audience: GET /api/proposals/{pr_number} is ANONYMOUS-readable (no require_ gate; uses current_user only for affordances). Admins reviewing a proposal (who hold merge/decline) render the payload → privileged-victim XSS. 8. Frontend token storage: grep for localStorage/sessionStorage/ document.cookie/Authorization/Bearer in api.js → ZERO hits. Auth is server cookie-session (credentials:'include'); device-trust + session cookies are HttpOnly+Secure+SameSite=Lax (main.py). No token in JS reach → XSS can't exfil an auth token directly. SOLID. 9. VITE_ discipline: all VITE_ vars are genuinely public — APP_NAME, BETA_CONTACT, TURNSTILE_SITE_KEY (public by design), AMPLITUDE_API_KEY (Amplitude browser ingestion key, public by design; .env.example explicitly documents it as `flotilla overlay set`, NOT secret), policy URLs. No secret shipped as a VITE_ var. SOLID. 10. npm audit (frontend): 0 vulnerabilities (info/low/moderate/high/ critical all 0). SOLID. 11. OTC abuse: request_code has a per-email cooldown (OTC_REQUEST_COOLDOWN_SECONDS default 60) AND the route runs turnstile.verify_token BEFORE request_code (so a failed challenge spends no budget / sends no mail). Email-bomb + LLM-cost surfaces mitigated. Cooldown is per-email not per-IP, but Turnstile covers the bot/IP dimension. SOLID with one caveat (below). 12. beta-request: require_user gated (not anon), admin notification fires once via the was_already_complete guard. Not anon-abusable. 13. Security headers: app has only SessionMiddleware — no CSP / X-Frame / X-Content-Type / HSTS. nginx conf (deploy/nginx/ohm.wiggleverse.org.conf) sets only Cache-Control — NO security headers either. So nothing backstops the XSS sinks at the header layer. ## Cut state — findings ### F1 — Stored XSS via proposed_use_case rendered through marked + dangerouslySetInnerHTML (no sanitizer) - **Location:** frontend/src/components/ProposalView.jsx:172 (sink); source written at backend/app/api.py:841-854 (#26 propose) and backend/app/api_prs.py:186-199 (#26 open-pr); served unsanitized via GET /api/proposals/{pr_number} (api.py:716, anon-readable); proposed_use_case max_length=8000. - **Issue:** marked does not sanitize by default (verified live: raw `` and `javascript:` hrefs pass through), and the project has NO DOMPurify. A granted contributor's use_case text renders as live HTML in any viewer's browser, including admins who hold merge/decline on that proposal. - **Exploit:** contributor proposes an RFC with `` in the use case; an admin opens the proposal to review it; script runs in the admin's authenticated session (CSRF-style writes via the admin's cookie, UI defacement). Auth-token exfil is blocked (HttpOnly cookies), but cookie-authenticated state- changing requests from the victim's browser are not. - **Remediation:** sanitize all marked output with DOMPurify before dangerouslySetInnerHTML (or configure a safe renderer), at minimum on the new proposed_use_case sink. Same pattern should cover the pre-existing entry-body sink (F2). - **Severity:** High. **Confidence:** High (live-verified marked passthrough; confirmed no sanitizer; confirmed privileged victim). ### F2 — Pre-existing same-sink XSS on entry body (in-scope by adjacency) - **Location:** ProposalView.jsx:164 (also Editor.jsx:125 preview path); marked.parse(data.entry?.body) → dangerouslySetInnerHTML. - **Issue:** identical unsanitized sink; the proposed entry body is proposer-controlled markdown. Predates v0.24.0 but shares the root cause and remediation with F1. - **Severity:** High. **Confidence:** High. (Flagged as the root-cause class so a fix covers both, not just the #26 field.) ### F3 — No security headers at app or nginx layer (defense-in-depth gap) - **Location:** backend/app/main.py (only SessionMiddleware); deploy/nginx/ohm.wiggleverse.org.conf (only Cache-Control). - **Issue:** no Content-Security-Policy, X-Frame-Options/frame-ancestors, X-Content-Type-Options, or HSTS. A CSP would meaningfully blunt F1/F2 (block inline/onerror script execution). Absence means the XSS sinks have no header-layer backstop, and the app is clickjackable. - **Remediation:** add CSP (script-src self, no unsafe-inline), X-Frame-Options DENY / frame-ancestors none, X-Content-Type-Options nosniff, HSTS — at the nginx layer is simplest. - **Severity:** Medium. **Confidence:** High. ### F4 (minor / needs-verification) — OTC cooldown is per-email, not per-IP - **Location:** backend/app/otc.py:152-164. - **Issue:** the cooldown keys on email, so one attacker can rotate through many target emails (mail-bomb across recipients) without tripping a single email's cooldown. Mitigated by the mandatory Turnstile gate on /auth/otc/request (main.py:255) when TURNSTILE is wired; if TURNSTILE_REQUIRED=false and no secret is set (the default dev posture), the gate opens and only the per-email cooldown remains. - **Remediation:** keep Turnstile required in production; consider an IP-scoped request ceiling as belt-and-suspenders. - **Severity:** Low (prod-config-dependent). **Confidence:** Medium — hinges on the deployment's TURNSTILE_REQUIRED + secret state, which this subsession did not inspect (operator/§13 record, not code). ## What's solid - last-state #29: no IDOR (server-session user_id only), no open-redirect (resume only navigates client-side react-router routes from "/", with a NON_RESUMABLE_PREFIXES skip list; no external URL navigation), state blob is bounded + JSON-parsed defensively. Resume redirect is one-shot. SOLID. - tag_suggest #27: prompt-injection contained (output constrained to canonical tag universe), cost bounded (per-user rate limit + input truncation + universe cap + require_contributor gate), degrades to silence on any failure. SOLID. - Frontend secrets: no auth token in JS-reachable storage; cookies are HttpOnly+Secure+SameSite=Lax; no secret shipped as a VITE_ var (AMPLITUDE/TURNSTILE keys are public by design). SOLID. - npm audit: 0 advisories. SOLID. - OTC/beta-request authz + cost: Turnstile-gated OTC with per-email cooldown; beta-request require_user + once-only admin notify. SOLID (modulo F4 caveat). ## Driver-needs-to-know - F1 is the headline: a real, live-verified stored XSS in the #26 use-case field that can land on an admin reviewer. F2 is the same sink on the entry body — fix the sanitizer once, cover both. F3 (no CSP) removes the only header-layer backstop. Recommend a single remediation wave: add DOMPurify around every marked→dangerouslySetInnerHTML sink + add a CSP at nginx. - F4 depends on the OHM deployment's TURNSTILE_REQUIRED + secret state (a §13 record / operator question, not in this code), so verify that before downgrading or upgrading it. ## §19.2 candidates (flotilla-core / cross-cutting) - A shared "render-trusted-markdown" frontend helper (marked + DOMPurify in one place) so no future sink reintroduces F1. Not flotilla-core (it's rfc-app frontend), but a cross-cutting rfc-app refactor worth a roadmap item. - A security-headers block as a reusable nginx snippet shipped with the deploy/ templates — could become a flotilla overlay default.