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

4.3 KiB

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:

// 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

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

// 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();
  // ...
}
// 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 });
  // ...
}
// 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 });
  // ...
}
// 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

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.