From 75e99f75c6d14a591945dbf59464a35bf6c231a2 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 9 Aug 2026 01:12:06 +0200 Subject: [PATCH] feat: make "only the app publishes a port" a binding rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .claude/agents/backend-dev.md | 4 +++ .claude/agents/qa-engineer.md | 4 +++ .claude/rules/backend.md | 4 +++ .claude/rules/security.md | 48 ++++++++++++++++++++++++++++ .claude/skills/architecture/SKILL.md | 6 ++-- .claude/skills/deploy/SKILL.md | 12 +++++++ README.md | 32 +++++++++++++++++++ compose.yaml | 22 +++++++++++-- docs/DEPLOYMENT.md | 36 +++++++++++++++++++-- 9 files changed, 161 insertions(+), 7 deletions(-) diff --git a/.claude/agents/backend-dev.md b/.claude/agents/backend-dev.md index 087262d..ebc55ea 100644 --- a/.claude/agents/backend-dev.md +++ b/.claude/agents/backend-dev.md @@ -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 diff --git a/.claude/agents/qa-engineer.md b/.claude/agents/qa-engineer.md index 437880f..53f32b5 100644 --- a/.claude/agents/qa-engineer.md +++ b/.claude/agents/qa-engineer.md @@ -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, diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 71f4b99..989a744 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -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 diff --git a/.claude/rules/security.md b/.claude/rules/security.md index 5627cc7..16c31af 100644 --- a/.claude/rules/security.md +++ b/.claude/rules/security.md @@ -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`, diff --git a/.claude/skills/architecture/SKILL.md b/.claude/skills/architecture/SKILL.md index f57bcc8..2ed6e96 100644 --- a/.claude/skills/architecture/SKILL.md +++ b/.claude/skills/architecture/SKILL.md @@ -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 diff --git a/.claude/skills/deploy/SKILL.md b/.claude/skills/deploy/SKILL.md index 6e059a2..2006b45 100644 --- a/.claude/skills/deploy/SKILL.md +++ b/.claude/skills/deploy/SKILL.md @@ -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. diff --git a/README.md b/README.md index 3332e7b..d8a3446 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,37 @@ docker compose up -d --build # Erststart ./scripts/update.sh # neuen Stand ausrollen ``` +### Nur der App-Port geht nach außen + +Im Stack veröffentlicht **ausschließlich der App-Service** einen Port, und auch den +nur auf Loopback (`127.0.0.1`). Jeder weitere Service — Datenbank, Cache, Queue, +Admin-UI — bekommt **keinen `ports:`-Eintrag**. Die Container erreichen sich über +den Compose-Netzwerknamen, dafür muss nichts veröffentlicht werden: + +``` +DATABASE_URL=postgres://user:pass@db:5432/app +``` + +> `ports: "5432:5432"` bindet auf `0.0.0.0` — die Datenbank steht damit im offenen +> Internet, geschützt nur durch ihr Passwort. Und Docker schreibt eigene +> iptables-Regeln, die **ufw umgehen**: Der Server sieht abgeschottet aus, während +> der Port offen ist. Das ist einer der häufigsten Wege, wie selbst gehostete Stacks +> kompromittiert werden. + +Die Regel ist an vier Stellen verankert, damit sie beim Vibe-Coden nicht verloren +geht: `rules/security.md` (lädt automatisch beim Bearbeiten von `compose.yaml`), der +Backend-Sub-Agent, der QA-Sub-Agent (Verstoß = **Critical**) und der Pre-Flight von +`/deploy`, der den Deploy abbricht. + +Prüfen, was wirklich gebunden ist: + +```bash +docker compose config | grep -B8 "published:" +docker compose ps --format "{{.Service}}\t{{.Ports}}" +``` + +Zugriff zum Debuggen holst du dir per SSH-Tunnel, nicht per veröffentlichtem Port. + ### Was `update.sh` macht ```mermaid @@ -555,6 +586,7 @@ Vier Dinge, die nicht offensichtlich sind und je einmal wehtun: | Fall | Was passiert | |------|--------------| +| Ein DB-Service bekommt `ports: "5432:5432"` | Die Datenbank steht im offenen Internet — und Docker umgeht dabei ufw, der Server *wirkt* nur abgeschottet | | `NEXT_PUBLIC_APP_URL` weicht von der Domain ab | Alle Passkeys scheitern, ohne verwertbare Fehlermeldung | | Port 3000 ist von einem anderen Projekt belegt | Playwright testet still gegen den fremden Server — `E2E_PORT` setzen | | Jemand legt eine `tailwind.config.ts` an | Wird in v4 ignoriert; Tokens gehören in `globals.css` | diff --git a/compose.yaml b/compose.yaml index 9e6e3f8..007aa80 100644 --- a/compose.yaml +++ b/compose.yaml @@ -32,8 +32,13 @@ services: max-file: "3" # A database is deliberately not part of this template — /architecture picks one -# per project. When you add one, declare it here as a second service, put it on -# an internal network without a published port, and add its volume below. +# per project. +# +# RULE FOR EVERY SERVICE ADDED BELOW: only `app` gets a `ports:` entry. Databases, +# caches and queues get none — the app reaches them by service name over this +# compose network (`db:5432`). Publishing 5432 puts the database on the public +# internet, and Docker's iptables rules bypass ufw, so the server only *looks* +# firewalled. See .claude/rules/security.md. # # db: # image: postgres:17-alpine @@ -41,8 +46,21 @@ services: # environment: # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} # POSTGRES_DB: ${POSTGRES_DB:-app} +# expose: +# - "5432" # documentation only — NOT reachable from the host # volumes: # - db-data:/var/lib/postgresql/data +# healthcheck: +# test: ["CMD-SHELL", "pg_isready -U postgres"] +# interval: 10s +# timeout: 5s +# retries: 5 +# +# Then make the app wait for it: +# app: +# depends_on: +# db: +# condition: service_healthy # # volumes: # db-data: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4721fea..1d72a71 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -82,14 +82,44 @@ to `HEALTH_TIMEOUT` seconds (default 120). If the container never turns healthy prints the last 50 log lines, resets to the previous commit, rebuilds and restarts that — so a bad deploy self-heals instead of leaving the site down. +## Port exposure + +**Only the app service publishes a port, and only on loopback.** Everything else — +database, cache, queue, admin UI — gets no `ports:` entry. Services reach each other +by name over the Compose network, which needs nothing published: + +``` +DATABASE_URL=postgres://user:pass@db:5432/app +``` + +`ports: "5432:5432"` binds `0.0.0.0`, which means the database is on the public +internet. And Docker writes its own nat/DOCKER-USER iptables rules, so **ufw does not +block it** — the server looks firewalled while the port is open. This is a routine +way self-hosted stacks get breached. + +Need access for debugging? Do not publish the port. Use an SSH tunnel: + +```bash +ssh -L 5432:localhost:5432 user@server \ + -t 'docker compose -f /srv/app/compose.yaml exec db psql -U postgres' +``` + +Audit what is really bound before and after every deploy: + +```bash +docker compose config | grep -B8 "published:" # declared +docker compose ps --format "{{.Service}}\t{{.Ports}}" # actual +ss -tlnp | grep -v 127.0.0.1 # what the host exposes +``` + ## Database The template ships none by design. When `/architecture` picks one: -1. Add it as a second service in `compose.yaml` — no published port, reachable only - over the internal Compose network +1. Add it as a second service in `compose.yaml` — **no `ports:` entry**, reachable + only over the internal Compose network. Use `expose:` if you want it documented. 2. Add a named volume for its data -3. Add `DATABASE_URL` to `.env.example` +3. Add `DATABASE_URL` to `.env.example` (pointing at the service name, e.g. `db:5432`) 4. Run migrations **before** the new container serves traffic. Either add a short-lived `migrate` service that the app depends on, or add the migration command to `scripts/update.sh` between build and restart.