122 lines
4.3 KiB
Markdown
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.
|