Template
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
122 lines
4.6 KiB
Markdown
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)
|