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:
parent
6a67db252b
commit
274f7c5361
3 changed files with 210 additions and 95 deletions
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue