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)
179 lines
9.9 KiB
Markdown
179 lines
9.9 KiB
Markdown
---
|
|
track: porch
|
|
iteration: "4"
|
|
status: pending
|
|
readiness: ready
|
|
---
|
|
|
|
# porch 4 — CSRF: tokens that are unguessable, bound, and spendable once
|
|
|
|
> Part of [Story — `porch`, the writeonce web framework](00-story.md).
|
|
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §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-Site` checks, and `KeyGenerator` = `utils.SecureToken`.
|
|
> Needs [2](02-randomness-and-cookies.md) for randomness and cookies, and
|
|
> [3](03-sessions.md) 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 `TrustedOrigins`
|
|
alongside 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 `SingleUseToken` exists 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)
|
|
|
|
1. **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 `@table` keyed by the
|
|
token with a `session_id` index — 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.**
|
|
2. **Single-use is opt-in per route; the default token is multi-use.** Matches
|
|
fiber (`SingleUseToken` defaults 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.
|
|
3. **A double-submitted single-use token yields a distinct `SPENT` refusal, 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.
|
|
4. **No actor pool; not blocked on lang-41.** Like sessions, the `CsrfToken`
|
|
table is plain `@table` CRUD — 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.
|
|
5. **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's `random_bytes`, stores the row, and sets the
|
|
CSRF cookie carrying the token.
|
|
- Verify does the hybrid check (decision 1) with `ct_eq` on 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 `Csrf` middleware gating unsafe methods only. `GET`/`HEAD`/`OPTIONS` pass
|
|
untouched — and a `GET` of 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 `Referer` checking against a configured trusted set (the app's own
|
|
host, which behind the proxy is the forwarded `Host` that `HostAllow` already
|
|
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), `null` origin, and a same-origin request without the header.
|
|
A `Sec-Fetch-Site` the 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 `SPENT` class (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 `CsrfToken` rows for the old
|
|
session id through the `session_id` index — 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/reference` projects used.
|
|
- Verify: `just web-app`, `just site`, `just linkcheck` green.
|
|
|
|
## 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 (`Cors` before/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
|
|
`SameSite` default 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](01-store-backed-middleware.md); 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](09-idempotent-replay.md) — 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.
|