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:
221
docs/ADMIN_SECURITY_AUDIT.md
Normal file
221
docs/ADMIN_SECURITY_AUDIT.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# 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.
|
||||
104
docs/CORS_POLICY.md
Normal file
104
docs/CORS_POLICY.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# CORS Policy
|
||||
|
||||
This document describes how the receipt-scanner app decides which browser
|
||||
origins may call its API, and how to configure additional ones.
|
||||
|
||||
## Policy in one sentence
|
||||
|
||||
**Only origins on an explicit allowlist may call the API cross-origin — the
|
||||
app never responds with `Access-Control-Allow-Origin: *`.**
|
||||
|
||||
A wildcard is not an option here for two reasons:
|
||||
|
||||
1. The app authenticates with cookies (guest sessions, the `app.<domain>` /
|
||||
`admin.<domain>` subdomains). Browsers **refuse**
|
||||
`Access-Control-Allow-Origin: *` for
|
||||
credentialed requests, so a wildcard would simply break the feature it
|
||||
claims to enable.
|
||||
2. A wildcard would let *any* website drive a user's browser into the API with
|
||||
their cookies (classic CSRF/CORS-abuse surface). Locking the list down to
|
||||
known origins is the point of this change.
|
||||
|
||||
## How it works
|
||||
|
||||
All enforcement lives in the Edge middleware (`src/middleware.ts` →
|
||||
`src/lib/http/cors.ts`) so it runs before any route handler, on every request
|
||||
the matcher covers (including `/api/*`; static assets and `demo/` are excluded
|
||||
by the existing matcher). `next.config.ts` is unchanged.
|
||||
|
||||
| Request | Origin header | Result |
|
||||
| --- | --- | --- |
|
||||
| Preflight (`OPTIONS` + `Access-Control-Request-Method`) | allowed | `204` with `Access-Control-Allow-Origin` (exact echo), `Vary: Origin`, `Access-Control-Allow-Credentials: true`, `Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS`, bounded echo of `Access-Control-Allow-Headers`, `Access-Control-Max-Age: 600` |
|
||||
| Preflight | disallowed / absent | `403` JSON `{ "error": "cors_origin_not_allowed" }` |
|
||||
| Any request | disallowed | `403` JSON `{ "error": "cors_origin_not_allowed" }` |
|
||||
| Any request | none (same-origin fetch, curl, server-to-server) | allowed, request continues untouched |
|
||||
| Any request | allowed | allowed, request continues untouched |
|
||||
|
||||
### Matching rules
|
||||
|
||||
- The allowlist is `CORS_ORIGINS` (comma-separated) **plus**
|
||||
`NEXT_PUBLIC_APP_URL`, which is always allowed (it is the app's own origin).
|
||||
- Comparison is exact on **host + port**; trailing slashes are stripped,
|
||||
hostnames are lower-cased, and the `http://`/`https://` scheme prefix is
|
||||
ignored for the comparison (so `https://App.example.com/` and
|
||||
`https://app.example.com` are the same entry). The **full origin from the
|
||||
request** is echoed back in the header — never a wildcard, never a prefix,
|
||||
never a substring match.
|
||||
- The literal origin `"null"` (sandboxed iframes, `file://` pages) is always
|
||||
rejected.
|
||||
- No `Origin` header = not a browser cross-origin request = allowed.
|
||||
|
||||
### Credentials
|
||||
|
||||
Because the app uses cookies, every allowed CORS response advertises
|
||||
`Access-Control-Allow-Credentials: true`. This is safe precisely because the
|
||||
allow-origin value is always an exact echo of an allowlisted origin, never `*`.
|
||||
Browser CORS will only accept the response when both conditions hold.
|
||||
|
||||
### Non-preflight responses
|
||||
|
||||
The middleware enforces and answers preflights; actual (non-preflight)
|
||||
responses pass through untouched. Code that deliberately serves the API to
|
||||
cross-origin callers should attach CORS headers from
|
||||
`corsHeadersFor(request.headers.get("origin"))` (see `src/lib/http/cors.ts`).
|
||||
Same-origin callers (the default: pages and `/api/*` share one origin) never
|
||||
need any of this.
|
||||
|
||||
## Configuration
|
||||
|
||||
1. In `.env` (or the container environment), list every additional origin,
|
||||
comma-separated, full scheme://host[:port] form:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_APP_URL=https://app.example.com
|
||||
CORS_ORIGINS=https://admin.example.com,https://partner.example.com
|
||||
```
|
||||
|
||||
`NEXT_PUBLIC_APP_URL` must not be repeated in `CORS_ORIGINS`.
|
||||
2. `docker-compose.yml` already forwards `CORS_ORIGINS` into the app container
|
||||
(`- CORS_ORIGINS=${CORS_ORIGINS:-}`). If you deploy without Docker
|
||||
Compose, pass it as a runtime env var to the standalone server the same way
|
||||
you pass `ADMIN_EMAILS`.
|
||||
3. `app.<domain>` / `admin.<domain>` requests are routed back into this app by
|
||||
the middleware, so their `/api/*` calls are **same-origin** and need no
|
||||
`CORS_ORIGINS` entry. Only add an origin when a page on one host genuinely
|
||||
calls the API of another host (e.g. `https://admin.example.com`).
|
||||
4. Leave `CORS_ORIGINS` empty for **same-origin-only** access — the secure
|
||||
default: any cross-origin browser request is refused.
|
||||
|
||||
> Production note: `NEXT_PUBLIC_APP_URL` must be an `https://` URL (the app
|
||||
> sends HSTS), so `CORS_ORIGINS` entries should be `https://` too.
|
||||
|
||||
## Verification
|
||||
|
||||
`scripts/verify_cors.ts` (run with `npx tsx scripts/verify_cors.ts`) exercises
|
||||
the pure helpers against simulated requests: preflight 204/403, disallowed GET
|
||||
403, no-origin pass-through, allowlist normalization, and asserts that no
|
||||
header value ever contains `*`.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/lib/http/cors.ts` — the policy (Edge-runtime compatible, pure + testable)
|
||||
- `src/middleware.ts` — enforcement hook
|
||||
- `.env.example` — documented `CORS_ORIGINS` variable
|
||||
- `scripts/verify_cors.ts` — test harness
|
||||
118
docs/SECURITY_DEPLOYMENT.md
Normal file
118
docs/SECURITY_DEPLOYMENT.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Security & Deployment — Directory Listing & Sensitive Paths
|
||||
|
||||
This document explains why the app can never serve directory listings, which
|
||||
paths are blocked where, and how to deploy the standalone build behind nginx.
|
||||
It is the production hardening companion to `nginx.conf.example` at the repo
|
||||
root and to the in-app middleware policy in `src/lib/http/sensitivePaths.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why the app never lists directories (by design)
|
||||
|
||||
The production image runs the **Next.js standalone server** (`node server.js`,
|
||||
see `Dockerfile`, stage "Production Runner"). The standalone server:
|
||||
|
||||
- serves **only** the files that were copied into the image: `public/` and the
|
||||
compiled `.next/` build output — nothing else from the repository (no
|
||||
`src/`, no root config files, no `.env`);
|
||||
- **never generates directory listings**: a request to a directory path (e.g.
|
||||
`/demo/` or `/showcase/`) is answered with 404/403 by Next's file router —
|
||||
there is no directory-index mechanism at all, unlike Apache (`Options
|
||||
Indexes`) or nginx (`autoindex on`);
|
||||
- only responds with content when the exact URL maps to an existing file under
|
||||
`public/` or a compiled route.
|
||||
|
||||
So "disable directory listing" is already the default behaviour of the stack —
|
||||
there is no `autoindex`/`Options Indexes` directive anywhere in this
|
||||
repository, and `nginx.conf.example` additionally sets `autoindex off;` for the
|
||||
reverse proxy (defense in depth, see §3).
|
||||
|
||||
## 2. What the app-layer middleware blocks
|
||||
|
||||
`src/middleware.ts` runs the Edge-runtime hook `blockSensitivePath()` (defined
|
||||
in `src/lib/http/sensitivePaths.ts`) on **every request before any other
|
||||
processing**, including before the CORS hook and before static-file serving.
|
||||
Blocked paths get a plain **404** (`{"error":"Not found"}`) — deliberately not
|
||||
a redirect, so an attacker cannot distinguish "blocked" from "does not exist".
|
||||
|
||||
The blocking policy (all case-insensitive):
|
||||
|
||||
| Rule | Example paths blocked |
|
||||
|---|---|
|
||||
| Any path segment starting with a dot (dotfiles / dot-directories) | `/.env`, `/.env.local`, `/.git/config`, `/.dockerignore`, `/.npmrc`, `/.next/…`, `/.next-corrupt-20260815-2345/…`, `/api/.env` |
|
||||
| Path-traversal segments `.` / `..` | `/%2e%2e/…` (normalized), `/foo/../bar` |
|
||||
| Non-web-facing directories | `/node_modules/…`, `/drizzle/…`, `/scripts/…` |
|
||||
| Project / build / config filenames | `/docker-compose.yml`, `/Dockerfile`, `/build_err.txt`, `/package.json`, `/package-lock.json`, `/tsconfig.json`, `/next.config.ts`, `/drizzle.config.ts`, `…` at any depth |
|
||||
| Sensitive file extensions | `*.md`, `*.pem`, `*.key`, `*.crt`, `*.log` at any depth |
|
||||
| Percent-encoded traversal artefacts | `/%2eenv`, `/%252eenv`, `/%5c…` (encoded dot/backslash/double-encoding) |
|
||||
|
||||
The middleware additionally blocks nothing legitimate: `public/` assets
|
||||
(`/showcase/*.png|jpg`, `/app-icon.jpg`, icons, favicon) and the `demo/` image
|
||||
folder match none of the patterns. The middleware `matcher` in
|
||||
`src/middleware.ts` excludes `_next/static`, `_next/image`, the favicon/icon
|
||||
files and `demo/` entirely, so those are served untouched.
|
||||
|
||||
> Note: `/.env*` is covered by the dotfile rule, and the `.env` extension rule
|
||||
> (`env.*`) in the nginx config catches non-dot files like `foo.env`.
|
||||
|
||||
## 3. Deploying behind nginx (production)
|
||||
|
||||
The standalone server listens on `127.0.0.1:3000` inside the container (the
|
||||
Dockerfile sets `PORT=3000` / `HOSTNAME="0.0.0.0"`). Put nginx in front of it:
|
||||
|
||||
1. Copy `nginx.conf.example` to `/etc/nginx/conf.d/receipt-scanner.conf` and
|
||||
replace the placeholders (`example.com`, certificate paths).
|
||||
2. Obtain TLS certificates (e.g. Let's Encrypt) — the config enforces HTTPS
|
||||
with HSTS and refuses to serve anything over plain HTTP.
|
||||
3. `nginx -t && systemctl reload nginx` (or `docker exec nginx nginx -s reload`).
|
||||
4. Point the app's `NEXT_PUBLIC_APP_URL` at the public `https://` URL.
|
||||
|
||||
What the proxy enforces **before any request reaches the app**:
|
||||
|
||||
- `autoindex off;` — directory listing is explicitly disabled;
|
||||
- `location ~ /\. { deny all; }` — any URI containing a `/` + `.` segment
|
||||
(dotfiles, dot-directories) is rejected with 403 at the proxy; the single
|
||||
carve-out is `location ^~ /.well-known/acme-challenge/` so Let's Encrypt
|
||||
HTTP-01 challenges keep working (`^~` beats the regex location);
|
||||
- `location ~* \.(md|pem|key|crt|log|env.*)$ { deny all; }` — sensitive file
|
||||
extensions rejected with 403, at any depth;
|
||||
- explicit `deny all` locations for the known sensitive root files
|
||||
(`docker-compose.yml`, `Dockerfile`, `tsconfig.json`, `next.config.ts`, …);
|
||||
- security headers: HSTS, `X-Frame-Options: DENY`,
|
||||
`X-Content-Type-Options: nosniff`, `Referrer-Policy`;
|
||||
- gzip for text assets;
|
||||
- everything else is proxied to `http://127.0.0.1:3000` with the original
|
||||
`Host`, client IP and `X-Forwarded-Proto` headers so the app's own HSTS and
|
||||
CORS logic sees the true scheme.
|
||||
|
||||
## 4. Keeping secrets out of `public/` (and out of the image)
|
||||
|
||||
`public/` is the **only** directory the web server ever serves directly. Rules:
|
||||
|
||||
- Never put `.env`, `.env.*`, keys, certificates, logs, or documentation into
|
||||
`public/` — files there are downloadable by URL by design.
|
||||
- `.env*`, `node_modules`, `.next`, `build_err.txt`, `tests` and `drizzle` are
|
||||
already excluded from the Docker image via `.dockerignore`; secrets are
|
||||
injected at runtime through the container's environment, not baked in.
|
||||
- Treat any file you add to the repo root as potentially web-reachable by URL
|
||||
(`.env`, `.git`, `*.md`, configs, ...) — the middleware and nginx config
|
||||
above block those paths, but the first line of defense is not having them in
|
||||
`public/` and not shipping them in the image at all.
|
||||
|
||||
## 5. Verification
|
||||
|
||||
- `scripts/verify_sensitive_paths.mjs` — run
|
||||
`node --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs`
|
||||
(plain Node, uses native TypeScript type-stripping; the small resolve hook
|
||||
maps `next/server` → `next/server.js`, which only plain Node needs) or
|
||||
`npx tsx scripts/verify_sensitive_paths.mjs`. It asserts the pure policy and
|
||||
the middleware wrapper against the blocked / allowed path battery in the
|
||||
file. The output ends with `N checks, 0 failure(s)` and exit code 0.
|
||||
- `npx tsc --noEmit` — type-checks the new module and its wiring (clean).
|
||||
- Repo scan for `autoindex` / `Options Indexes` (excluding `node_modules`,
|
||||
`.next*`, `.git`, `.agents`): the only hits are `nginx.conf.example`
|
||||
(`autoindex off;`) and this document — no web-server config in the repo
|
||||
enables directory listing.
|
||||
- `Get-ChildItem public -Recurse -Force` — `public/` contains only demo
|
||||
images, showcase images and icons; no `.env`, no `.git`, no markdown, no
|
||||
keys/certs/logs.
|
||||
Reference in New Issue
Block a user