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 anOriginLayer— read intopcoat-session-0.5.0/src/origin.rs, not assumed — which refuses any non-safe method whoseSec-Fetch-Siteis notsame-origin/none, falling back to comparingOrigin'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=Laxand 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
SameSitequestion, and it protects/auth/loginalone.
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 devrestarts 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=Laxand 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,subtleandrandwere all already here.