writeonce/docs/stories/porch/04-csrf.md
shoney.arickathil 01df75245f docs(porch): give the framework its own story track, iterations 1-8
- docs/stories/porch/ — a TRACK folder, not a status folder: status still
  lives only in frontmatter. Adds `track: porch` so a query over
  docs/stories/ can tell a porch 3 from a language 3
- 00-story.md carries the sequence, the dependency graph, and a table of
  what the track explicitly does NOT own (binding -> 29, cache -> 18,
  proxy -> 38, metrics -> 30, TLS/templates -> doctrine)
- eight iterations, each with phases, per-phase tasks, Given/When/Then
  criteria, out-of-scope and the forks a spec must settle:
  1 store-backed middleware (limiter + idempotency — needs nothing new,
    first on purpose so the store pattern is proven cheaply)
  2 randomness + cookies (phase A is language-track: a CSPRNG builtin;
    `Resp.headers` being a map cannot emit two Set-Cookie lines)
  3 sessions   4 CSRF   5 routing/response ergonomics (independent)
  6 streaming core (the seam 7 and 8 wait on; chunked-request refusal
    must survive)   7 SSE + compression   8 static + lifecycle hooks
- language iteration 39 -> status: hold, retitled superseded, with a row
  mapping each of its goals to the porch iteration that took it. Kept, not
  deleted: the Fiber study cites it and its randomness argument is what
  this track is built on
- board gains a porch section; board-views gains porch and both-track
  Dataview queries; porch README and the Fiber study §7 point at the track
- no code blocks in any story (plans carry concept and actions in words);
  linkcheck 0 broken / 0 anchors

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:17:33 +02:00

6.4 KiB

track iteration status
porch 4 refine

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. 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 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.

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).
  • Verify: a valid token passes; altered, absent and foreign tokens each fail distinctly.

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.
  • 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.
  • Verify: a spent token is refused; a double-click produces a comprehensible outcome rather than a raw 403.

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.
  • 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 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 and the outcome is distinguishable 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; the rest is not porch's.
  • Encrypted or stateless tokens. No symmetric cipher exists, and a stateless token cannot be revoked or spent once.

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'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.