- 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>
6.4 KiB
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
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.
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
Csrfmiddleware gating unsafe methods only.GET/HEAD/OPTIONSmust pass untouched or every link on the site breaks. - Origin and
Refererchecking against a configured trusted set, including the awkward cases: absent origin,nullorigin, 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 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 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 (
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.
Info
Forks the spec must settle:
- 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.
- 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.
- 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.