Files
Florian Wilde 75e99f75c6 feat: make "only the app publishes a port" a binding rule
A published database port puts the data on the public internet, and Docker's
own iptables rules bypass ufw — the server looks firewalled while the port is
open. Previously this was only a comment in compose.yaml, which an agent adding
a db service later is free to ignore.

Anchored at every point where a service could be added or shipped:
- rules/security.md: the rule, the reasoning, and the audit commands
- rules/backend.md + architecture SKILL: constraint when picking the data layer
- agents/backend-dev.md: non-negotiable for any service it adds
- agents/qa-engineer.md: a published port is a Critical finding
- skills/deploy: blocking pre-flight audit
- compose.yaml: db example now uses expose:, with depends_on healthcheck
- docs/DEPLOYMENT.md + README: SSH tunnel instead of publishing for debugging
2026-08-09 01:12:06 +02:00

122 lines
4.6 KiB
Markdown

---
paths:
- "src/app/api/**"
- "src/lib/auth*"
- "src/server/**"
- ".env*"
- "next.config.*"
- "Dockerfile"
- "compose.yaml"
---
# Security Rules
## Authentication: Better Auth with passkeys (mandatory)
Whenever a project needs authentication, it uses **Better Auth** with the
**passkey plugin**. Do not substitute another auth library, and do not build a
custom session/credential layer.
```bash
npm install better-auth
# passkey support ships as a Better Auth plugin — enable it in the auth config
```
**Passkeys are the primary method.** The sign-in screen offers passkey first and
presents it as the default path. A password flow is permitted only as a fallback
for users whose device cannot do WebAuthn.
Requirements whenever auth is present:
- Passkey registration is offered during sign-up, not hidden in settings
- Existing password users are prompted to add a passkey after signing in
- The WebAuthn relying-party ID must match the deployment hostname. It is derived
from `NEXT_PUBLIC_APP_URL` / `BETTER_AUTH_URL` — a mismatch makes every passkey
fail verification, and it fails silently.
- Passkeys require HTTPS in production (localhost is exempt). The reverse proxy
terminates TLS — see `docs/DEPLOYMENT.md`.
- If the password fallback exists: rate-limit it, never reveal whether an account
exists, and store hashes only through Better Auth's own primitives
## Authorization
- Verify the session in every route handler before any work
- Authorization is per resource, not per route: having a session does not mean
owning the record. Scope the query by the session user.
- Never trust an ID coming from the client as proof of ownership
## Secrets
- Never commit secrets. `.env` is gitignored; only `.env.example` is committed.
- Every new variable is documented in `.env.example` with a dummy value
- `NEXT_PUBLIC_` only for values safe to expose in the browser bundle
- `BETTER_AUTH_SECRET` is generated per environment (`openssl rand -base64 32`)
and never shared between staging and production
## Input Handling
- Validate all input server-side with Zod, even when the client already did
- Never interpolate user input into SQL, shell commands or file paths
- Escape or avoid `dangerouslySetInnerHTML`
## Port exposure (mandatory)
**Only the app service publishes a port. Nothing else. Ever.**
Databases, caches, queues, search indexes, admin UIs and migration containers get
**no `ports:` entry at all**. Services reach each other by service name over the
Compose network — `postgres://user:pass@db:5432/app` works without publishing
anything. Use `expose:` if you want the port documented; it changes nothing about
reachability from outside.
```yaml
db:
image: postgres:17-alpine
expose:
- "5432" # documentation only — not reachable from the host
# ports: # NEVER. This would put the database on the public internet.
```
Why this is not a style preference: `ports: "5432:5432"` on a public server means
the only thing between an attacker and the whole dataset is the database password.
Worse, Docker writes its own nat/DOCKER-USER iptables rules that **bypass ufw** on a
typical setup — the server looks firewalled while the port is wide open. This is one
of the most common ways self-hosted stacks get breached.
The app service itself binds to loopback (`${APP_BIND:-127.0.0.1}`) because a reverse
proxy on the host terminates TLS and forwards to it. Binding `0.0.0.0` is only
correct when there is no proxy, and it means serving plain HTTP — which also breaks
passkeys.
If a port genuinely must be reachable for local debugging, bind it explicitly to
loopback and never bare:
```yaml
ports:
- "127.0.0.1:5432:5432" # ok for an SSH-tunnelled session
- "5432:5432" # WRONG — this is 0.0.0.0, the whole internet
```
Verify what is actually published before deploying:
```bash
docker compose config | grep -A3 "ports:" # what the merged config declares
docker compose ps --format "{{.Service}}\t{{.Ports}}" # what is really bound
```
Anything other than the app service showing a `0.0.0.0:` binding is a Critical
finding — treat it as such in `/qa` and block the deploy.
## Transport and Headers
Security headers are configured in `next.config.ts`: `X-Frame-Options: DENY`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy: origin-when-cross-origin`,
`Strict-Transport-Security`. Verify they survive the reverse proxy after deploying.
## Changes That Need Explicit User Approval
- Any change to the authentication flow
- Weakening or removing the passkey requirement
- Any change to authorization or ownership checks
- New environment variables (must land in `.env.example` in the same commit)