| 1 | # 0008 — A per-session CSRF token on every form, as the third layer |
| 2 | |
| 3 | **Status:** accepted · **Date:** 2026-09-08 |
| 4 | |
| 5 | ## Context |
| 6 | |
| 7 | Steid has thirty-eight POST routes, thirty-one of which are forms a signed-in owner |
| 8 | submits with an ambient session cookie. Cross-site request forgery is the classic hole |
| 9 | under that shape, and `current.md` had been carrying it as decidable-not-decided since |
| 10 | the forms first existed. |
| 11 | |
| 12 | Two defences were already in place, and finding the second is what made this a question |
| 13 | about *depth* rather than about coverage: |
| 14 | |
| 15 | - The session cookie is `SameSite=Lax`, which stops a cross-site POST in every current |
| 16 | browser. |
| 17 | - Topcoat's `sessions()` silently installs an `OriginLayer` — read in |
| 18 | `topcoat-session-0.5.0/src/origin.rs`, not assumed — which refuses any non-safe method |
| 19 | whose `Sec-Fetch-Site` is not `same-origin`/`none`, falling back to comparing |
| 20 | `Origin`'s host against the request's own. |
| 21 | |
| 22 | Both have the same gap, and it is deliberate in the layer: **a request carrying neither |
| 23 | header passes**, because that is what a non-browser client looks like and a non-browser |
| 24 | client has no ambient cookies to forge with. A browser old enough to send neither is in |
| 25 | that gap, and so is a proxy that strips `Origin`. And `SameSite=Lax` is one line in |
| 26 | `session_cookie.rs` that somebody could loosen for an unrelated reason. |
| 27 | |
| 28 | There is also no configured session secret to derive anything from: `main` builds |
| 29 | `SessionConfig::default()`. |
| 30 | |
| 31 | ## Decision |
| 32 | |
| 33 | **Every form carries a hidden `csrf_token` of `HMAC-SHA-256(per-boot secret, session |
| 34 | token hash)`, and every guarded POST handler verifies it in constant time before its use |
| 35 | case. A missing or wrong token is a 403 with one sentence. The secret is random per |
| 36 | boot.** |
| 37 | |
| 38 | Two exemption classes, enumerated with reasons in `csrf::EXEMPT` and asserted by a test: |
| 39 | routes with no cookie and no form (the git transport, `/api/setup`), and routes |
| 40 | submitted signed out (`/auth/setup`, `/auth/login`, `/auth/forgot`, |
| 41 | `/auth/reset/{token}`), which have no session to bind a token to. |
| 42 | |
| 43 | The obligation this creates is enforced by a test that reads |
| 44 | `src/infrastructure/web/*.rs` and fails when a POST route neither calls the check nor is |
| 45 | named in `EXEMPT`. |
| 46 | |
| 47 | ## Alternatives considered |
| 48 | |
| 49 | - **Nothing — rely on `SameSite=Lax` and the origin layer.** Cheapest, and correct for |
| 50 | every browser anyone actually uses. Rejected because both defences are one |
| 51 | configuration line from being off, both share a gap they cannot close by design, and |
| 52 | the cost of closing it is one hidden input. |
| 53 | - **A random token stored in the session row.** The conventional shape, and it needs a |
| 54 | column, a write on first render, and a decision about what happens when two tabs race. |
| 55 | The HMAC gets the same binding out of state that already exists on both sides. |
| 56 | - **A token derived from the session secret rather than random per boot.** Would survive |
| 57 | a restart, which is the one real cost of this choice. There is no session secret to |
| 58 | derive from, and inventing a persisted one is a bigger decision than this — it becomes |
| 59 | a thing to back up, rotate and configure. |
| 60 | - **A second CSRF cookie, so signed-out forms could be protected too.** The honest fix |
| 61 | for login CSRF, and rejected as scope: it is a cookie with its own lifetime and |
| 62 | `SameSite` question, and it protects `/auth/login` alone. |
| 63 | |
| 64 | ## Consequences |
| 65 | |
| 66 | - **A restart invalidates every form already open on someone's screen.** Submitting one |
| 67 | answers 403 and the person reloads and retypes. Acceptable for a form and not for a |
| 68 | session, which is why the session cookie is not treated the same way. It bites hardest |
| 69 | in development, where `topcoat dev` restarts on every edit. |
| 70 | - **Every new form is now three things, not two**: the field, the struct field, and the |
| 71 | check. The scan test is what makes forgetting the third a failing build rather than a |
| 72 | silent hole, and it is the only reason this is safe to spread across thirty-one |
| 73 | handlers. |
| 74 | - **Login CSRF is still open**, and knowingly. An attacker can force a victim's browser |
| 75 | to sign in as the attacker; `SameSite=Lax` and the origin layer still cover it in any |
| 76 | current browser, and closing it properly needs a pre-session cookie. |
| 77 | - **Reversing is cheap**: delete the module, the field, and the calls. Nothing is stored |
| 78 | and no URL or schema depends on it. |
| 79 | - **No new dependency.** `hmac`, `sha2`, `subtle` and `rand` were all already here. |