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>
17 KiB
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:
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):
/** 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/EXCEPTsrc/app/api/webhooks/stripe/route.ts, EXCEPTsrc/app/api/auth/change-password/route.ts(Task C creates it) — addrequireCsrfto: 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 forexport async function (POST|PUT|PATCH|DELETE)insrc/app/api.) - Every client file that calls
fetch(undersrc/→ switch toapiFetch. 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 touchapp/(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(addchangePasswordForUser)src/app/api/auth/change-password/route.ts(new — MUST callrequireCsrffrom@/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,destroyAllSessionsForin@/lib/auth/session. Password hashing:hashPassword,verifyPasswordin@/lib/auth/password. isProductioncomes from@/lib/auth/config.- DB tables:
users,sessions,password_reset_tokensetc. from@/lib/schema/db. - Tests use the custom runner (
describe/test/expect/runAllTestsfromtests/e2e/runner.ts). Pure-logic tests go intests/e2e/*.test.ts; DB-backed tests go intests/integration/*.test.ts, importing./loadEnvFIRST, following the patterns intests/integration/auth_db.test.ts(namespaced emailsauthtest-*,cleanup()deletinglike(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)
- Self-review your files for type errors (
npx tsc --noEmitis allowed — ignore errors in files you do not own, other tasks are in flight). - Run your own tests where possible (
npx tsx tests/integration/<your_file>.test.tsfor DB tests when a Postgres is reachable, otherwise rely on the graceful skip). - Report exactly: files created/modified, test results, anything you could not verify.
Task A — done
next.config.ts: the catch-allheaders()rule is split in two. All security headers except HSTS stay unconditional onsource: "/(.*)".Strict-Transport-Securitynow lives on its own rule withhas: [{ 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. Importsnext.config, awaitsconfig.headers(), and asserts the HSTS directive parts (max-age=63072000,includeSubDomains,preload), thex-forwarded-proto: httpshascondition, 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_URLnow documents that production must be anhttps://URL (HTTPS-only app);http://localhost:3000is noted as dev-only.- Verified:
npx tsxrun of the suite passes 4/4 (executed via a standalone tsx eval that registers the suite and callsrunAllTests());npx tsc --noEmitshows no errors innext.config.tsortests/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):
- Fresh link reads
"valid"and itsexpiresAtsits inside the TTL window (0 < expiresAt − now ≤ PASSWORD_RESET_TTL_MS, and ≈ full TTL since issued moments ago). - Consuming a valid token →
"success", new password verifies / old does not,consumedAtis set on the row. - Single-use: replaying the same token →
"invalid",peek→"invalid", password unchanged. - Expiry: a token with
expiresAtin the past →peek"expired", reset"expired", password unchanged. - Session invalidation: 2 seeded sessions for the user are gone after a successful reset.
- 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: addedChangePasswordOutcomeandchangePasswordForUser(userId, currentPassword, newPassword)— loads the user by id (invalidwhen absent), rejects Google-only accounts (no_password), re-verifies the CURRENT password (wrong_password), then stores the new scrypt digest and callsdestroyAllSessionsFor(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. CallsrequireCsrfFIRST, thenrequireDatabase, resolves the caller viagetCurrentUser(unauthorized401 when signed out), validates the body, re-validates strength server-side, rate-limitschange:ip:<ip>at 10 / 15 min, maps outcomes (no_password→409use_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 → 500server_error. Note:wrong_password/unauthorizedwere added to the sharedAuthErrorCodevocabulary insrc/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 thesr_csrfcookie and sendsx-csrf-token(plain fetch, decoupled from Task B'sapiFetch). 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 theauth_db.test.tspattern (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 equalsiteUrl, plus localhost variants in dev),validateCsrf()(origin AND cookie==header),requireCsrf(request)→nullor403 { error: "csrf_failed" }.src/lib/csrf/client.ts(new):apiFetch()reads thesr_csrfcookie fromdocument.cookieand echoes it asx-csrf-token, preserving method/body/FormData.src/middleware.ts: issues thesr_csrfcookie on next/rewrite/redirect responses when the request had none (existing admin/locale/CORS/sensitive-path logic preserved).- 17 route handlers guarded with
requireCsrfas 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(toapiFetch((plus the settings page uses its own local CSRF helper). - Tests:
tests/e2e/csrf_tokens.test.ts(23 pure-logic) andtests/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-.nextcache 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 intests/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 withCREATE SEQUENCE IF NOT EXISTS); pgpool.end()never resolves in this sandboxed environment (trivial clean probe reproduces it); the pre-existingtests/integration/auth_db.test.tstruncates 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.