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

6.6 KiB

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/servernext/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 -Forcepublic/ contains only demo images, showcase images and icons; no .env, no .git, no markdown, no keys/certs/logs.