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:
Timo
2026-08-19 20:59:04 +02:00
parent 650a74da97
commit 84b9987c49
415 changed files with 96619 additions and 0 deletions

View 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 2627) |
| `src/app/api/admin/stats/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 1415) |
| `src/app/api/admin/system/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 1112) |
| `src/app/api/admin/users/route.ts` | `GET` | `getAdminUser()` → 403 | `{error:'Forbidden'}` | ✅ (line 1112) |
| `src/app/api/admin/waitlist/route.ts` | `GET` + `DELETE` | `getAdminUser()` → 403 (both) | `{error:'Forbidden'}` | ✅ (lines 1112, 3233) |
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 1013) |
| `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 7275). | ✅ 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
View 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
View 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.