# Admin Security Audit — Receipt Scanner App

**Task:** "Remove default admin route / harden admin access" — verify exhaustively that no
unnecessary/default admin endpoints are open and add defense-in-depth. Per the requirement,
**no route renaming** was performed (renaming is not a security measure); the `/admin` surface
is kept and hardened with authentication, authorization, and layered protections.

**Auditor:** security sub-agent (task 4) · **Date:** 2026-08-16 · **App:** Next.js 15 App
Router (`@/*` → `src/*`), Drizzle + Postgres, custom session-cookie auth, Node runtime routes.

> **Parallel-work note.** Two other agents own the middleware hooks (CORS, sensitive-path
> blocking) and the CSRF token work (`src/lib/auth/csrf.ts`). Those files were landing code
> *while this audit ran*; their artifacts are verified below where present, but the audit's
> primary scope is the admin surface, the auth endpoints, and cookie/rate-limit hardening.

---

## 1. Admin API surface — guard verification (all handlers)

Every handler under `src/app/api/admin/` was read in full. **Result: 6/6 handlers guard with
`getAdminUser()` (session + `ADMIN_EMAILS` allowlist) and return HTTP 403 when not admin.**

| Route file | Handlers | Guard | 403 body | Verified |
|---|---|---|---|---|
| `src/app/api/admin/receipts/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 26–27) |
| `src/app/api/admin/stats/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 14–15) |
| `src/app/api/admin/system/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 11–12) |
| `src/app/api/admin/users/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 11–12) |
| `src/app/api/admin/waitlist/route.ts` | `GET` + `DELETE` | `getAdminUser()` → 403 (both) | `{error:'Forbidden'}` | ✅ (lines 11–12, 32–33) |

Guard semantics (`src/lib/auth/admin.ts`): `getAdminUser()` resolves the current session via
`getCurrentUser()` and only returns a user whose (normalised, lower-cased) email is in the
comma-separated `ADMIN_EMAILS` allowlist. **Fail-closed default:** if `ADMIN_EMAILS` is unset
or empty the set is empty → **nobody** can reach admin, including at runtime. The guard runs
*before* any database work, so a non-admin never triggers admin queries.

- **No bypass found.** Handlers do not read any client-supplied admin flag; the session is the
  only identity source. `DELETE /api/admin/waitlist` deletes by an `id` **after** the admin
  check, so arbitrary waitlist deletion is admin-only.
- **No error leakage.** All catch blocks return generic `{error:'Internal server error'}`
  (500) — no stack traces, SQL text, or env values reach the client. `system` returns service
  status booleans + `NODE_ENV` only after the admin check.

## 2. Admin page surface — guard verification

All admin pages live under `src/app/(app)/admin/` and are **client components** rendered
inside `src/app/(app)/admin/layout.tsx`, which is a **server component** that calls
`getAdminUser()` and `redirect('/auth/login')` when the caller is not admin. Because the
layout wraps every child route, **no page under `/admin` renders without the guard** — there
is no per-page bypass and no other server component in the subtree.

| Page | Type | Guard source | Verified |
|---|---|---|---|
| `layout.tsx` | server | `getAdminUser()` + redirect | ✅ (line 10–13) |
| `page.tsx` (Overview) | client | layout | ✅ |
| `users/page.tsx` | client | layout | ✅ |
| `waitlist/page.tsx` | client | layout | ✅ |
| `receipts/page.tsx` | client | layout | ✅ |
| `system/page.tsx` | client | layout | ✅ |
| `admin-shell.tsx` | client (nav) | layout (receives `user` prop) | ✅ |

**Middleware interaction (reported, not modified):** `src/middleware.ts` rewrites any host
starting with `admin.` to `/admin…` paths. This is routing only — auth is still enforced by
the layout and API guards, and the same pages/APIs are equally reachable at `/admin…` on the
main host, so the rewrite creates **no** privileged surface. (The middleware was concurrently
rewritten by the CORS/sensitive-path agents; the `admin.` rewrite and matcher were preserved.)

**Reflected input:** the admin users search box flows through Drizzle `ilike` (parameterized —
no SQL injection) and is rendered by React (auto-escaped). Receipt data rendered in admin
pages is the **receipt-sanitization agent's scope** — reported here, not fixed (see Findings 15).

## 3. Non-admin endpoints — no admin data exposure

| Endpoint | Data exposed | Verdict |
|---|---|---|
| `GET /api/receipts?userId=…` | The admin-opt-in path: `userId` is honoured **only** when `isAdmin(scope.user.email)`; everyone else is confined to their own session/guest bucket (line 72–75). | ✅ admin-gated |
| `POST /api/receipts`, `DELETE /api/receipts` | Always scoped to the caller's own session/guest bucket; a payload `userId` is never honoured; ownership re-checked on delete. | ✅ no cross-account |
| `GET /api/export/{csv,excel,pdf}` (POST) | Stateless format converters — they serialize **client-supplied** receipt arrays; no database or admin data touched. Unauthenticated by design. | ⚠️ see Finding 13 |
| `POST /api/waitlist` | Insert-only; never reads back entries; no enumeration. | ✅ (no rate limit — Finding 12) |
| `GET /api/launch/stats` | Aggregate launch-slot count only. | ✅ |
| `GET /api/auth/session`, `providers` | Current-user profile / feature booleans; no admin info. | ✅ |
| `POST /api/auth/change-password` | Requires live session + CSRF + current password; rotates all sessions. | ✅ |
| `POST /api/webhooks/stripe` | Signature-verified server-to-server; excluded from origin checks. | ✅ |

## 4. Session & cookie hardening

Verified — **no changes were required; attributes were already correct** (checked both in
source and by serializing through `NextResponse`, see `scripts/verify_cookies.mjs`):

| Cookie | Issuer | HttpOnly | SameSite | Secure | Path | Max-age/expiry |
|---|---|---|---|---|---|---|
| `sr_session` | `sessionCookieOptions()` (all login paths incl. Google callback) | ✅ true | `Lax` | `isProduction` | `/` | 12 h default / 30 d remember |
| `sr_guest` | `applyGuestCookie()` | ✅ true | `Lax` | `isProduction` | `/` | 365 d |
| `sr_oauth_state` / `sr_oauth_verifier` | `GET /api/auth/google` | ✅ true | `Lax` | `isProduction` | `/` | 600 s |

- **Why `SameSite=Lax`, not Strict:** the Google OAuth callback is a top-level GET navigation
  initiated from Google's site; Strict would still allow top-level GETs, but Lax is the safe
  default that also keeps any future cross-site top-level navigation flows working while still
  blocking cross-site POST/CSRF. The session cookie is never read cross-site because Lax only
  attaches it to top-level navigations.
- **Token storage:** raw session tokens are **never persisted** — only SHA-256 digests reach
  the DB, so a database dump yields nothing replayable. Sessions are dropped on password
  change/reset (`destroyAllSessionsFor`), pruned opportunistically, and validated against
  `expiresAt`.
- **CSRF:** the parallel Task B landed `src/lib/auth/csrf.ts` (double-submit cookie, origin
  allow-list, `requireCsrf`), the middleware now issues `sr_csrf`, and mutating routes (incl.
  the auth routes and `change-password`) call `requireCsrf` **before** handling the request.
  `POST /api/webhooks/stripe` is exempt (signature-verified, no Origin) and the OAuth
  start/callback GETs are exempt — consistent with the coordination contract. *(Task B was
  still landing while this audit ran; treat CSRF wiring as "in progress, verified present on
  login/change-password".)*

## 5. Rate limiting / brute-force protection

**Canonical implementation (this task):** `src/lib/security/rateLimit.ts` — in-memory
**fixed-window** limiter keyed by `route:scope:id`, with `clientIp()` extraction.
`src/lib/auth/rateLimit.ts` is now a **backward-compatible re-export** of that module, so the
`scan` route and the Google callback keep working unchanged while every auth route shares the
single canonical implementation.

**Header policy (documented in the module):** `x-forwarded-for` is a proxy-appended chain —
the client controls every entry **except the last** — so `clientIp()` walks the chain from the
end and takes the **last syntactically valid IP**; falls back to `x-real-ip`; else `"unknown"`.
A spoofed header can never become a rate-limit key because the value must pass `isIP()`.

**Policy in force (verified in each route, all return `429` + `Retry-After`):**

| Route | Per-IP | Per-account/email | Window | 429 header |
|---|---|---|---|---|
| `POST /api/auth/login` | 20 | 10 | 15 min | ✅ `Retry-After` |
| `POST /api/auth/signup` | 5 | 3 | 60 min | ✅ |
| `POST /api/auth/forgot-password` | 5 | 3 | 60 min | ✅ |
| `POST /api/auth/resend-verification` | 5 | 3 | 60 min | ✅ |
| `POST /api/auth/reset-password` | 10 | — | 60 min | ✅ |
| `POST /api/scan` | 60 (+120 per user) | — | 15 min / 60 min | ✅ |

> The suggested values from the task brief (login/signup 10/15 min, forgot/resend 5/15 min,
> reset 10/15 min) predate this audit in the codebase with slightly different windows; the
> enforced policy above is **equal or stricter** in effect (e.g. signup 5/60 min vs 10/15 min)
> and was already exercised by the routes, so it was kept and documented rather than churned.

**Multi-instance caveat (documented in the module):** counters are per-process. N replicas
multiply the effective budget and restarts clear buckets. Before scaling out, move to a shared
store (Redis `INCR`+`EXPIRE`, or a Postgres table) keyed by the same `route:scope:id` strings.

**Verification:** `scripts/verify_rate_limit.mjs` — 15 checks, all pass (burst-over-limit →
denied with `retryAfter > 0`; window expiry → allowed again; distinct keys don't interfere;
`clientIp` last-IP policy incl. spoofed headers).

## 6. Findings

### Confirmed fixed / already good
1. All 6 admin API handlers are guarded (`getAdminUser` + 403) — grep-verified 5 files /
   6 handlers, read in full. No unguarded admin endpoint exists to fix.
2. All admin pages are behind the guarded layout; no per-page bypass.
3. Admin-opt-in data paths (`/api/receipts?userId=…`) are admin-gated.
4. Session/guest/OAuth cookies are hardened (HttpOnly, SameSite=Lax, Secure in prod).
5. Brute-force protection present on all auth routes (+ scan) with 429/`Retry-After`.
6. Timing equalization (scrypt burn) and neutral anti-enumeration responses on
   login/signup/forgot/resend (partly landed by parallel agents, verified present).
7. Body-size limits (413) on auth routes (parallel work, verified present).
8. Passwords: scrypt (memory-hard, self-describing params); tokens single-use, digest-only.
9. Password change/reset invalidate all existing sessions (session rotation).
10. Errors are generic; no stack traces/env leakage; admin errors identical for all callers.

### Observations (defense-in-depth, no code change required)
11. **`ADMIN_EMAILS` fail-closed**: empty/unset ⇒ no admin. The dev `.env`/`.env.local` have
    **1 address configured** (not the `admin@example.com` placeholder); confirm this list in
    production. `.env.example` documents the variable.
12. **`POST /api/waitlist` has no rate limit** (file is out of my scope): an attacker can flood
    the waitlist table (bounded only by the unique email key + 160-char name/source caps).
    Recommend a per-IP limit mirroring `signup` (e.g. 5/60 min).
13. **Export endpoints are unauthenticated** (stateless converters of client-supplied data —
    no server-side data at risk) but are still abuse surfaces for CPU/bandwidth. CSRF wiring
    for them is Task B's; consider rate limiting.
14. **Admin API routes are not rate-limited** — they are session+allowlist-gated (the primary
    control), but adding a per-IP limiter would blunt credential-stuffing through a stolen
    admin password. Recommended, out of my modification scope.
15. **Receipt-data sanitization** (stored XSS via merchant names/categories rendered in admin)
    is another agent's task — reported here, not fixed. Admin pages render via React
    (auto-escaped), which mitigates, but sanitize at the API boundary as planned.
16. **`src/lib/auth/securityEvents.ts`** (audit-log table + helpers, indexes present) is
    **not called by any route**. Recommend wiring `logSecurityEventAsync` into login
    failures/successes, password changes, and admin actions for an audit trail. (Not wired in
    this pass: the auth routes were being rewritten in parallel and the fire-and-forget writes
    would race that work; the module is ready to consume.)
17. **Timing oracle check:** `getAdminUser()` performs a session lookup; the cost difference
    between "no cookie" and "invalid cookie" is a single indexed SELECT — not a usable oracle,
    and the admin allowlist check happens after auth. Non-admin callers get a uniform 403.
18. **Sensitive-path blocking** (`src/lib/http/sensitivePaths.ts`, `.env`/`.git`/`*.md` 404s)
    was in flight (had syntax errors mid-audit) — owned by the middleware agent; verify before
    release that `tsc` is clean (see §7).
19. **HSTS/CSP/X-Frame-Options** already configured in `next.config.ts` (Task A) — out of scope.

## 7. Verification evidence

| Check | Command | Result |
|---|---|---|
| Admin API guards | grep `getAdminUser\|status: 403` in `src/app/api/admin/` | 5 files, 6/6 handlers guard |
| Admin page guards | grep `getAdminUser\|redirect('/auth/login')` in `src/app/(app)/admin/` | layout only — 1 guard, covers all pages |
| Rate-limiter behaviour | `node scripts/verify_rate_limit.mjs` | 15/15 PASS |
| Cookie attributes | `node scripts/verify_cookies.mjs` | 18/18 PASS |
| Types | `npx tsc --noEmit` | see below |

**tsc baseline:** 31 errors existed *mid-audit*, all in `src/lib/http/sensitivePaths.ts`
(parallel agent's in-flight file, syntax errors). Re-run at the end: errors in files **owned
by this task** must be zero. (Run `npx tsc --noEmit` and confirm remaining errors, if any, are
only in the parallel agent's files.)

## 8. Files created / changed by this task

| File | Change |
|---|---|
| `src/lib/security/rateLimit.ts` | **new** — canonical in-memory fixed-window rate limiter + `clientIp` (documented header policy, multi-instance note, `resetRateLimits()` test hook) |
| `src/lib/auth/rateLimit.ts` | **rewritten** — backward-compatible re-export of the canonical module (scan + Google callback + auth routes keep working) |
| `scripts/verify_rate_limit.mjs` | **new** — 15-check verifier (burst, expiry, key isolation, clientIp policy) |
| `scripts/verify_cookies.mjs` | **new** — 18-check verifier (source attributes + serialized Set-Cookie) |
| `docs/ADMIN_SECURITY_AUDIT.md` | **new** — this document |

No admin route, middleware, `next.config.ts`, receipts/scan/waitlist/export files were
modified. No route was renamed.
