steid

@jamesgill /

steid/plans/decisions/0008-csrf-double-submit-bound-to-the-session.md
Markdown4.3 KBRendered·Source·Blame·History·Raw

0008 — A per-session CSRF token on every form, as the third layer

Status: accepted · Date: 2026-09-08

Context

Steid has thirty-eight POST routes, thirty-one of which are forms a signed-in owner submits with an ambient session cookie. Cross-site request forgery is the classic hole under that shape, and current.md had been carrying it as decidable-not-decided since the forms first existed.

Two defences were already in place, and finding the second is what made this a question about depth rather than about coverage:

  • The session cookie is SameSite=Lax, which stops a cross-site POST in every current browser.
  • Topcoat's sessions() silently installs an OriginLayer — read in topcoat-session-0.5.0/src/origin.rs, not assumed — which refuses any non-safe method whose Sec-Fetch-Site is not same-origin/none, falling back to comparing Origin's host against the request's own.

Both have the same gap, and it is deliberate in the layer: a request carrying neither header passes, because that is what a non-browser client looks like and a non-browser client has no ambient cookies to forge with. A browser old enough to send neither is in that gap, and so is a proxy that strips Origin. And SameSite=Lax is one line in session_cookie.rs that somebody could loosen for an unrelated reason.

There is also no configured session secret to derive anything from: main builds SessionConfig::default().

Decision

Every form carries a hidden csrf_token of HMAC-SHA-256(per-boot secret, session token hash), and every guarded POST handler verifies it in constant time before its use case. A missing or wrong token is a 403 with one sentence. The secret is random per boot.

Two exemption classes, enumerated with reasons in csrf::EXEMPT and asserted by a test: routes with no cookie and no form (the git transport, /api/setup), and routes submitted signed out (/auth/setup, /auth/login, /auth/forgot, /auth/reset/{token}), which have no session to bind a token to.

The obligation this creates is enforced by a test that reads src/infrastructure/web/*.rs and fails when a POST route neither calls the check nor is named in EXEMPT.

Alternatives considered

  • Nothing — rely on SameSite=Lax and the origin layer. Cheapest, and correct for every browser anyone actually uses. Rejected because both defences are one configuration line from being off, both share a gap they cannot close by design, and the cost of closing it is one hidden input.
  • A random token stored in the session row. The conventional shape, and it needs a column, a write on first render, and a decision about what happens when two tabs race. The HMAC gets the same binding out of state that already exists on both sides.
  • A token derived from the session secret rather than random per boot. Would survive a restart, which is the one real cost of this choice. There is no session secret to derive from, and inventing a persisted one is a bigger decision than this — it becomes a thing to back up, rotate and configure.
  • A second CSRF cookie, so signed-out forms could be protected too. The honest fix for login CSRF, and rejected as scope: it is a cookie with its own lifetime and SameSite question, and it protects /auth/login alone.

Consequences

  • A restart invalidates every form already open on someone's screen. Submitting one answers 403 and the person reloads and retypes. Acceptable for a form and not for a session, which is why the session cookie is not treated the same way. It bites hardest in development, where topcoat dev restarts on every edit.
  • Every new form is now three things, not two: the field, the struct field, and the check. The scan test is what makes forgetting the third a failing build rather than a silent hole, and it is the only reason this is safe to spread across thirty-one handlers.
  • Login CSRF is still open, and knowingly. An attacker can force a victim's browser to sign in as the attacker; SameSite=Lax and the origin layer still cover it in any current browser, and closing it properly needs a pre-session cookie.
  • Reversing is cheap: delete the module, the field, and the calls. Nothing is stored and no URL or schema depends on it.
  • No new dependency. hmac, sha2, subtle and rand were all already here.