Template
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
This commit is contained in:
@@ -36,6 +36,10 @@ The non-negotiables:
|
||||
as a fallback
|
||||
- Every new env var lands in `.env.example` in the same change
|
||||
- Whatever you add must run inside the Docker Compose stack
|
||||
- **Any service you add to `compose.yaml` gets no `ports:` entry.** Only `app`
|
||||
publishes a port. A database, cache or queue is reached by service name over the
|
||||
compose network (`db:5432`) and must stay unreachable from the host. Publishing it
|
||||
exposes the data to the internet and bypasses the server's firewall.
|
||||
|
||||
## Tests
|
||||
For each route handler, write a Vitest test next to it
|
||||
|
||||
@@ -33,6 +33,10 @@ The feature spec path and the list of previously deployed features to regression
|
||||
fallback rate-limited? A missing or weakened passkey flow is a **High** finding.
|
||||
- Injection through every user-controlled input (XSS, SQL)
|
||||
- Secrets or internal detail leaking in API responses, logs or the network tab
|
||||
- **Published ports:** run `docker compose config | grep -B8 "published:"`. Only the
|
||||
`app` service may publish, and only on `127.0.0.1`. A database, cache or queue with
|
||||
a published port — or anything bound to `0.0.0.0` — is a **Critical** finding: it
|
||||
exposes the service to the internet and bypasses the host firewall.
|
||||
|
||||
## Tests you write
|
||||
- **Unit (Vitest), co-located in `src/`:** hooks with real logic, pure transforms,
|
||||
|
||||
@@ -18,6 +18,10 @@ decision exists, do not install an ORM or write schema code.
|
||||
Whatever is chosen must be self-hostable inside the Docker Compose stack —
|
||||
the deployment target is a plain server, not a managed platform.
|
||||
|
||||
When you add it to `compose.yaml`, it gets **no published port**. The app reaches it
|
||||
by service name over the compose network. See the port-exposure rule in
|
||||
`.claude/rules/security.md`.
|
||||
|
||||
## Route Handlers
|
||||
|
||||
- Validate every request body with a Zod schema before touching it. Reuse the
|
||||
|
||||
@@ -59,6 +59,54 @@ Requirements whenever auth is present:
|
||||
- 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`,
|
||||
|
||||
@@ -41,8 +41,10 @@ This template deliberately ships **no** database or ORM. Decide per feature:
|
||||
> es eine Datenbank im Compose-Stack.
|
||||
|
||||
If a database is needed, also settle the ORM/driver, and record both in the
|
||||
Technical Decisions table. Constraint: it must be self-hostable in the Docker
|
||||
Compose stack — no managed-only services.
|
||||
Technical Decisions table. Two constraints: it must be self-hostable in the Docker
|
||||
Compose stack (no managed-only services), and it runs **without a published port** —
|
||||
only the app is reachable from outside, everything else talks over the internal
|
||||
compose network.
|
||||
|
||||
### Authentication
|
||||
|
||||
|
||||
@@ -36,6 +36,18 @@ Then check:
|
||||
- [ ] Pending migrations are committed
|
||||
- [ ] Everything committed and pushed to the remote the server pulls from
|
||||
|
||||
**Port audit — blocking.** Only the app service may publish a port, and only on
|
||||
loopback:
|
||||
|
||||
```bash
|
||||
docker compose config | grep -B8 "published:"
|
||||
```
|
||||
|
||||
Any service other than `app` with a published port, or any binding to `0.0.0.0`,
|
||||
stops the deploy. A published database port puts the data on the public internet and
|
||||
bypasses ufw, because Docker writes its own iptables rules. Fix `compose.yaml`
|
||||
before continuing.
|
||||
|
||||
## 3. First deployment only
|
||||
|
||||
Walk the user through this once. It happens on the server, over SSH.
|
||||
|
||||
Reference in New Issue
Block a user