82 lines
6.4 KiB
Markdown
82 lines
6.4 KiB
Markdown
# 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:
|
|
|
|
```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.
|