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>
119 lines
6.6 KiB
Markdown
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.
|