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
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`
(brainstormed 2026-09-06, five forks locked, validated against
`.dev/reference/fiber`); 3–8 remain `refine`.** Ordered by dependency; the
Supersedes language iteration 39, now a pointer. **Stories 2, 3 and 4 are
`ready` (brainstormed 2026-09-06, forks locked, validated against
`.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
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) |
| 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 |
| 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 |
| 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) | ✅ **`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 🔶) |
| 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 |

View file

@ -2,13 +2,17 @@
track: porch
iteration: "3"
status: pending
readiness: refine
readiness: ready
---
# porch 3 — sessions: server-side state, revocable, durable
> 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
> signed cookie to carry it) and inherits the store convention from
> [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.
- **Both timeouts, because they answer different questions.** Idle timeout
bounds "how long since you did anything"; absolute timeout bounds "how long
since you authenticated". Fiber ships both (`IdleTimeout`, `AbsoluteTimeout`)
and a session with only the first never expires for an active attacker.
since you authenticated". Fiber ships both and a session with only the first
never expires for an active attacker.
- **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 —
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
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
### Phase A — the session table and its lifecycle
- The `@table` class: random id, principal, created-at, last-seen, and whatever
payload shape fork 1 settles. Secondary index on principal, because
"revoke all for this user" is an equality probe and that is the only index
shape the engine has.
- Create, touch, expire and delete, with expiry evaluated on access (the lazy
discipline iteration 1 established — there is still no timer).
- Verify: rows replay across a restart; an expired row is pruned when touched.
- The `@table` class with `id @unique`, `principal`, `created_at`, `last_seen`
(all wall-clock), and two indexes — `index: [id]` for the load and
`index: [principal]` for revoke-all. The non-unique secondary index on a
plain column is the same shape `skill-catalog` already uses, so revoke-all is
an equality probe, the only index shape the engine has.
- Create, touch (throttled per decision 4), expire and delete, with expiry
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
- A `Session` middleware whose `before` reads the signed cookie, loads the row,
checks both timeouts, and attaches identity. Decide whether it sets
`req.principal` or writes into `req.ctx` — `principal` is the field the
framework already means for identity, so it should be that unless the session
carries more than identity.
- 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 `Session` middleware whose `before` reads the signed cookie (iteration 2's
`verify` with the app-supplied key), extracts the id, loads the row, checks
both timeouts, and sets `req.principal` on success (decision 5).
- Rotate the id on privilege change — login especially — per decision 3: a fresh
random id and row, the old row deleted, a fresh signed cookie set.
- A missing, expired, unknown or tampered cookie all end at the same place —
unauthenticated — but for distinguishable reasons, because debugging a login
loop without that distinction is miserable.
- Verify: each of those four cases behaves; the id changes across login.
unauthenticated — but for **distinguishable** reasons (no cookie / bad
signature / no row / timed out), because debugging a login loop without that
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
- 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 —
sessions are about *keeping* identity, not establishing it.
- Logout deletes the row and clears the cookie with matching attributes.
- Revoke-all deletes by principal and is proven with two concurrent sessions.
- Login mints the fresh row and cookie (decision 3). Logout deletes the row and
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
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
an admin route that currently checks a bearer token per request, which is
exactly the thing sessions replace.
- Ledger rows, status board entry, and the standup questions answered.
exactly the thing sessions replace — so the migration is the proof.
- 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.
## Acceptance Criteria
- **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,
**when** it is presented, **then** the request is unauthenticated — a
well-formed cookie is not authority.
- **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
active* past A, **then** it is still rejected — the criterion an idle-only
implementation fails.
- **Given** an anonymous session id, **when** the user logs in, **then** the id
is rotated and the pre-login id is inert.
- **Given** an anonymous or planted session id, **when** the user logs in,
**then** a fresh id is minted and the pre-login id is inert.
- **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**
both are inert and other principals are untouched.
- **Given** logout, **when** the cleared cookie is compared to the one that set
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
- **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
a default to adopt.
shape first; an untyped payload is deferred (decision 1), not adopted as a
default. Apps needing per-session state keep their own keyed table.
- **OAuth, OIDC, SSO, "log in with X".** Every one needs an outbound socket,
which does not exist — language
[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
properly; they do not provide it.
- **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
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
payload? A payload wants a shape, and without generics the shape is either a
declared class per app or `Text`. The framework's cache already stores `Text`
and that is the compromise this repo dislikes most — so leaning: identity
plus declared columns, and apps that want more keep their own table keyed by
session id.
2. **Idle-timeout writes on every request.** Touching last-seen means a WAL
write per request, which turns every read into a durable write. Options:
write at a coarser granularity, or accept the cost and say so with a number
from `just db-bench`. This is a real performance fork, not a detail.
3. **Does `Session` set `req.principal` or `req.ctx`?** `principal` is the
framework's declared home for identity and auth middleware already writes it,
so two writers of one field need a documented precedence.
The one language dependency is entirely upstream: iteration 2's `random_bytes`
and signed-cookie helpers. This iteration is pure `.wo` on top of them plus the
`@table` engine that iteration 1 already proved under the actor store — no new
runtime work.
It also does **not** need the per-key actor pool, and so is **not blocked on
lang-41** (unlike iteration 9). The pool exists for the rate limiter's atomic
read-modify-write counter; sessions have no counter. Create is an insert of a
fresh unique id, load is a read, touch is a last-writer-wins on `last_seen` (a
race writes ≈ the same value, harmless), delete is idempotent — all plain
`@table` CRUD over the same gate-green DB path the storefront and skill-catalog
use, never the `keypool` actor that provokes the hang. Confirmed 2026-09-06
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
iteration: "4"
status: pending
readiness: refine
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.
> 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.
@ -34,46 +38,93 @@ readiness: refine
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
- Token generation, storage keyed by session, and constant-time verification.
- Decide the transport: a dedicated cookie plus a form field (double-submit), or
session-stored plus a form field. The second needs no second cookie and is the
stronger of the two once sessions exist.
- Extraction from where forms and fetch clients actually put it: a form field, a
header, and decide whether a query parameter is ever allowed (it should not be
— it leaks into logs and referrers).
- 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.
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` must
pass untouched or every link on the site breaks.
- Origin and `Referer` checking against a configured trusted set, including the
awkward cases: absent origin, `null` origin, and a same-origin request that
arrives without the header.
- Failure is a distinct status with a body that does not leak whether the token
was wrong or merely stale.
- 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 for the routes that opt in, and decide what happens to a
double-submitted form (the user double-clicking is not an attack, and treating
it as one is a support ticket).
- Rotate on privilege change, matching the session-id rotation from iteration 3.
- 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 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
- Both serving gates: the site's admin edit is the natural CSRF subject, the
storefront's checkout the natural single-use subject.
- Ledger and status board.
- 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
@ -82,6 +133,8 @@ readiness: refine
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
@ -90,8 +143,8 @@ readiness: refine
- **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 and the outcome is distinguishable from a forged
token in the logs.
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,
@ -110,22 +163,17 @@ readiness: refine
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
Forks the spec must settle:
1. **Double-submit cookie, or session-stored token?** Double-submit needs no
store and works without sessions; session-stored needs no second cookie and
is strictly stronger. Since iteration 3 lands first, leaning session-stored —
and if so, say plainly that porch has no CSRF story for sessionless apps
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.
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.