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