ADR-0033 — CSRF as an Auth-Class-Derived Disposition¶
Status: Accepted 2026-06-03 — implemented (Phases 1–3) (#1337). Phase 1 (session-bound token, v0.81.15), Phase 2 (origin-primary admission gate, v0.81.16), Phase 3 (disposition predicate + auditable policy, v0.81.17). Phase 4 governance items deliberately scoped down — see Deferred.
Issue: #1337 (the 403-on-every-write bug that prompted the rework), follow-on to #1336. Design + 4-phase plan brainstormed with the maintainer: docs/superpowers/specs/2026-06-03-declarative-csrf-design.md.
Relates: ADR-0028 (guarded transactional actions — the precondition substrate a future CSRF seam could join), ADR-0029 (atomic flows), ADR-0008 (PostgreSQL-only runtime — the server-side session store this binds to). Supersedes the ad-hoc hardcoded-exempt-path-list model that preceded it.
Context¶
The pre-rework CSRF middleware was a pure double-submit token check with a hardcoded list of exempt paths/prefixes/regexes. Two consecutive releases shipped a browser-broken /app experience the test suite did not catch (#1336: vendor widget JS never loaded; #1337: no front-end code attached the X-CSRF-Token header, so every generated-form write 403'd in a real browser — universal, all profiles, masked in CI only because the test clients echo the cookie by hand). The token was also free-floating: a random cookie independent of the auth session, never rotated.
The deeper problem the rework addresses: CSRF protection was modeled as a token dance bolted onto every request, with exemptions as an inferred-from-absence path list — opaque, easy to get wrong, and impossible to audit ("what is exempt and why?").
Decision¶
CSRF is a control on ambient authority — the credential the browser attaches automatically (the session cookie). A forged cross-site request "works" only because it rides the victim's cookie. The corollary is the spine of this architecture:
CSRF is relevant only to requests authenticated by an ambient credential. A request authenticated by a credential the caller must explicitly present — a Bearer token, an HMAC webhook signature, an OAuth
statenonce — is structurally immune, and CSRF is categorically not its control.
Therefore CSRF admission is derived from the request's authentication class, not declared per path. Every state-changing request classifies into a Disposition; the disposition decides admission. This dissolves the opt-out-hole risk (there is no csrf: exempt attribute to forget or sneak past review) and makes the policy auditable — an endpoint is CSRF-protected iff it is session-cookie-authenticated.
The disposition model (Phase 3, back/runtime/csrf.py)¶
Two pure functions, evaluated on the unsafe-method path:
csrf_disposition(method, path, headers, config) -> Disposition— classify. Default-deny: anything not positively classifiedNA_*isPROTECTED_SESSION.csrf_admits(disposition, headers, host, csrf_cookie, config) -> bool— admit.
| Disposition | When | Admission |
|---|---|---|
PROTECTED_SESSION |
ambient session cookie (the default) | origin-primary gate + session-bound token fallback |
NA_BEARER |
Authorization: Bearer … |
admit — caller-presented credential |
NA_SIGNATURE |
HMAC/signature endpoints (webhooks, doc signing) | admit — message-authenticated |
NA_PREAUTH |
pre-session / idempotent cookie-setter / infra | admit — SameSite=Lax + protocol nonce cover these |
UNAUTH_MUTATING |
mutating, no auth at all | defined, not yet produced at runtime — see Deferred |
ESCAPE_HATCH |
session-authed, explicitly cross-origin-allowed | defined, not yet produced — see Deferred |
The admission gate for PROTECTED_SESSION (Phase 2, origin-primary)¶
Sec-Fetch-Sitepresent →same-origin/noneadmit;cross-site/same-sitereject (unless theOriginis intrusted_origins).- else
Originpresent → admit iff its host authority equals the requestHost(per-request comparison, sotenant_hostmulti-tenancy works with zero config), or it is trusted. - else (no fetch metadata) → fall back to the double-submit token.
A same-origin request admits without a token; a provably cross-site/same-site one is rejected even with one. Both signals are browser-set and unforgeable cross-site, so this is strictly stronger than token-only — and resilient to the #1337 failure mode (if a render path ever drops the token header again, same-origin writes still admit via the origin signal instead of universally 403'ing).
The session-bound token (Phase 1)¶
The token IS the server-side session's own csrf_secret (a sessions.csrf_secret column, minted with the session), set as the dazzle_csrf cookie at every browser-login site and cleared at logout. It rotates on session lifecycle (login/logout) — never per request, so it survives htmx swaps, multi-tab, and the back button. The CSRF middleware defers to a route-set cookie rather than clobbering it with a freshly-minted one (the C1 fix — caught only by composing middleware + route, not by router-only unit tests).
Auditability (Phase 3, §6)¶
render_csrf_policy(config) enumerates every disposition rule with its rationale into the RBAC compliance report — so an agent or auditor reads what is exempt from CSRF and why, rather than inferring protection from absence.
Why this shape (agent-first)¶
A handler author — human or LLM — cannot forget CSRF, because admission is a structural property of crossing the request boundary (the always-on middleware), not handler code. And an agent auditing the catalogue sees every non-protected disposition explicitly, with its auth-class justification, rather than having to prove a negative. This collapses CSRF from "remember the token dance per form" to "the layer derives admission from the request's auth class" — the boring, routine, auditable outcome the rework set out to deliver.
Deferred (deliberately not built, with rationale)¶
These were in the original 4-phase plan; investigation during Phase 3–4 found them low-value, redundant, or speculative. Recorded here so the absence is a decision, not an oversight:
<body hx-headers>transport switch (retiredz-csrf.js). Dropped.dz-csrf.jsis one central bundled script that echoes the cookie on every htmx request; the swap would have scattered the per-request token across 6+Page-construction sites for marginal benefit, and post-Phase-2 the token is only a fallback leg.- Guarded-action seam (wire
csrf_admitsinto the ADR-0028/0029 precondition path). Dropped as redundant — the CSRF middleware already runs on every request including atomic-flow routes, so the "handler can't forget CSRF" goal is already met.csrf_admitsis nonetheless shaped as a reusable precondition should a non-HTTP invocation path ever need it. - Test-harness refactor (stop clients hand-rolling the token). Dropped — the original motivation was that harnesses masked a missing front-end capability; Phases 1 + dz-csrf.js made that capability exist, so the harnesses no longer mask anything.
ESCAPE_HATCHDSL knob (csrf: cross_origin_allowed(reason:)). Deferred — real grammar/IR/parser work for a genuinely rare case (a session-authed endpoint that must accept cross-origin). The enum value exists; the classifier doesn't produce it. Revisit when a concrete need arises.UNAUTH_MUTATINGruntime classification. Deferred — it is a security-sensitive behavior change (it would admit unauthenticated mutating POSTs that today hit the gate) plus a design fork (reliable no-session detection in the raw ASGI middleware). Themethodparameter oncsrf_dispositionis already plumbed (currently unread) as the signal it will need.
Consequences¶
- The CSRF enforcement boundary is one predicate with one outer call site (the middleware), behavior-equivalent to the prior model (verified by a 585-case differential during the Phase-3 refactor).
- Exemptions are disposition-labeled config (
na_signature_*for signature endpoints;exempt_paths/exempt_path_prefixesfor pre-auth/idempotent) — typed, not a flat untyped list — and surfaced in the compliance report. regenerate_session_csrfrotates a session's secret in place (the privilege-change-rotation primitive); it guards the no-match case loudly rather than returning an un-persisted secret.