# Security Hardening — Coordination Contract Four tasks are being implemented **in parallel by four separate agents**. This file is the single coordination contract: it defines the shared CSRF API that Task B creates and Task C consumes, the exact file ownership map (so agents never edit the same file), and the acceptance criteria each agent must satisfy. Project: Next.js 15 (App Router, `output: "standalone"`, Turbopack dev), Drizzle ORM + Postgres, custom session-cookie auth. Path alias `@/*` → `src/*`. --- ## Task overview and current state | Task | Current state | Work required | |---|---|---| | **A — HSTS / HTTPS-only** | `next.config.ts` already sends `Strict-Transport-Security: max-age=63072000; includeSubDomains; preload` on `/(.*)` | Verify, harden (recommend: only emit HSTS when `x-forwarded-proto: https`), add a unit test, update `.env.example` guidance | | **B — CSRF tokens** | Not implemented. Only `sameSite: "lax"` cookies | Full implementation: token issuance (double-submit cookie), Origin check, server `requireCsrf` helper, client `apiFetch` helper, wire into every mutating API route + every client fetch call site, tests | | **C — Sessions invalidated on password change** | `resetPasswordWithToken` already deletes all sessions; **no logged-in password-change flow exists** (settings page is a static mockup) | Add `changePasswordForUser` to `accounts.ts`, new `POST /api/auth/change-password` route (must call `requireCsrf`), a working password-change form in the settings page, integration tests | | **D — Reset-link expiry** | Already implemented: `PASSWORD_RESET_TTL_MS = 60min`, single-use `consumedAt`, `peekPasswordResetToken`, page shows expired/invalid | Verify + add integration tests (expiry, single-use, session invalidation on reset) + short docs note. **Do not modify `src/lib/auth/accounts.ts`** (owned by Task C) | --- ## Shared CSRF API contract (created by Task B, consumed by Task C) File **`src/lib/auth/csrf.ts`** — exact exports: ```ts export const CSRF_COOKIE = "sr_csrf"; // double-submit cookie name export const CSRF_HEADER = "x-csrf-token"; // header the client echoes /** Fresh random token (crypto randomBytes(32).toString("base64url")). */ export function issueCsrfToken(): string; /** Cookie attributes: httpOnly:false (JS must read it), sameSite:"lax", * secure: isProduction, path:"/", maxAge 24h, same values for dev/prod. */ export function csrfCookieOptions(): { httpOnly: false; sameSite: "lax"; secure: boolean; path: "/"; maxAge: number }; /** Constant-time comparison of two values (undefined-safe). */ export function tokensMatch(a: string | undefined, b: string | undefined): boolean; /** Origin allow-list check. Returns true when the Origin header is absent * (non-browser client) or matches the configured site origin (incl. localhost * variants in dev). Uses `siteUrl` from `@/lib/seo/site`. */ export function isAllowedOrigin(request: Request): boolean; /** Full check: origin allow-list AND cookie==header (constant-time). * True = request is CSRF-safe. */ export function validateCsrf(request: Request): boolean; /** Route guard: returns null when safe, else a 403 NextResponse * `{ error: "csrf_failed" }`. Every mutating route calls this FIRST. */ export function requireCsrf(request: Request): NextResponse | null; ``` Client helper **`src/lib/csrf/client.ts`** (created by Task B): ```ts /** fetch() wrapper that reads the `sr_csrf` cookie from document.cookie and * adds the `x-csrf-token` header to every request. */ export function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise; ``` Middleware (owned by Task B): set the `sr_csrf` cookie on responses when missing, so the token exists before any page/form/API call. Keep the existing admin-subdomain rewrite and locale redirect behaviour intact. Exemptions (routes that must NOT call `requireCsrf`): `POST /api/webhooks/stripe` (signature-verified, Stripe servers send no Origin), GET routes, OAuth start/callback GETs. --- ## File ownership map (agents may ONLY write these files) **Task A (HSTS):** `next.config.ts`, `tests/e2e/security_headers.test.ts` (new), `.env.example`, docs note in `SECURITY_HARDENING.md` § "Task A — done". **Task B (CSRF):** - `src/lib/auth/csrf.ts` (new) - `src/lib/csrf/client.ts` (new) - `src/middleware.ts` - Every mutating route file under `src/app/api/` EXCEPT `src/app/api/webhooks/stripe/route.ts`, EXCEPT `src/app/api/auth/change-password/route.ts` (Task C creates it) — add `requireCsrf` to: auth/login, auth/signup, auth/logout, auth/forgot-password, auth/reset-password, auth/resend-verification, admin/waitlist, admin/users, admin/receipts, admin/system, receipts, scan, export/csv, export/excel, export/pdf, onboarding, checkout, license/verify, waitlist. (Enumerate by grepping for `export async function (POST|PUT|PATCH|DELETE)` in `src/app/api`.) - Every client file that calls `fetch(` under `src/` → switch to `apiFetch`. Known: `components/paywall/PaywallModal.tsx`, `app/(app)/admin/waitlist/page.tsx`, `components/auth/ResetPasswordForm.tsx`, `components/auth/AuthForm.tsx`, `components/auth/ForgotPasswordForm.tsx`, `app/(app)/admin/users/page.tsx`, `app/(marketing)/[locale]/page.tsx`, `app/(app)/admin/page.tsx`, `app/(app)/admin/system/page.tsx`, `components/dashboard/Sidebar.tsx`, `app/(app)/admin/receipts/page.tsx`, `components/dashboard/ExportBar.tsx`, `app/(app)/dashboard/onboarding/page.tsx`, `app/(app)/dashboard/layout.tsx`, `components/dashboard/BatchUploadDrawer.tsx`, `components/landing/AppSection.tsx`, `app/(app)/dashboard/export/page.tsx`. Do not touch `app/(app)/dashboard/settings/page.tsx` (Task C owns it) — it currently has no fetch calls. - `tests/e2e/csrf_tokens.test.ts` (new), `tests/integration/csrf_flow.test.ts` (new, DB-backed). **Task C (password change):** - `src/lib/auth/accounts.ts` (add `changePasswordForUser`) - `src/app/api/auth/change-password/route.ts` (new — MUST call `requireCsrf` from `@/lib/auth/csrf`; the module is being created in parallel by Task B, write the import against the contract above) - `src/app/(app)/dashboard/settings/page.tsx` (replace the static "Password Verification" row with a working form) - `tests/integration/password_change.test.ts` (new, DB-backed) **Task D (reset expiry):** - `tests/integration/reset_token_expiry.test.ts` (new, DB-backed) - Docs note in `SECURITY_HARDENING.md` § "Task D — done". **Integration (parent agent, after all four finish):** register all new test files in `tests/e2e/runner.ts` imports, run typecheck/build/tests, fix integration issues. --- ## Conventions - Path alias `@/` → `src/`. Imports like `@/lib/auth/config`, `@/lib/schema/db`, `@/lib/auth/session`. - Auth routes use `runtime = "nodejs"`, `dynamic = "force-dynamic"`, helpers from `@/lib/auth/http` (`authError`, `readJson`, `rateLimited`, `requireDatabase`) and `@/lib/auth/rateLimit` (`clientIp`, `rateLimit`). - Session helpers: `createSession`, `getCurrentUser`, `destroySession`, `destroyAllSessionsFor` in `@/lib/auth/session`. Password hashing: `hashPassword`, `verifyPassword` in `@/lib/auth/password`. - `isProduction` comes from `@/lib/auth/config`. - DB tables: `users`, `sessions`, `password_reset_tokens` etc. from `@/lib/schema/db`. - Tests use the custom runner (`describe/test/expect/runAllTests` from `tests/e2e/runner.ts`). Pure-logic tests go in `tests/e2e/*.test.ts`; DB-backed tests go in `tests/integration/*.test.ts`, importing `./loadEnv` FIRST, following the patterns in `tests/integration/auth_db.test.ts` (namespaced emails `authtest-*`, `cleanup()` deleting `like(users.emailKey, 'authtest-%')`). Integration tests must skip gracefully (exit 0) when the database is unavailable. - **Do NOT edit `tests/e2e/runner.ts`** — the parent agent registers new test files there during integration. ## Verification expectations (each agent) 1. Self-review your files for type errors (`npx tsc --noEmit` is allowed — ignore errors in files you do not own, other tasks are in flight). 2. Run your own tests where possible (`npx tsx tests/integration/.test.ts` for DB tests when a Postgres is reachable, otherwise rely on the graceful skip). 3. Report exactly: files created/modified, test results, anything you could not verify. --- ## Task A — done - **`next.config.ts`**: the catch-all `headers()` rule is split in two. All security headers except HSTS stay unconditional on `source: "/(.*)"`. `Strict-Transport-Security` now lives on its own rule with `has: [{ type: "header", key: "x-forwarded-proto", value: "https" }]`, so the HSTS promise is only emitted when the request actually arrived over HTTPS (an HTTP server can no longer poison HTTP clients with an upgrade it cannot deliver). Directive kept exactly: `max-age=63072000; includeSubDomains; preload`. `images.remotePatterns`, `serverExternalPackages`, `output: "standalone"` and the CSP value are untouched. - **`tests/e2e/security_headers.test.ts`** (new): pure-logic suite for the custom runner. Imports `next.config`, awaits `config.headers()`, and asserts the HSTS directive parts (`max-age=63072000`, `includeSubDomains`, `preload`), the `x-forwarded-proto: https` `has` condition, and the unconditional presence/values of X-Frame-Options (DENY), X-Content-Type-Options (nosniff), Referrer-Policy and CSP on the catch-all rule. - **`.env.example`**: `NEXT_PUBLIC_APP_URL` now documents that production must be an `https://` URL (HTTPS-only app); `http://localhost:3000` is noted as dev-only. - Verified: `npx tsx` run of the suite passes 4/4 (executed via a standalone tsx eval that registers the suite and calls `runAllTests()`); `npx tsc --noEmit` shows no errors in `next.config.ts` or `tests/e2e/security_headers.test.ts`. --- ## Task D — done **Reset-link expiry verified** (implementation untouched — `src/lib/auth/accounts.ts` remains owned by Task C). **Files:** `tests/integration/reset_token_expiry.test.ts` (new). Follows the `tests/integration/auth_db.test.ts` conventions (`./loadEnv` first, `authtest-*` namespacing, `cleanup()` on `like(users.emailKey, 'authtest-%')`, graceful exit-0 skip when no DB). **Tests (6, DB-backed):** 1. Fresh link reads `"valid"` and its `expiresAt` sits inside the TTL window (`0 < expiresAt − now ≤ PASSWORD_RESET_TTL_MS`, and ≈ full TTL since issued moments ago). 2. Consuming a valid token → `"success"`, new password verifies / old does not, `consumedAt` is set on the row. 3. Single-use: replaying the same token → `"invalid"`, `peek` → `"invalid"`, password unchanged. 4. Expiry: a token with `expiresAt` in the past → `peek` `"expired"`, reset `"expired"`, password unchanged. 5. Session invalidation: 2 seeded sessions for the user are gone after a successful reset. 6. Constants: `PASSWORD_RESET_TTL_MS` ∈ [15, 60] minutes and `< VERIFICATION_TTL_MS`. **Result:** `npx tsx tests/integration/reset_token_expiry.test.ts` ran against the live local Postgres (healthy, port 5436) — **6/6 passed**, exit 0. `npx tsc --noEmit` reports 0 errors in this file (the 2 project-wide errors are in Task A/B/C files, in flight). **Implementation verified as correct:** `issuePasswordResetLink` deletes prior unconsumed tokens and sets `expiresAt = now + PASSWORD_RESET_TTL_MS`; `peekPasswordResetToken` maps missing/consumed → `"invalid"` and elapsed → `"expired"` without spending; `resetPasswordWithToken` consumes the token, swaps the password hash, marks the address verified, and deletes all sessions for the user; only the SHA-256 digest of the token is stored. --- ## Task C — done (password change) Implemented `changePasswordForUser` and the full logged-in password-change flow with session rotation: - **`src/lib/auth/accounts.ts`**: added `ChangePasswordOutcome` and `changePasswordForUser(userId, currentPassword, newPassword)` — loads the user by id (`invalid` when absent), rejects Google-only accounts (`no_password`), re-verifies the CURRENT password (`wrong_password`), then stores the new scrypt digest and calls `destroyAllSessionsFor(userId)` so every existing token dies. The user row is never deleted. - **`src/app/api/auth/change-password/route.ts`** (new): `POST`, `nodejs` + `force-dynamic`. Calls `requireCsrf` FIRST, then `requireDatabase`, resolves the caller via `getCurrentUser` (`unauthorized` 401 when signed out), validates the body, re-validates strength server-side, rate-limits `change:ip:` at 10 / 15 min, maps outcomes (`no_password`→409 `use_google`, `wrong_password`→400, `invalid`→401) and on success re-issues a FRESH session for the current device (`remember: true`, UA + IP context) and returns `{ status: "password_changed" }`. Errors → 500 `server_error`. Note: `wrong_password` / `unauthorized` were added to the shared `AuthErrorCode` vocabulary in `src/lib/auth/errors.ts` (DE/EN copy) during integration, so the route emits them without a cast. - **`src/app/(app)/dashboard/settings/page.tsx`**: the static "Password Verification" row is now a working bilingual form (current / new / confirm), with client-side match + strength checks and a local CSRF helper that reads the `sr_csrf` cookie and sends `x-csrf-token` (plain fetch, decoupled from Task B's `apiFetch`). On success it states that all other sessions were terminated and this session was refreshed. - **`tests/integration/password_change.test.ts`** (new): DB-backed suite following the `auth_db.test.ts` pattern (loadEnv first, `authtest-` namespacing, graceful DB skip) covering wrong password, Google-only, unknown user, hash replacement, and — the core — that all 3 seeded session rows are deleted on success. - **Session semantics**: the user who just proved their password is NOT locked out — the route re-issues a brand-new token for the current device; every other device is signed out immediately. --- ## Task B — done (CSRF tokens) Double-submit-cookie CSRF protection implemented end-to-end: - **`src/lib/auth/csrf.ts`** (new): `CSRF_COOKIE = "sr_csrf"`, `CSRF_HEADER = "x-csrf-token"`, `issueCsrfToken()` (32-byte base64url via Web Crypto — Edge-runtime compatible so the middleware can import it), `csrfCookieOptions()` (non-HttpOnly, SameSite=Lax, `secure: isProduction`, 24h), `tokensMatch()` (constant-time XOR compare, undefined-safe), `isAllowedOrigin()` (absent Origin = non-browser client OK; else must equal `siteUrl`, plus localhost variants in dev), `validateCsrf()` (origin AND cookie==header), `requireCsrf(request)` → `null` or `403 { error: "csrf_failed" }`. - **`src/lib/csrf/client.ts`** (new): `apiFetch()` reads the `sr_csrf` cookie from `document.cookie` and echoes it as `x-csrf-token`, preserving method/body/FormData. - **`src/middleware.ts`**: issues the `sr_csrf` cookie on next/rewrite/redirect responses when the request had none (existing admin/locale/CORS/sensitive-path logic preserved). - **17 route handlers** guarded with `requireCsrf` as the first check: all six auth POSTs (login, signup, logout, forgot-password, reset-password, resend-verification), admin/waitlist DELETE, receipts POST+DELETE, scan, export csv/excel/pdf, onboarding, checkout, license/verify, waitlist. Exempt: `webhooks/stripe` (signature-verified), GET-only routes, change-password (Task C). - **19 client files** switched from `fetch(` to `apiFetch(` (plus the settings page uses its own local CSRF helper). - **Tests**: `tests/e2e/csrf_tokens.test.ts` (23 pure-logic) and `tests/integration/csrf_flow.test.ts` (5 DB-backed: no token → 403, matching cookie+header → success, mismatch → 403). - **Result**: tsc clean; 23/23 e2e + 5/5 integration passed against the live Postgres. --- ## Integration verification (parent) - `npx tsc --noEmit`: **0 errors** across the whole project. - `npm run build`: **success** (transient corrupt-`.next` cache error on first attempt; clean on retry). - Full e2e runner: **585/585 tests passed** (125 suites), including the new `security_headers.test.ts` + `csrf_tokens.test.ts` (registered in `tests/e2e/runner.ts`). - New integration suites against live Postgres: password_change 5/5, reset_token_expiry 6/6, csrf_flow 5/5, auth_db (pre-existing) partial — see note below. - **Environmental findings (pre-existing, not caused by these tasks):** the live Postgres was missing `launch_claims_position_seq` (repaired with `CREATE SEQUENCE IF NOT EXISTS`); pg `pool.end()` never resolves in this sandboxed environment (trivial clean probe reproduces it); the pre-existing `tests/integration/auth_db.test.ts` truncates mid-run at its 20-iteration unique-violation loop (reproduced in a standalone probe with zero task code involved — the failing-insert path stalls and the event loop drains). None of the four tasks touch the user-insert / launch-claims / pool code paths.