docs(porch-csrf): brainstorm stories 3 (sessions) + 4 (CSRF) to ready

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)
This commit is contained in:
shoney.arickathil 2026-09-06 17:07:34 +02:00
parent 6a67db252b
commit 274f7c5361
3 changed files with 210 additions and 95 deletions

View file

@ -1195,9 +1195,9 @@ the language arc as v1 history.
### ▸ porch — the web framework track ### ▸ porch — the web framework track
New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md). New 2026-08-26, from [the Fiber v3.5.0 parity study](../plan/exploration/fiber/00-fiber-parity.md).
Supersedes language iteration 39, now a pointer. **Story 2 is `ready` Supersedes language iteration 39, now a pointer. **Stories 2, 3 and 4 are
(brainstormed 2026-09-06, five forks locked, validated against `ready` (brainstormed 2026-09-06, forks locked, validated against
`.dev/reference/fiber`); 3–8 remain `refine`.** Ordered by dependency; the `.dev/reference/fiber`); 5–8 remain `refine`.** Ordered by dependency; the
first slice is deliberately the cheapest so the store pattern and gate shape are first slice is deliberately the cheapest so the store pattern and gate shape are
proven before the runtime and `Resp` are touched. proven before the runtime and `Resp` are touched.
@ -1205,8 +1205,8 @@ proven before the runtime and `Resp` are touched.
| --- | --- | --- | | --- | --- | --- |
| 1 | [Store-backed middleware](porch/01-store-backed-middleware.md) | ✅ **DONE 2026-08-30** — rate limiter + idempotency serialized through a per-key actor pool, both durable in a `@table`. Gate-proven end to end: threshold + restart + exact concurrent counts (limiter), byte-identical replay + digest refusal + concurrent duplicates + no-5xx-replay (idempotency), and pool saturation failing closed (503, never a bypass) | | 1 | [Store-backed middleware](porch/01-store-backed-middleware.md) | ✅ **DONE 2026-08-30** — rate limiter + idempotency serialized through a per-key actor pool, both durable in a `@table`. Gate-proven end to end: threshold + restart + exact concurrent counts (limiter), byte-identical replay + digest refusal + concurrent duplicates + no-5xx-replay (idempotency), and pool saturation failing closed (503, never a bypass) |
| 2 | [Randomness and cookies](porch/02-randomness-and-cookies.md) | ✅ **`ready` 2026-09-06** — the foundation; the reference read settled that **exactly one language enhancement is needed**. Phase A is that language work: a bare-name `random_bytes(n) -> Bytes` builtin in the compiler's crypto-family table (`emit.ml` `b_*` + `types.ml` function list — **not** `wob.h`'s module enum; next free id `84`/`90`, confirm before use), `getrandom(2)`-sourced, refuses loudly. Then `Resp` gains `cookies: multi SetCookie` beside the unchanged `headers` map (the map can't emit two `Set-Cookie` lines; `multi` already exists), `Cookie:` parsing (structural 400 in `parse_request`, on-demand `cookie()` helper), and signed cookies (`base64(value).base64(mac)`, app-supplied key) | | 2 | [Randomness and cookies](porch/02-randomness-and-cookies.md) | ✅ **`ready` 2026-09-06** — the foundation; the reference read settled that **exactly one language enhancement is needed**. Phase A is that language work: a bare-name `random_bytes(n) -> Bytes` builtin in the compiler's crypto-family table (`emit.ml` `b_*` + `types.ml` function list — **not** `wob.h`'s module enum; next free id `84`/`90`, confirm before use), `getrandom(2)`-sourced, refuses loudly. Then `Resp` gains `cookies: multi SetCookie` beside the unchanged `headers` map (the map can't emit two `Set-Cookie` lines; `multi` already exists), `Cookie:` parsing (structural 400 in `parse_request`, on-demand `cookie()` helper), and signed cookies (`base64(value).base64(mac)`, app-supplied key) |
| 3 | [Sessions](porch/03-sessions.md) | ⬜ after 2. Server-side rows keyed by a random id, idle **and** absolute timeout, id rotation on login, revoke-all-for-principal, durable across restart | | 3 | [Sessions](porch/03-sessions.md) | ✅ **`ready` 2026-09-06** — after 2. Six decisions locked: row is a pure auth primitive (id/principal/created_at/last_seen, no payload bag); **wall-clock `time.now`, not monotonic `time.ticks`** (sessions survive restart); rotation = login always mints a fresh id (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. Pure `.wo` on iteration 2 + the `@table` engine — no new runtime work |
| 4 | [CSRF](porch/04-csrf.md) | ⬜ after 2 + 3. Session-bound tokens, trusted origins as the second layer, opt-in single use, and refusal classes that are distinguishable in logs | | 4 | [CSRF](porch/04-csrf.md) | ✅ **`ready` 2026-09-06** — after 2 + 3. Five decisions locked: fiber's **hybrid** transport (session-stored `CsrfToken` @table keyed by token + double-submit cookie, both must pass; no CSRF for sessionless apps); opt-in single-use (checkout the example, admin multi-use); a double-click yields a distinct `SPENT` refusal with **no coupling to the lang-41-blocked idempotency**; trusted origin/referer/`Sec-Fetch-Site` as the second layer; refusal classes distinguishable in logs, opaque in body. Plain `@table` CRUD — no actor pool, not blocked on lang-41 |
| 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ⬜ **independent, any time** — `patch`/`options`/`head`/`all`, named routes + URL building, per-route body limit (today `BODY_MAX` is one compile-time number), request ids, `Location`/`Vary`/`Attachment`, and q-value ranking (retires a standing 🔶) | | 5 | [Routing + response ergonomics](porch/05-routing-response-ergonomics.md) | ⬜ **independent, any time** — `patch`/`options`/`head`/`all`, named routes + URL building, per-route body limit (today `BODY_MAX` is one compile-time number), request ids, `Location`/`Vary`/`Attachment`, and q-value ranking (retires a standing 🔶) |
| 6 | [Streaming core](porch/06-streaming-core.md) | ⬜ the riskiest and highest-leverage slice: incremental writes + chunked framing + an explicit commit point. `serialize()` always emits `Content-Length` today. Chunked REQUEST bodies are deliberately refused (request smuggling) and that refusal must survive | | 6 | [Streaming core](porch/06-streaming-core.md) | ⬜ the riskiest and highest-leverage slice: incremental writes + chunked framing + an explicit commit point. `serialize()` always emits `Content-Length` today. Chunked REQUEST bodies are deliberately refused (request smuggling) and that refusal must survive |
| 7 | [SSE + compression](porch/07-sse-and-compression.md) | ⬜ after 6. SSE fits the actor/fiber model unusually well; compression carries a real fork — pure-`.wo` DEFLATE (now expressible after iteration 36's bit operators) vs a C builtin. CRC32 finally gets its consumer | | 7 | [SSE + compression](porch/07-sse-and-compression.md) | ⬜ after 6. SSE fits the actor/fiber model unusually well; compression carries a real fork — pure-`.wo` DEFLATE (now expressible after iteration 36's bit operators) vs a C builtin. CRC32 finally gets its consumer |

View file

@ -2,13 +2,17 @@
track: porch track: porch
iteration: "3" iteration: "3"
status: pending status: pending
readiness: refine readiness: ready
--- ---
# porch 3 — sessions: server-side state, revocable, durable # porch 3 — sessions: server-side state, revocable, durable
> Part of [Story — `porch`, the writeonce web framework](00-story.md). > Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §2. > 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/session` — its `IdleTimeout`/`AbsoluteTimeout` config (it panics
> if absolute < idle), and `Regenerate()`, documented as the post-authentication
> fixation defence.
> Needs [iteration 2](02-randomness-and-cookies.md) (a random session id and a > Needs [iteration 2](02-randomness-and-cookies.md) (a random session id and a
> signed cookie to carry it) and inherits the store convention from > signed cookie to carry it) and inherits the store convention from
> [iteration 1](01-store-backed-middleware.md). > [iteration 1](01-store-backed-middleware.md).
@ -21,8 +25,8 @@ readiness: refine
login. This is the fork iteration 1's store convention exists to answer. login. This is the fork iteration 1's store convention exists to answer.
- **Both timeouts, because they answer different questions.** Idle timeout - **Both timeouts, because they answer different questions.** Idle timeout
bounds "how long since you did anything"; absolute timeout bounds "how long bounds "how long since you did anything"; absolute timeout bounds "how long
since you authenticated". Fiber ships both (`IdleTimeout`, `AbsoluteTimeout`) since you authenticated". Fiber ships both and a session with only the first
and a session with only the first never expires for an active attacker. never expires for an active attacker.
- **Durability is the differentiator.** Fiber's default store is in-memory: a - **Durability is the differentiator.** Fiber's default store is in-memory: a
restart logs everyone out. porch's sessions ride the WAL, so they survive — restart logs everyone out. porch's sessions ride the WAL, so they survive —
and the gate proves it, because an unexercised durability claim is not a and the gate proves it, because an unexercised durability claim is not a
@ -31,39 +35,84 @@ readiness: refine
principal" — the second is what a password change needs, and it is nearly free principal" — the second is what a password change needs, and it is nearly free
once sessions are rows with an owner column. once sessions are rows with an owner column.
## Decisions locked (brainstorm 2026-09-06)
1. **The row is a pure auth primitive: `id`, `principal`, `created_at`,
`last_seen` — no payload column.** An app wanting cart, flash or preferences
keeps its own typed `@table` keyed by the session id. This rejects the
untyped `data: Text` bag (fiber's shape) precisely because it is the
Text-as-payload compromise the repo dislikes most — untyped, unversioned, and
every read a `json.decode` exposed to the lang-41 decode-corruption gotcha.
2. **Timestamps are wall-clock (`time.now`, epoch seconds), not monotonic
`time.ticks`.** This corrects an inherited assumption: iteration 1's store
used `time.ticks` (µs monotonic), which is right for short rate-limit windows
but **resets on restart**. Sessions survive restart by design, so a monotonic
timestamp would make every idle/absolute check wrong the moment the process
bounces. `time.now` is the only correct source here.
3. **Rotation is: login always mints a fresh id and a fresh row,
unconditionally.** Any session id on the incoming request is ignored when
establishing the logged-in session. That defeats fixation without an
anonymous-session model — an attacker can plant a cookie regardless of
whether the app creates pre-login sessions, so the fix is simply that login
never reuses an incoming id. Matches fiber's `Regenerate()`.
4. **`last_seen` is written with a throttled touch, not on every request.** It
advances (and WAL-writes) only when it is already older than a fraction of
the idle window — `idle/20`, floored at a few seconds. This bounds writes to
roughly one per active session per that interval no matter the request rate,
so a hot read endpoint behind a session stops turning every read into a
durable write. The cost is a bounded idle overshoot of at most `idle/20`,
which is documented, not hidden. The granularity is a knob.
5. **`Session` writes `req.principal`; chain order is the precedence.** It writes
the framework's declared identity field — the same slot `BearerAuth` and
`BasicAuth` write. A valid session sets it; a missing, expired, unknown or
tampered cookie leaves it empty and the route's policy decides. The rule for
two writers is documented: last middleware in the registration chain wins,
and `Session` is not stacked with a token-auth on the same route — sessions
*replace* per-request auth, which is exactly what phase D does to the site's
admin route.
6. **Config invariant, borrowed from fiber: absolute timeout must be ≥ idle
timeout.** An absolute shorter than idle is a misconfiguration; the framework
refuses it rather than silently making idle unreachable.
## Phases ## Phases
### Phase A — the session table and its lifecycle ### Phase A — the session table and its lifecycle
- The `@table` class: random id, principal, created-at, last-seen, and whatever - The `@table` class with `id @unique`, `principal`, `created_at`, `last_seen`
payload shape fork 1 settles. Secondary index on principal, because (all wall-clock), and two indexes — `index: [id]` for the load and
"revoke all for this user" is an equality probe and that is the only index `index: [principal]` for revoke-all. The non-unique secondary index on a
shape the engine has. plain column is the same shape `skill-catalog` already uses, so revoke-all is
- Create, touch, expire and delete, with expiry evaluated on access (the lazy an equality probe, the only index shape the engine has.
discipline iteration 1 established — there is still no timer). - Create, touch (throttled per decision 4), expire and delete, with expiry
- Verify: rows replay across a restart; an expired row is pruned when touched. evaluated on access — the lazy discipline iteration 1 established, since there
is still no timer. Expiry is `now - created_at > absolute` OR
`now - last_seen > idle`; a rejected session's row is deleted on that access.
- Verify: rows replay across a restart; an expired row is pruned when touched;
a throttled touch does not write when `last_seen` is fresh.
### Phase B — the middleware ### Phase B — the middleware
- A `Session` middleware whose `before` reads the signed cookie, loads the row, - A `Session` middleware whose `before` reads the signed cookie (iteration 2's
checks both timeouts, and attaches identity. Decide whether it sets `verify` with the app-supplied key), extracts the id, loads the row, checks
`req.principal` or writes into `req.ctx` — `principal` is the field the both timeouts, and sets `req.principal` on success (decision 5).
framework already means for identity, so it should be that unless the session - Rotate the id on privilege change — login especially — per decision 3: a fresh
carries more than identity. random id and row, the old row deleted, a fresh signed cookie set.
- Rotate the id on privilege change (login especially). Session fixation is the
attack that a login which reuses the pre-login id walks straight into.
- A missing, expired, unknown or tampered cookie all end at the same place — - A missing, expired, unknown or tampered cookie all end at the same place —
unauthenticated — but for distinguishable reasons, because debugging a login unauthenticated — but for **distinguishable** reasons (no cookie / bad
loop without that distinction is miserable. signature / no row / timed out), because debugging a login loop without that
- Verify: each of those four cases behaves; the id changes across login. distinction is miserable. The reasons are logged, not returned.
- Verify: each of those four cases behaves; the id changes across login; the
pre-login id is inert afterward.
### Phase C — login, logout, revoke-all ### Phase C — login, logout, revoke-all
- The three flows end to end in a sample, using porch's existing auth pieces - The three flows end to end in a sample, using porch's existing auth pieces
(`bearer_token`, `basic_credentials`, `ct_eq`) for the credential check — (`bearer_token`, `basic_credentials`, `ct_eq`) for the credential check —
sessions are about *keeping* identity, not establishing it. sessions are about *keeping* identity, not establishing it.
- Logout deletes the row and clears the cookie with matching attributes. - Login mints the fresh row and cookie (decision 3). Logout deletes the row and
- Revoke-all deletes by principal and is proven with two concurrent sessions. clears the cookie with matching attributes (iteration 2's clear-cookie).
Revoke-all deletes by principal through the secondary index and is proven with
two concurrent sessions.
- Verify: after logout the old cookie is inert even though it is still - Verify: after logout the old cookie is inert even though it is still
well-signed — the point of server-side state. well-signed — the point of server-side state.
@ -71,36 +120,43 @@ readiness: refine
- Extend `scripts/site-accept.sh` rather than only the storefront: the site has - Extend `scripts/site-accept.sh` rather than only the storefront: the site has
an admin route that currently checks a bearer token per request, which is an admin route that currently checks a bearer token per request, which is
exactly the thing sessions replace. exactly the thing sessions replace — so the migration is the proof.
- Ledger rows, status board entry, and the standup questions answered. - Ledger rows, status board entry, and the standup questions answered, including
the `.dev/reference` projects used.
- Verify: `just web-app`, `just site`, `just linkcheck` green. - Verify: `just web-app`, `just site`, `just linkcheck` green.
## Acceptance Criteria ## Acceptance Criteria
- **Given** a valid session cookie, **when** a request arrives, **then** - **Given** a valid session cookie, **when** a request arrives, **then**
identity is attached and the row's last-seen advances. identity is attached to `req.principal` and the row's last-seen advances —
subject to the throttle, so a fresh last-seen is not rewritten.
- **Given** a session whose signature is valid but whose row was deleted, - **Given** a session whose signature is valid but whose row was deleted,
**when** it is presented, **then** the request is unauthenticated — a **when** it is presented, **then** the request is unauthenticated — a
well-formed cookie is not authority. well-formed cookie is not authority.
- **Given** an idle timeout of T, **when** a session is unused for longer than - **Given** an idle timeout of T, **when** a session is unused for longer than
T, **then** it is rejected and its row pruned on that access. T, **then** it is rejected and its row pruned on that access — within the
documented `T/20` overshoot.
- **Given** an absolute timeout of A, **when** a session is *continuously - **Given** an absolute timeout of A, **when** a session is *continuously
active* past A, **then** it is still rejected — the criterion an idle-only active* past A, **then** it is still rejected — the criterion an idle-only
implementation fails. implementation fails.
- **Given** an anonymous session id, **when** the user logs in, **then** the id - **Given** an anonymous or planted session id, **when** the user logs in,
is rotated and the pre-login id is inert. **then** a fresh id is minted and the pre-login id is inert.
- **Given** active sessions and a SIGTERM plus restart, **when** the same - **Given** active sessions and a SIGTERM plus restart, **when** the same
cookies return, **then** the users are still logged in, replayed from the WAL. cookies return, **then** the users are still logged in, replayed from the WAL
with wall-clock timestamps intact.
- **Given** two sessions for one principal, **when** revoke-all runs, **then** - **Given** two sessions for one principal, **when** revoke-all runs, **then**
both are inert and other principals are untouched. both are inert and other principals are untouched.
- **Given** logout, **when** the cleared cookie is compared to the one that set - **Given** logout, **when** the cleared cookie is compared to the one that set
it, **then** the attributes match, so the browser actually drops it. it, **then** the attributes match, so the browser actually drops it.
- **Given** a configuration with absolute timeout below idle timeout, **when**
the app starts, **then** it is refused rather than run with an unreachable
idle timeout.
## Out Of Scope ## Out Of Scope
- **Flash messages and a general session bag.** A typed session with a known - **Flash messages and a general session bag.** A typed session with a known
shape first; an untyped `map<Text,Text>` payload is a decision to defer, not shape first; an untyped payload is deferred (decision 1), not adopted as a
a default to adopt. default. Apps needing per-session state keep their own keyed table.
- **OAuth, OIDC, SSO, "log in with X".** Every one needs an outbound socket, - **OAuth, OIDC, SSO, "log in with X".** Every one needs an outbound socket,
which does not exist — language which does not exist — language
[iteration 38](../language-runtime-database/38-content-platform-capabilities.md). [iteration 38](../language-runtime-database/38-content-platform-capabilities.md).
@ -109,22 +165,33 @@ readiness: refine
- **CSRF** — iteration [4](04-csrf.md). Sessions make CSRF *possible* to do - **CSRF** — iteration [4](04-csrf.md). Sessions make CSRF *possible* to do
properly; they do not provide it. properly; they do not provide it.
- **Sliding-window renewal of the absolute timeout.** That is not what absolute - **Sliding-window renewal of the absolute timeout.** That is not what absolute
means. means; the throttled touch (decision 4) slides only `last_seen`, never
`created_at`.
## Info ## Info
Forks the spec must settle: The throttle in decision 4 interacts with durability in a way worth stating: a
restart replays the last *written* `last_seen`, which may be up to `idle/20`
staler than the true last access. That only ever makes the idle timer slightly
conservative — a session logs out marginally earlier after a crash, never
later — so it is safe in the direction that matters.
1. **What does the session row carry beyond identity?** Just principal, or a The one language dependency is entirely upstream: iteration 2's `random_bytes`
payload? A payload wants a shape, and without generics the shape is either a and signed-cookie helpers. This iteration is pure `.wo` on top of them plus the
declared class per app or `Text`. The framework's cache already stores `Text` `@table` engine that iteration 1 already proved under the actor store — no new
and that is the compromise this repo dislikes most — so leaning: identity runtime work.
plus declared columns, and apps that want more keep their own table keyed by
session id. It also does **not** need the per-key actor pool, and so is **not blocked on
2. **Idle-timeout writes on every request.** Touching last-seen means a WAL lang-41** (unlike iteration 9). The pool exists for the rate limiter's atomic
write per request, which turns every read into a durable write. Options: read-modify-write counter; sessions have no counter. Create is an insert of a
write at a coarser granularity, or accept the cost and say so with a number fresh unique id, load is a read, touch is a last-writer-wins on `last_seen` (a
from `just db-bench`. This is a real performance fork, not a detail. race writes ≈ the same value, harmless), delete is idempotent — all plain
3. **Does `Session` set `req.principal` or `req.ctx`?** `principal` is the `@table` CRUD over the same gate-green DB path the storefront and skill-catalog
framework's declared home for identity and auth middleware already writes it, use, never the `keypool` actor that provokes the hang. Confirmed 2026-09-06
so two writers of one field need a documented precedence. against `.dev/reference/fiber` `middleware/session`: fiber enforces idle timeout
by refreshing a storage TTL on a `Save` at the end of **every** request (a write
per request its in-memory store makes cheap); porch's throttled lazy touch is
the durable-WAL equivalent. The one place sessions would have forced language
work — fiber's general `Set(key, any)` bag with `msgp` codegen and
`RegisterType` reflection — is closed by decision 1's no-payload rule, which
principle 13 (no reflection) would otherwise have collided with.

View file

@ -2,13 +2,17 @@
track: porch track: porch
iteration: "4" iteration: "4"
status: pending status: pending
readiness: refine readiness: ready
--- ---
# porch 4 — CSRF: tokens that are unguessable, bound, and spendable once # porch 4 — CSRF: tokens that are unguessable, bound, and spendable once
> Part of [Story — `porch`, the writeonce web framework](00-story.md). > Part of [Story — `porch`, the writeonce web framework](00-story.md).
> Source: [the Fiber parity study](../../plan/exploration/fiber/00-fiber-parity.md) §2. > 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 > Needs [2](02-randomness-and-cookies.md) for randomness and cookies, and
> [3](03-sessions.md) for something to bind a token to. > [3](03-sessions.md) for something to bind a token to.
@ -34,46 +38,93 @@ readiness: refine
already-spent must be told apart in the logs, or nobody can debug a form that already-spent must be told apart in the logs, or nobody can debug a form that
stopped working. 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 ## Phases
### Phase A — mint and verify ### Phase A — mint and verify
- Token generation, storage keyed by session, and constant-time verification. - The `CsrfToken` `@table`: `token @unique`, `session_id` (indexed for
- Decide the transport: a dedicated cookie plus a form field (double-submit), or rotation cleanup), `created_at` (wall-clock, lazy idle expiry like sessions).
session-stored plus a form field. The second needs no second cookie and is the Mint draws from iteration 2's `random_bytes`, stores the row, and sets the
stronger of the two once sessions exist. CSRF cookie carrying the token.
- Extraction from where forms and fetch clients actually put it: a form field, a - Verify does the hybrid check (decision 1) with `ct_eq` on the token halves,
header, and decide whether a query parameter is ever allowed (it should not be then the session-binding lookup.
— it leaks into logs and referrers). - 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 - Verify: a valid token passes; altered, absent and foreign tokens each fail
distinctly. distinctly; a token minted for session A presented under session B is refused.
### Phase B — the middleware and safe-method policy ### Phase B — the middleware and safe-method policy
- A `Csrf` middleware gating unsafe methods only. `GET`/`HEAD`/`OPTIONS` must - A `Csrf` middleware gating unsafe methods only. `GET`/`HEAD`/`OPTIONS` pass
pass untouched or every link on the site breaks. untouched — and a `GET` of a form page is where the token is minted for the
- Origin and `Referer` checking against a configured trusted set, including the form that will submit it — or every link on the site breaks.
awkward cases: absent origin, `null` origin, and a same-origin request that - Origin and `Referer` checking against a configured trusted set (the app's own
arrives without the header. host, which behind the proxy is the forwarded `Host` that `HostAllow` already
- Failure is a distinct status with a body that does not leak whether the token validates, plus configured extras), including the awkward cases fiber handles:
was wrong or merely stale. 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 - Verify: the site's admin edit flow works through the middleware; unsafe
methods without a token are refused; safe methods are unaffected. methods without a token are refused; safe methods are unaffected.
### Phase C — single use and rotation ### Phase C — single use and rotation
- Mark-spent-on-use for the routes that opt in, and decide what happens to a - Mark-spent-on-use (delete the row) for the routes that opt in (decision 2); a
double-submitted form (the user double-clicking is not an attack, and treating double-click produces the `SPENT` class (decision 3), not a raw 403.
it as one is a support ticket). - Rotate on privilege change, matching the session-id rotation from iteration 3:
- 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 - Verify: a spent token is refused; a double-click produces a comprehensible
outcome rather than a raw 403. outcome; a token outstanding for the pre-login session is inert after login.
### Phase D — the gate and the ledger ### Phase D — the gate and the ledger
- Both serving gates: the site's admin edit is the natural CSRF subject, the - Both serving gates: the site's admin edit is the natural CSRF subject (and the
storefront's checkout the natural single-use subject. phase migrates it from its current per-request bearer check onto session +
- Ledger and status board. 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. - Verify: `just web-app`, `just site`, `just linkcheck` green.
## Acceptance Criteria ## Acceptance Criteria
@ -82,6 +133,8 @@ readiness: refine
it is refused and the handler never runs. it is refused and the handler never runs.
- **Given** a token minted for session A, **when** it is presented with session - **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. 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 - **Given** a token altered by one bit, **when** it is verified, **then** it is
refused in constant time. refused in constant time.
- **Given** a request from an untrusted origin carrying an otherwise valid - **Given** a request from an untrusted origin carrying an otherwise valid
@ -90,8 +143,8 @@ readiness: refine
- **Given** a safe method (`GET`, `HEAD`, `OPTIONS`), **when** it arrives with - **Given** a safe method (`GET`, `HEAD`, `OPTIONS`), **when** it arrives with
no token, **then** it passes untouched. no token, **then** it passes untouched.
- **Given** a single-use token, **when** it is submitted twice, **then** the - **Given** a single-use token, **when** it is submitted twice, **then** the
second attempt is refused and the outcome is distinguishable from a forged second attempt is refused as `SPENT`, told apart from a forged token in the
token in the logs. logs.
- **Given** login, **when** the session id rotates, **then** the outstanding - **Given** login, **when** the session id rotates, **then** the outstanding
token for the old session is no longer valid. token for the old session is no longer valid.
- **Given** each refusal class, **when** the logs are read, **then** missing, - **Given** each refusal class, **when** the logs are read, **then** missing,
@ -110,22 +163,17 @@ readiness: refine
iteration [1](01-store-backed-middleware.md); the rest is not porch's. iteration [1](01-store-backed-middleware.md); the rest is not porch's.
- **Encrypted or stateless tokens.** No symmetric cipher exists, and a - **Encrypted or stateless tokens.** No symmetric cipher exists, and a
stateless token cannot be revoked or spent once. 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 ## Info
Forks the spec must settle: 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
1. **Double-submit cookie, or session-stored token?** Double-submit needs no `@table` engine — no new runtime work, and, like sessions, no dependence on the
store and works without sessions; session-stored needs no second cookie and per-key actor pool or on the lang-41 fix. Origin and Referer matching is string
is strictly stronger. Since iteration 3 lands first, leaning session-stored — comparison against the trusted set; form-field extraction is `form_values`,
and if so, say plainly that porch has no CSRF story for sessionless apps which already exists.
rather than shipping the weaker one silently.
2. **Which routes default to single-use?** All of them is safest and the most
annoying; opt-in is pleasant and easy to forget on the one route that
mattered. Leaning opt-in with the checkout as the worked example, because a
default nobody can live with gets disabled wholesale.
3. **What happens when a form is submitted twice by a human?** This is the fork
that decides whether the feature is usable. Iteration
[1](01-store-backed-middleware.md)'s idempotency machinery may be the honest
answer — the same key, replayed, gets the same response — which would make
these two features compose rather than collide.