9e6756f678
Backfills transcripts that existed only locally in ohm-infra: - 0019.1/.2/.3 - UX-polish wave subagent transcripts (driver 0019.0 never finalized) - 0026.1-.9 - security-audit-0026 subagent transcripts (driver abandoned/closed-out by 0068; audit drove published 0030 remediation) - 0073.0 - PPE/progressive-delivery + engineering-handbook session, originally drafted as a duplicate 0046.0; reassigned next free number (0072 taken by a concurrent session) sessions.json: add 0019/0073 titles, update 0026 title. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
4.6 KiB
Markdown
82 lines
4.6 KiB
Markdown
# SESSION-0026.3 — OHM security audit subsession: webhooks, CAPTCHA, email
|
|
|
|
Parent: [SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md](./SESSION-0026.0-TRANSCRIPT-2026-05-28T13-46--INPROGRESS.md)
|
|
Subsession: 0026.3
|
|
Mode: READ-ONLY security audit. No mutations. Only file written: this transcript.
|
|
Target: rfc-app @ v0.24.0 (commit 28015ed), /Users/benstull/git/rfc-app
|
|
Date: 2026-05-28, America/Los_Angeles
|
|
|
|
## Surface
|
|
Webhooks (backend/app/webhooks.py), Turnstile/CAPTCHA (backend/app/turnstile.py),
|
|
email (email.py, email_otc.py, email_invite.py, email_envelope.py + the
|
|
api_invitations.py / api_notifications.py call/receive sites).
|
|
|
|
## Pre-state
|
|
Came in with the three-part brief (webhook HMAC/replay/unknown-repo; Turnstile
|
|
verify/fail-open/single-use/coverage; email header-injection/redirect/SSRF).
|
|
No prior knowledge of these modules.
|
|
|
|
## Turn-by-turn arc
|
|
|
|
1. Confirmed tag v0.24.0, read webhooks.py + turnstile.py in full.
|
|
- Webhook: HMAC-SHA256 over the *raw* `await request.body()` bytes (good),
|
|
`hmac.compare_digest` (good), secret required at config-load (config.py
|
|
refuses to start without GITEA_WEBHOOK_SECRET unless RFC_APP_INSECURE_WEBHOOKS=1).
|
|
Verification happens BEFORE any json.loads / cache action. Unknown-repo path
|
|
(#18) only logs — no unsafe action. No replay/dedup (delivery-id/timestamp).
|
|
- Turnstile: success field checked; network/parse failure -> reason="network"
|
|
(fail-CLOSED only when TURNSTILE_REQUIRED=true; fail-OPEN by default when
|
|
secret unset). Token never cached -> single-use enforced by Cloudflare.
|
|
|
|
2. Read all email modules + envelope builder. Found a 5th sender (api_invitations.py
|
|
RFC-invite) beyond the 4 named in the brief — also routes through build_envelope.
|
|
|
|
3. Located enforcement sites: Turnstile wired ONLY to /auth/otc/request (main.py:255).
|
|
Checked beta-request (api.py:441, require_user) and propose (api.py:766,
|
|
require_contributor) — both require an authenticated session, so Turnstile is
|
|
correctly unneeded there. The unauthenticated abuse hot path (OTC request) is
|
|
the one that needs it, and it's covered. -> KILLED the "Turnstile missing on
|
|
beta/propose" candidate.
|
|
|
|
4. Header-injection probe. Empirically tested Python 3.13 EmailMessage:
|
|
- `m["To"]=`, `m["Subject"]=`, and `formataddr((display,addr))` assignment all
|
|
raise ValueError on embedded CR/LF (default EmailPolicy). Code uses
|
|
EmailMessage (NOT legacy email.mime), so the classic header-injection vector
|
|
is closed at the stdlib layer for To/From/Subject/display-name.
|
|
-> KILLED "CRLF header injection / BCC smuggling" as exploitable. Downgraded to
|
|
a minor availability note (a malicious display name -> uncaught ValueError ->
|
|
500), needs-verification on whether that path is reachable with attacker text.
|
|
|
|
5. email-bounce webhook (api_notifications.py:543): unauthenticated when
|
|
WEBHOOK_EMAIL_BOUNCE_SECRET unset (the documented v1 dev default); flips
|
|
email_opt_out_all=1 for any matching user email. Constant-time compare when set.
|
|
|
|
6. Confirmed unsubscribe tokens: itsdangerous URLSafeSerializer signed with
|
|
SECRET_KEY, salt "email-unsubscribe", scoped to (user_id, category). No expiry
|
|
(intentional, revocable by rotating SECRET_KEY). app_url is operator-controlled
|
|
(no open-redirect via user input). siteverify URL is fixed (no SSRF).
|
|
|
|
## Cut state — findings
|
|
|
|
- F1 (LOW/Medium-conf): email-bounce webhook unauthenticated by default
|
|
(WEBHOOK_EMAIL_BOUNCE_SECRET unset) -> unauth attacker can force global
|
|
email opt-out for any known address (DoS on a user's mail). Mitigated in prod
|
|
IF the secret is wired; needs-verification that OHM's overlay sets it.
|
|
- F2 (INFO): no webhook replay protection (no delivery-id/timestamp dedup) on
|
|
the Gitea receiver. Low impact — handlers are idempotent cache refreshes, no
|
|
state mutation from payload contents. Acceptable; documented for completeness.
|
|
- F3 (LOW/availability, needs-verification): malicious display-name containing
|
|
CR/LF reaching build_envelope raises uncaught ValueError -> 500. Not injection.
|
|
- KILLED: CRLF header injection / BCC smuggling (stdlib guard).
|
|
- KILLED: Turnstile missing on beta-request/propose (both auth-gated).
|
|
- KILLED: Turnstile token replay (not cached; Cloudflare enforces single-use).
|
|
|
|
## Driver needs to know
|
|
- Verify OHM overlay sets WEBHOOK_EMAIL_BOUNCE_SECRET (F1) and
|
|
TURNSTILE_REQUIRED=true (else Turnstile fails OPEN if the secret regresses).
|
|
- These are env/overlay checks for the flotilla side, not rfc-app code bugs.
|
|
|
|
## §19.2 candidates
|
|
- Webhook delivery-id dedup could become a shared flotilla-core concern if more
|
|
webhook receivers land. Not urgent.
|