# 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.