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>
This commit is contained in:
249
SECURITY_HARDENING.md
Normal file
249
SECURITY_HARDENING.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user