# CSRF and Mutating Route Inventory This inventory documents the intended CSRF and alternate-auth posture for mutating browser and API surfaces. It is deliberately limited to route names, protection classes, and verification commands; it must not include secrets, tokens, tenant identifiers, fixture IDs, or customer data. ## Central CSRF Contract The web session CSRF contract is centralized in `apps/web/app/utils/session.server.ts` and covered by `apps/web/app/utils/session.server.test.ts` plus the static sweep in `apps/web/app/utils/session-csrf-sweep.test.ts`. The runtime API contract — including the single-read FormData injection rule introduced by the B-009 follow-up fix — is documented in `docs/architecture/csrf.md`. Expected behavior: - New CSRF state is stored and read through the canonical `csrf` session key. - Request validation compares the submitted `csrf` form value with the session `csrf` value. - Raw session mutation of `session.set("csrf", ...)` stays centralized in the session helper. - Action handlers that drain `request.formData()` before validating MUST pass the parsed FormData via `validateCSRFToken(request, { formData })`; the helper never re-reads a consumed body. See `docs/architecture/csrf.md` for the full API. - Historical legacy spelling `crsf` is mentioned here only as a deprecated spelling that must not reappear in application source or active session behavior. ## Protected Web Form and Action Surfaces Protected React Router POST form surfaces must either submit a `csrf` field or route through server-side CSRF validation. The current enforced protected form entry is: | Surface | Protection | Enforced by | | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `apps/web/app/modules/users/components/RegisterForm.tsx` | Hidden `csrf` field with server-side validation in the auth flow | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/utils/session.server.test.ts` | | `apps/web/app/routes/app.$tenant/calls.$id.tsx` | Programmatic note, match, and callback POSTs append the root-session token and validate the already parsed `FormData` before tenant access | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/routes/app.$tenant/telephony.routes.test.ts` | | `apps/web/app/routes/app.$tenant/settings.integrations.telephony.tsx` | Programmatic health, binding, and disconnect POSTs append the root-session token and validate the already parsed `FormData` before tenant access | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/routes/app.$tenant/telephony.routes.test.ts` | | `apps/web/app/routes/app.$tenant/telephony.push-subscription.ts` | JSON registration requires the root-session token in the CSRF header, rejects tenant/user fields from the client, and persists only after membership, module, permission, and tenant-RLS checks | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/routes/app.$tenant/telephony.push-subscription.test.ts` | | Contact, company, lead, and project detail routes | Embedded Aircall Click-to-Call POSTs append the root-session token, validate it before the tenant-scoped number-to-entity check, and keep `tel:` fallback entirely client-side | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/modules/telephony/services/TelephonyService.server.test.ts` | | `apps/web/app/routes/integrations.aircall.oauth.install.tsx` | Marketplace GET renders only a read-only entry or confirmation screen. Only its CSRF-protected POST reserves the tenant slot and redirects to Aircall after membership, module, and `telephony.manage` checks | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/routes/integrations.aircall.oauth.routes.test.ts` | The sweep intentionally reports path-specific diagnostics so any newly added protected `Form` or `fetcher.Form` with `method="post"` must be added with a clear protection path instead of silently bypassing CSRF. ### Telephony read and callback routes | Surface | Classification | | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `apps/web/app/routes/app.$tenant/calls._index.tsx` | Read-only tenant route; requires an active session, tenant membership, enabled Telephony module, and call-read permission before RLS-scoped reads. | | `apps/web/app/routes/integrations.aircall.oauth.callback.tsx` | Provider GET callback; requires a valid short-lived HMAC-signed state bound to tenant, initiating user, and connection slot, then re-checks the active session, membership, module, and `telephony.manage` permission before token exchange. | ## Documented Web Exceptions These browser-facing mutating surfaces are intentional non-CSRF exceptions because they are public entry points that create or recover sessions, accept invitation input, or otherwise run before a signed-in CSRF-bearing session exists. | Surface | Rationale / alternate guard | | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `apps/web/app/modules/users/components/LoginForm.tsx` | Public credential exchange creates a session before CSRF exists; guarded by authentication checks rather than an existing session token. | | `apps/web/app/routes/forgot-password.tsx` | Public password-reset request surface; protection is request-specific reset flow handling rather than a prior session CSRF token. | | `apps/web/app/routes/invitation.$id.tsx` | Public invitation acceptance surface addressed by invitation context; does not rely on an existing authenticated browser session. | | `apps/web/app/routes/reset.tsx` | Public password-reset completion surface; guarded by reset-flow state rather than a prior session CSRF token. | Keep these exceptions path-specific. Broad directory or glob exceptions would make future protected forms unverifiable. ## Dedicated API Mutating Route Classes The Hono API under `/v1` is not protected by browser-session CSRF. Mutating API endpoints are classified by dedicated authentication or public-ingress semantics in `apps/api/src/csrf-inventory.test.ts`. | Class | Routes | Rationale | | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `auth-credential-exchange` | `POST /v1/auth/login` | Exchanges submitted credentials for an API JWT after credential verification. | | `jwt-auth` | `POST /v1/auth/refresh-token`, `POST /v1/auth/logout` | Requires an existing bearer JWT before issuing a replacement token or revoking the active API session. | | `optional-api-key-validation` | `POST /v1/api-keys/validate` | Public validation surface that classifies supplied API keys or service tokens without browser CSRF state. | | `platform-source-credential` | `POST /v1/platform/leads` | Server-to-server lead ingestion requires a rotatable source-scoped bearer capability and mandatory source-partitioned idempotency instead of browser-session CSRF state. | | `service-token` | `POST /v1/maintenance/export-retention/enqueue`, `POST /v1/workspaces/:tenantId/context/revalidate`, `POST /v1/workspaces/:tenantId/invites`, `POST /v1/workspaces/:tenantId/invites/:inviteId/accept`, `POST /v1/workspaces/:tenantId/invites/:inviteId/revoke` | Requires service/runtime credentials rather than browser CSRF state. | | `webhook-signature` | `POST /v1/events/webhooks/attempts/:id`, `POST /v1/support/inbound-email`, `POST /v1/email/inbound-mailjet`, `POST /v1/email-sequences/mailjet/webhooks` | Requires signed webhook credentials before queueing retry work, accepting inbound support or tenant email, or correlating sequence provider bounces. | | `provider-hmac-webhook` | `POST /v1/admin-sales/email/provider-events`, `POST /v1/billing/lago/webhooks` | Requires the provider-specific HMAC contract over the exact bounded request body before binding an AdminUser or tenant RLS context or persisting provider lifecycle changes. | | `webhook-signature` | `POST /v1/admin-sales/email/bcc-inbound` | Requires provider Basic-Auth plus one signed, rotatable BCC recipient capability before binding AdminUser RLS context or persisting an external send copy. | | `provider-body-token-webhook` | `POST /v1/webhooks/telephony/aircall`, `POST /v1/webhooks/admin-telephony/aircall` | Authenticates the bounded Aircall body token against its stored hash before resolving tenant or AdminUser context and persisting idempotent call state; neutral responses disclose no credential, tenant, or AdminUser state. | | `provider-header-token-webhook` | `POST /v1/webhooks/telephony/hallopetra` | Authenticates the dedicated bearer webhook secret against its stored hash and encrypted connection credential before resolving tenant context and persisting idempotent post-call state; neutral responses disclose no credential or tenant state. | | `provider-refetch-webhook` | `POST /v1/billing/mollie/webhooks` | Accepts only an opaque payment id and refetches the authoritative payment from Mollie server-side before tenant-scoped billing writes. | | `public-analytics-token` | `POST /v1/analytics/events`, `POST /v1/analytics/page-views` | Browser-facing ingest capability accepts only a bounded anonymous visitor identifier, rejects user/portal identity fields, and writes tenantless records without browser-session authority. | Any new `POST`, `PATCH`, or `DELETE` `/v1` route must be classified in the API inventory test with an allowed protection class and a non-empty rationale. ## Verification Commands Run focused verification from the repository root after changing CSRF/session behavior, protected POST forms, API mutating routes, or this document: ```bash pnpm exec vitest run apps/web/app/utils/session.server.test.ts apps/web/app/utils/session-csrf-sweep.test.ts apps/api/src/csrf-inventory.test.ts test -s docs/security/csrf-inventory.md && grep -q "apps/api/src/csrf-inventory.test.ts" docs/security/csrf-inventory.md python - <<'PY' from pathlib import Path text = Path('docs/security/csrf-inventory.md').read_text() required = [ 'Central CSRF Contract', 'Protected Web Form and Action Surfaces', 'Documented Web Exceptions', 'Dedicated API Mutating Route Classes', 'Verification Commands', 'apps/web/app/utils/session-csrf-sweep.test.ts', 'apps/api/src/csrf-inventory.test.ts', ] missing = [item for item in required if item not in text] assert not missing, missing assert text.count('c' + 'rsf') == 1 PY ``` The Python check keeps the document regression-friendly by requiring the durable sections and allowing the legacy spelling only in the historical note above.