Files
scan-receipts/docs/SECURITY_DEPLOYMENT.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

119 lines
6.6 KiB
Markdown

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