Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/csrf.md
T

122 lines
4.3 KiB
Markdown

# CSRF — Single-Read Helper Contract
This document covers the runtime API contract of `validateCSRFToken` in
`apps/web/app/utils/session.server.ts`. The complementary route-level
inventory of which surfaces are protected lives in
`docs/security/csrf-inventory.md` — read that file when adding a new
mutating route.
## Background — the body-already-consumed bug
Before the May 2026 fix, `validateCSRFToken(request)` always tried to
locate the request token by either reading the `x-csrf-token` header or,
falling back, by cloning the request and re-parsing the body:
```ts
// historical implementation (broken when caller already drained the body)
const formData = await request.clone().formData();
```
Cloning a request whose body was already consumed throws
`TypeError: unusable` in undici. UI form-submits where the action handler
read `await request.formData()` first and validated afterwards crashed
in production with a 500. The bug was first surfaced during the B-009
lighthouse-bot session-cookie capture, where the workaround was to send
the token via the `x-csrf-token` header to bypass the body path.
## API contract
```ts
export type ValidateCSRFTokenOptions = {
/** Already-parsed form data. When set, the helper NEVER reads the body. */
formData?: FormData;
/** Skip body fallback entirely. Header must carry the token. */
headerOnly?: boolean;
};
export async function validateCSRFToken(
request: Request,
options?: ValidateCSRFTokenOptions,
): Promise<void>;
```
### Resolution order
1. `x-csrf-token` / `csrf-token` request header.
2. If `headerOnly` is `true`, stop here.
3. `options.formData?.get("csrf")` — used when the caller has already
parsed the body.
4. Body fallback: clone-and-parse the request as `FormData`,
`application/json`, or `URLSearchParams`. Reaching this branch on a
request whose body has already been consumed surfaces a CSRF-specific
error (`CSRF body already consumed; pass { formData } to validateCSRFToken.`)
instead of the opaque `TypeError: unusable` from undici.
### Calling patterns
```ts
// Preferred: validate before reading the body. Header-first short-circuit
// keeps this path zero-cost on header-bearing requests.
export async function action({ request }: ActionFunctionArgs) {
await validateCSRFToken(request);
const form = await request.formData();
// ...
}
```
```ts
// Required when the caller parses the body first (e.g. routing based on a
// form action discriminator). Inject the parsed FormData — never let the
// helper attempt to re-read the consumed body.
export async function action({ request }: ActionFunctionArgs) {
const form = await request.clone().formData();
await validateCSRFToken(request, { formData: form });
// ...
}
```
```ts
// Helpers / cross-file flows that receive a request plus pre-parsed
// FormData from the route action must forward the FormData.
async function loginFromRequest(request: Request, form: FormData) {
await validateCSRFToken(request, { formData: form });
// ...
}
```
```ts
// Bot / lighthouse / capture contexts where the body is reserved for a
// different purpose. The token must be sent in the x-csrf-token header.
await validateCSRFToken(request, { headerOnly: true });
```
## Migrated callsites (May 2026 fix)
- `apps/web/app/root.tsx` — the locale/theme action pre-parses form data
for routing and now hands the FormData to `validateCSRFToken`.
- `apps/web/app/modules/users/services/AuthService.ts` —
`loginFromRequest` receives `FormData` from `routes/login.tsx` and
`root.tsx`, and forwards it.
Any new helper that accepts `(request, formData)` from a caller MUST
forward the FormData to `validateCSRFToken`. The convention check is
covered by `apps/web/app/utils/session.csrf-regression.test.ts`.
## Verification
```bash
pnpm exec vitest run \
apps/web/app/utils/session.server.test.ts \
apps/web/app/utils/session.csrf-regression.test.ts \
apps/web/app/utils/session-csrf-sweep.test.ts
```
The regression file specifically asserts:
- Calling the helper after `await request.formData()` succeeds when the
parsed body is injected.
- Calling the helper after the body was consumed without injection raises
a CSRF-specific error — never the undici `TypeError: unusable`.
- The header-token fast path still skips the body even when the body has
already been read.