sessions (3): - six decisions: pure-auth-primitive row (no payload bag); wall-clock time.now not monotonic time.ticks (restart durability); login always mints a fresh id (fixation, no anon-session model); throttled last_seen touch at idle/20 (not a WAL write per request); Session writes req.principal; config refuses absolute < idle - finding: no per-key actor pool, so NOT blocked on lang-41 (plain @table CRUD, same path storefront uses); the no-bag rule closes the one place fiber's Set(key,any)+msgp+RegisterType would have hit principle 13 csrf (4): - five decisions: fiber's hybrid transport (session-stored CsrfToken @table + double-submit cookie, both must pass; no CSRF for sessionless apps); opt-in single-use (checkout example); double-click -> distinct SPENT refusal, NOT coupled to lang-41-blocked idempotency; trusted origin/referer/Sec-Fetch-Site second layer; refusal classes distinct in logs, opaque in body - no actor pool, not blocked on lang-41 both validated against .dev/reference/fiber (v3, 3ca9a9d); exactly ZERO language enhancement needed beyond iteration 2's random_bytes. Board synced. (cherry picked from commit 3a4fb4215b23d2516362e2dd0acc5bec6c9aebc0)
9.9 KiB
| track | iteration | status | readiness |
|---|---|---|---|
| porch | 4 | pending | ready |
porch 4 — CSRF: tokens that are unguessable, bound, and spendable once
Part of Story —
porch, the writeonce web framework. Source: the Fiber parity study §2, re-checked 2026-09-06 against.dev/reference/fiber(v3,3ca9a9d):middleware/csrf— its hybrid double-submit-plus-session-stored model (csrf.go,session_manager.go),SingleUseToken, the origin/referer/Sec-Fetch-Sitechecks, andKeyGenerator=utils.SecureToken. Needs 2 for randomness and cookies, and 3 for something to bind a token to.
Goals
- Tokens that cannot be guessed or forged. Minted from the iteration-2
builtin, verified with
ct_eq. This is the iteration that could not have been written honestly before phase A of 2 existed, which is the whole reason the track is ordered this way. - Bound to a session, not floating. An unbound token is a token an attacker can fetch for themselves and replay against a victim. Binding is what makes the defence real, and it is why this follows sessions rather than preceding them.
- Origin checking as the cheap second layer. Fiber ships
TrustedOriginsalongside the token. Same-site cookies plus an origin check stop most of what tokens stop, for almost no cost — and the two layers fail differently, which is the argument for having both. - Single-use where it matters. Fiber's
SingleUseTokenexists because a long-lived token in a browser history or a referrer header is a credential left lying around. Decide which routes get it rather than making everything pay. - Refusals that are distinguishable. Missing, stale, foreign-origin and already-spent must be told apart in the logs, or nobody can debug a form that stopped working.
Decisions locked (brainstorm 2026-09-06)
- Fiber's hybrid transport: a session-stored token AND a double-submit cookie
compare, both must pass. Because iteration 3's session row carries no
payload bag, the token lives in a dedicated framework
@tablekeyed by the token with asession_idindex — the "own table keyed by session id" pattern iteration 3 established. A CSRF cookie carries the same token; the client also echoes it in a form field or header. Verify requires (a) the echoed token equals the cookie value, and (b) the stored row exists and binds to the current session. The stored row is the authority, so signing the CSRF cookie is optional and not relied upon — the token is high-entropy and a tampered cookie simply misses the row. porch has no CSRF story for sessionless apps, said plainly rather than shipping the weaker double-submit-only silently. - Single-use is opt-in per route; the default token is multi-use. Matches
fiber (
SingleUseTokendefaults off). A multi-use token is valid until idle expiry and has no double-click problem at all, so the sharp edge shrinks to exactly the routes that opt in. Worked examples: the storefront checkout is single-use, the site's admin edit is multi-use. - A double-submitted single-use token yields a distinct
SPENTrefusal, and CSRF does not couple to idempotency. The story floated reusing iteration 1's idempotency so a replay returns the same response — but that middleware is reverted and blocked on the lang-41 arena hang, so coupling would drag CSRF behind that blocker. Instead the spent-token refusal is its own class, distinguishable in the logs from forged/missing/stale, so an app can present "already submitted" rather than a raw 403. True exactly-once execution is idempotency's job (iteration 9), a separate concern. - No actor pool; not blocked on lang-41. Like sessions, the
CsrfTokentable is plain@tableCRUD — insert on mint, load on verify, delete on spend or rotation. None of it is the read-modify-write that iteration 1's per-key actor pool exists for, so this iteration rides the same gate-green DB path the storefront uses and is unblocked today. - Refusal classes are distinguishable in logs, opaque in the body.
MISSING,FORGED(echo/cookie mismatch or unknown token),STALE(expired row),FOREIGN(origin/referer untrusted),SPENT(single-use replay) are logged distinctly; the response body says only "forbidden" and never which check failed.
Phases
Phase A — mint and verify
- The
CsrfToken@table:token @unique,session_id(indexed for rotation cleanup),created_at(wall-clock, lazy idle expiry like sessions). Mint draws from iteration 2'srandom_bytes, stores the row, and sets the CSRF cookie carrying the token. - Verify does the hybrid check (decision 1) with
ct_eqon the token halves, then the session-binding lookup. - Extraction from where forms and fetch clients actually put it: a form field
(
form_values, which exists) and a header. A query parameter is never allowed — it leaks into logs and referrers. - Verify: a valid token passes; altered, absent and foreign tokens each fail distinctly; a token minted for session A presented under session B is refused.
Phase B — the middleware and safe-method policy
- A
Csrfmiddleware gating unsafe methods only.GET/HEAD/OPTIONSpass untouched — and aGETof a form page is where the token is minted for the form that will submit it — or every link on the site breaks. - Origin and
Refererchecking against a configured trusted set (the app's own host, which behind the proxy is the forwardedHostthatHostAllowalready validates, plus configured extras), including the awkward cases fiber handles: absent Origin (fall back to Referer on HTTPS, allow on plain HTTP where it cannot be told),nullorigin, and a same-origin request without the header. ASec-Fetch-Sitethe browser would never have sent is rejected. - Failure is a distinct status with a body that does not leak which check failed (decision 5).
- Verify: the site's admin edit flow works through the middleware; unsafe methods without a token are refused; safe methods are unaffected.
Phase C — single use and rotation
- Mark-spent-on-use (delete the row) for the routes that opt in (decision 2); a
double-click produces the
SPENTclass (decision 3), not a raw 403. - Rotate on privilege change, matching the session-id rotation from iteration 3:
when the session id rotates at login, delete the
CsrfTokenrows for the old session id through thesession_idindex — the same shape as sessions' revoke-all-by-principal. - Verify: a spent token is refused; a double-click produces a comprehensible outcome; a token outstanding for the pre-login session is inert after login.
Phase D — the gate and the ledger
- Both serving gates: the site's admin edit is the natural CSRF subject (and the phase migrates it from its current per-request bearer check onto session + CSRF), the storefront's checkout the natural single-use subject.
- Ledger and status board, standup questions answered including the
.dev/referenceprojects used. - Verify:
just web-app,just site,just linkcheckgreen.
Acceptance Criteria
- Given an unsafe request with no token, when it is dispatched, then it is refused and the handler never runs.
- Given a token minted for session A, when it is presented with session B's cookie, then it is refused — binding is enforced, not decorative.
- Given a token whose echoed copy and cookie disagree, when it is verified, then it is refused — the double-submit half is enforced too.
- Given a token altered by one bit, when it is verified, then it is refused in constant time.
- Given a request from an untrusted origin carrying an otherwise valid token, when it arrives, then it is refused — the layers are independent.
- Given a safe method (
GET,HEAD,OPTIONS), when it arrives with no token, then it passes untouched. - Given a single-use token, when it is submitted twice, then the
second attempt is refused as
SPENT, told apart from a forged token in the logs. - Given login, when the session id rotates, then the outstanding token for the old session is no longer valid.
- Given each refusal class, when the logs are read, then missing, stale, foreign-origin and spent are told apart — while the response body tells the client none of it.
Out Of Scope
- CORS. Already shipped (
Corsbefore/after middleware). It is a different problem — CORS decides who may read a response, CSRF stops a forged write. Conflating them is the most common way both get misconfigured. - Same-site cookie attributes as the whole answer. Iteration 2 sets a safe
SameSitedefault and it does real work, but it is a browser behaviour, not a server guarantee, and older clients ignore it. - Captcha, rate-limited forms, bot defence. Rate limiting shipped in iteration 1; the rest is not porch's.
- Encrypted or stateless tokens. No symmetric cipher exists, and a stateless token cannot be revoked or spent once.
- Exactly-once execution of a replayed unsafe request. That is idempotency's job — iteration 9 — not CSRF's (decision 3). CSRF only makes the double-submit refusal comprehensible.
- CSRF for sessionless apps. The hybrid model binds to a session; an app with no sessions has no CSRF story here (decision 1).
Info
The one language dependency is entirely upstream: iteration 2's random_bytes
and cookie helpers. This iteration is pure .wo on top of them plus the
@table engine — no new runtime work, and, like sessions, no dependence on the
per-key actor pool or on the lang-41 fix. Origin and Referer matching is string
comparison against the trusted set; form-field extraction is form_values,
which already exists.