Brings the working codebase (Next.js app, auth system, Stripe billing, Docker/deploy config, tests, docs) into version control on top of the placeholder initial commit, and adds account self-deletion (Danger Zone in Settings, password + typed-email confirmation, cascading DB cleanup, Stripe cancellation) per GDPR right-to-erasure. Excludes local build caches, node_modules, and internal agent scratch files; .gitignore hardened to keep those out going forward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
250 lines
17 KiB
Markdown
250 lines
17 KiB
Markdown
# 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<Response>;
|
||
```
|
||
|
||
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/<your_file>.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:<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.
|