# 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; ``` ### 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.