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>
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 (nosrc/, 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.envextension rule (env.*) in the nginx config catches non-dot files likefoo.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:
- Copy
nginx.conf.exampleto/etc/nginx/conf.d/receipt-scanner.confand replace the placeholders (example.com, certificate paths). - Obtain TLS certificates (e.g. Let's Encrypt) — the config enforces HTTPS with HSTS and refuses to serve anything over plain HTTP.
nginx -t && systemctl reload nginx(ordocker exec nginx nginx -s reload).- Point the app's
NEXT_PUBLIC_APP_URLat the publichttps://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 islocation ^~ /.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 alllocations 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:3000with the originalHost, client IP andX-Forwarded-Protoheaders 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 intopublic/— files there are downloadable by URL by design. .env*,node_modules,.next,build_err.txt,testsanddrizzleare 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 inpublic/and not shipping them in the image at all.
5. Verification
scripts/verify_sensitive_paths.mjs— runnode --import ./scripts/register-next-server-resolve.mjs scripts/verify_sensitive_paths.mjs(plain Node, uses native TypeScript type-stripping; the small resolve hook mapsnext/server→next/server.js, which only plain Node needs) ornpx 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 withN 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(excludingnode_modules,.next*,.git,.agents): the only hits arenginx.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.