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