# SESSION-0026.1 — AuthN/AuthZ security audit subsession (rfc-app v0.24.0)

Parent: SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md
Date: 2026-05-28T13-46 → 2026-05-28T13-50 (America/Los_Angeles)
Role: subsession 0026.1 of OHM security-audit driver session 0026.
Mode: READ-ONLY. No edits, no deploys, no mutations. Only file written is this transcript.

## Goal

Audit the authentication / authorization surface of the rfc-app codebase
(`/Users/benstull/git/rfc-app`, tag v0.24.0): the email/OTC login flow,
passcodes, device-trust, Gitea OAuth + core auth, session/cookie security,
the admin role model, and per-endpoint authz guards across every
`api*.py` write/admin/owner endpoint. Verify deployment guarantee #4
("anonymous users are off-limits for any write/mutation"). Hunt for missing
guards, IDOR, and OTC/passcode brute-force/replay.

## Outcome

One High finding (OTC verify has no attempt limit / lockout — asymmetric
with the passcode path which does). Two Low/Info findings (session cookie
`https_only=False`; email-bounce webhook unauthenticated when env unset —
both documented dev contracts). Every enumerated write/admin/owner endpoint
enforces auth; no missing guards, no IDOR found. Admin role model is sound.

## Pre-state (what I found at the start)

- `auth.py` defines the dependency ladder: `require_user` (401 if anon) →
  `require_contributor` (adds muted + permission_state='granted' gate) →
  `require_admin` (role in owner/admin). Plus per-RFC predicates
  (`can_contribute_to_rfc`, `can_discuss_rfc`, `can_invite_to_rfc`,
  `is_rfc_owner`). `current_user` re-reads role + permission_state from DB
  every request (so grants/revokes take effect next request — good).
- Credentials: OTC 6-digit codes (bcrypt at rest, 10-min TTL, 60s request
  cooldown, single live code per email); passcodes (bcrypt, 5-fail/15-min
  per-account lockout, denylist, 4–20 chars); device-trust (256-bit CSPRNG,
  bcrypt, 30-day, HttpOnly+Secure+SameSite=Lax cookie); invite tokens
  (256-bit CSPRNG, bcrypt, 7-day, single-use).

## Turn-by-turn arc

1. Read auth.py + config.py. Dependency ladder is clean. Noted SECRET_KEY
   is `_required` (good — no insecure default). OAuth provisioning sets
   owner role only via OWNER_GITEA_LOGIN match; OTC/invite users get
   gitea_login="" / gitea_id=0 coalesced.

2. Read otc.py + passcode.py side by side. **Caught the asymmetry**:
   `passcode.verify_passcode` has a per-account failed-attempts counter +
   15-min lockout; `otc.verify_code` has NOTHING — it walks the last 5
   rows, bcrypt-checks, and returns "wrong" with no counter, no lockout,
   no per-IP throttle. The 60s cooldown lives in `request_code`, not
   `verify_code`. This is the headline finding.

3. Read device_trust.py + email_otc.py. Device-trust is solid (constant-time
   bcrypt walk, no early-exit, token never logged, cookie flags correct,
   user-scoped revoke). email_otc always returns 202 / logs failures —
   no email enumeration via the request path.

4. Read main.py (middleware + all /auth endpoints). Confirmed:
   - SessionMiddleware uses `https_only=False` (line 150) — itsdangerous-
     signed cookie, HttpOnly by default, but Secure flag off. Dev parity
     comment present. Behind HTTPS+proxy in prod this is low-impact but
     worth flagging.
   - `/auth/otc/verify` has NO rate-limit dependency, confirming finding #1
     at the endpoint layer too.
   - Turnstile gates `/auth/otc/request` ONLY, not verify.

5. Refutation pass on finding #1: Is there a global limiter? Grepped for
   RateLimit/slowapi/limiter/429 across backend/app — only tag_suggest.py
   (per-user, in-memory) and the OTC *request* cooldown 429. No global
   middleware, no limiter on verify. SPEC §ahead-of (line ~4100) discusses
   per-IP throttle for the *passcode* verify (which already has a per-
   account lockout) but never notes the OTC verify has no per-account limit
   at all. Quantified: single live code per email, bcrypt ~tens of ms,
   10^6 keyspace, 10-min TTL. Full sweep not possible in one TTL, but
   attacker re-requests (60s cooldown) to keep a live code and runs
   parallel verifies; expected ~500k guesses. bcrypt cost + TTL + single-
   live-code = real friction but NO attempt cap. Downgraded Critical→High.

6. Read api_admin.py end to end. Every endpoint calls require_admin; the
   user-search typeahead intentionally uses require_user (no privileged
   data). Role escalation well-guarded: only owner grants/changes owner;
   self-downgrade, self-mute, self-permission-flip, self-invite all refused;
   admins/owners not write-mutable. No spoof/escalation path. SOLID.

7. Enumerated routes+guards across api.py, api_prs.py, api_branches.py,
   api_graduation.py, api_invitations.py, api_notifications.py,
   api_discussion.py via grep. Every write/mutation route has require_user
   / require_contributor / require_admin, and the per-RFC write routes
   additionally call can_contribute_to_rfc / can_discuss_rfc /
   can_invite_to_rfc. GET read routes use current_user (nullable) — anon
   read is by design (guarantee #4 is about writes).

8. IDOR hunt — checked every owner/id-comparing helper:
   - api_prs `_can_merge`/`_can_withdraw`/`_can_edit_pr_text`: compare
     viewer.gitea_login against RFC frontmatter owners/arbiters cache and
     pr_row["opened_by"] — server-side data, not user-supplied. Safe.
   - api_branches `_require_branch_owner`/`_can_contribute`/
     `_can_read_branch`: branch grants keyed on grantee_user_id (session
     id), creator compared to gitea_login. Safe.
   - notification mute (`/api/users/{user_id}/notification-mute`): SQL
     scoped to viewer.user_id; only the {user_id} *target* is user-supplied
     and it's just the mute subject, not an ownership pivot. Self-mute
     refused. Safe.
   - invitations accept: requires auth + case-insensitive email match +
     refuses empty email; collaborator row keyed on viewer.user_id. Safe.
   - Considered: OTC/invite users have gitea_login="". Could "" match an
     owners list? Only if frontmatter literally contained "" — impossible
     from gitea_login frontmatter. `"" in []` is False. Killed as non-issue.

9. Checked anonymous-reachable mutation surfaces:
   - `/api/email/unsubscribe` (GET+POST): itsdangerous URLSafeSerializer
     signed token (SECRET_KEY, salted) → user_id+category not forgeable;
     idempotent, low-stakes; no expiry but URLSafeSerializer is fine here.
   - `/api/webhooks/email-bounce`: optional X-Webhook-Secret (constant-time
     compare) when WEBHOOK_EMAIL_BOUNCE_SECRET set; UNAUTHENTICATED when
     unset (documented v1 dev contract). Impact: flip email opt-out flags +
     mark outbound rows bounced. Low.
   - `/api/webhooks/...` Gitea receiver: mandatory HMAC-SHA256, constant-
     time compare, refuses to start without secret unless
     RFC_APP_INSECURE_WEBHOOKS=1. SOLID.
   - `/api/invites/claim`, `/auth/device-trust/start`: anon by design (they
     establish the session); both gated by 256-bit bcrypt-hashed tokens.

10. Confirmed db is a single shared sqlite connection (check_same_thread=
    False, isolation_level=None) — no incidental concurrency limiter that
    would mitigate the OTC brute-force.

## Cut state (final findings)

### High
- **F1 — OTC verify has no attempt limit / lockout / per-IP throttle.**
  `backend/app/otc.py:211 verify_code` (endpoint `backend/app/main.py:279
  /auth/otc/verify`). The passcode path (`passcode.py:262`) has a 5-fail/
  15-min per-account lockout; the OTC path has none. 60s cooldown is on
  *request* only. 6-digit code (10^6), 10-min TTL, single live code per
  email. Remediation: add a per-account failed-OTC-attempt counter +
  lockout mirroring passcode.py, and/or a per-IP 429 throttle on verify;
  consider invalidating the live code after N failures. Confidence: HIGH
  the control is absent (verified across module + endpoint + global grep).
  Severity argued down from Critical to High because bcrypt cost + 10-min
  TTL + single-live-code give real friction; still a genuine gap and an
  inconsistency with the passcode path.

### Low / Info
- **F2 — Session cookie `https_only=False`.** `backend/app/main.py:150`
  SessionMiddleware. Cookie is itsdangerous-signed + HttpOnly, but Secure
  is off "for dev parity." The device-trust cookie correctly sets
  Secure=True. In prod behind HTTPS the session cookie should also be
  Secure. Remediation: drive https_only from an env flag, true in prod.
  Confidence: HIGH it's set false; impact Low given HTTPS + signed cookie.
- **F3 — Email-bounce webhook unauthenticated when env unset.**
  `backend/app/api_notifications.py:543`. Documented v1 dev contract;
  impact limited to email opt-out flag flips + outbound-row status. Info/
  Low. Confidence HIGH on behavior, low on impact.

### What's solid (controls correctly implemented)
- Three-tier dependency ladder; `current_user` re-reads role +
  permission_state from DB every request (revokes take effect immediately).
- Admin role model: owner-only owner grants, self-downgrade/self-mute/
  self-permission/self-invite refusals, admins/owners not write-mutable.
  No escalation/spoof path. Every /api/admin/* endpoint require_admin.
- Every write/mutation endpoint across all api*.py files enforces auth;
  per-RFC writes additionally enforce can_*_to_rfc. No missing guard.
- No IDOR: owner/permission checks compare against server-side frontmatter
  cache / session user_id / pr opened_by, never a trusted user-supplied id.
- Passcode lockout (5/15min), denylist; OTC single-live-code + consume-
  before-provision (no replay, no double-sign-in).
- All tokens (OTC, passcode, device-trust, invite) bcrypt-hashed at rest;
  device-trust/invite tokens 256-bit CSPRNG; constant-time bcrypt walks,
  no early-exit timing leak; raw tokens never logged.
- Gitea webhook mandatory HMAC-SHA256 + constant-time compare; OAuth state
  param checked with secrets.compare_digest.
- SECRET_KEY is _required (no insecure default); unsubscribe tokens signed
  with it (salted, not forgeable).
- OTC request returns 202 regardless (no email enumeration); verify
  collapses wrong/unknown to one 400 (no enumeration); passcode verify
  collapses wrong/unknown likewise.

## What the driver needs to know

- F1 is the only finding worth a remediation slice. It is a real,
  exploitable-with-effort gap AND an internal inconsistency (passcode has a
  lockout, OTC doesn't). Recommend the driver fold it in at High; if the
  driver judges the bcrypt+TTL friction insufficient given OHM is publicly
  reachable, it could argue back toward Critical — I left it at High after
  refutation.
- F2/F3 are documented dev contracts; flag them but they are low priority.
  Worth a one-line confirmation that OHM's prod overlay sets
  WEBHOOK_EMAIL_BOUNCE_SECRET and serves over HTTPS (the device-trust
  cookie is Secure-only, so if OHM weren't HTTPS, device trust would
  already be broken — implying HTTPS is in fact present, which de-risks F2).
- The authz guard coverage is genuinely complete — guarantee #4 holds for
  every write/admin/owner endpoint I enumerated. No anon-write hole found.

## §19.2 candidates surfaced

- Per-account OTC-verify lockout + per-IP verify throttle (pairs with the
  SPEC's already-listed per-IP passcode-verify candidate). This is the
  remediation for F1 and is a flotilla-irrelevant, rfc-app-core change.
