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

6.4 KiB

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

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.

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.
service-token POST /v1/ai/upload, 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.
service-or-tenant-api-key POST /v1/entities/:entity/rows, PATCH /v1/entities/:entity/rows/:rowId, DELETE /v1/entities/:entity/rows/:rowId, POST /v1/relationships, DELETE /v1/relationships/:id Requires service-token or tenant API-key authorization with tenant scope rather than browser CSRF state.
webhook-signature POST /v1/events/webhooks/attempts/:id, POST /v1/support/inbound-email, POST /v1/email-sequences/mailjet/webhooks, POST /v1/billing/stripe/webhooks Requires signed webhook headers before queueing retry work, accepting inbound support email, correlating sequence provider bounces, or processing billing lifecycle events.
public-analytics-token POST /v1/analytics/events, POST /v1/analytics/page-views Public analytics write ingress scoped to analytics payload identifiers and analytics-token semantics, not a browser session.

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:

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.