Files
scan-receipts/SECURITY_HARDENING.md
Timo 84b9987c49 Add full application: receipt scanning, auth, billing, and account deletion
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>
2026-08-19 20:59:04 +02:00

17 KiB
Raw Permalink Blame History

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/ 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.