boma/roles/reverse_proxy/SECURITY.md
sjat cb8f924d4b docs(reverse_proxy): service-role SECURITY/VERIFY/ACCESS records (O12)
reverse_proxy is the first built+applied service role; add the per-service
records CLAUDE.md/ADR-002/008/017/021 require. Add access__*/backup__* data to
defaults as the source of truth (ADR-021/022). reverse_proxy is stateless (ACME
certs re-issue via HTTP-01), so it declares backup__state: false with a reason
rather than a BACKUP.md (ADR-022 convention).

The access__*/backup__* cross-role field names intentionally don't carry the
reverse_proxy__ prefix, so each is marked `# noqa: var-naming[no-role-prefix]`
(ansible-lint has no per-prefix allowlist; rule stays enabled elsewhere).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 19:06:23 +02:00

61 lines
3.8 KiB
Markdown

# Security — reverse_proxy (Caddy)
## Exposure
- **Published ports:** `80/tcp` + `443/tcp` (HTTP→HTTPS redirect + TLS). Both are
declared in the `group_vars` firewall catalog as the askari `public_web` opens
(ADR-020); the Hetzner Cloud Firewall also opens 80/443 (and 3478 for NetBird).
Port 80 must stay open to the internet for the ACME HTTP-01 challenge.
- **Auth surface:** none of its own. Caddy is the TLS terminator and router; per-service
authentication (Authentik `forward_auth`) is added at each route in Phase 2 (ADR-024
§4). Today it fronts only a static `respond` test vhost and (M4b) the NetBird stack,
which carries its own auth.
- **Reachability:** public — askari is internet-facing. Caddy is the single public entry
point; upstreams sit on the internal `boma` Docker network and are reached by name, not
published directly.
- **Data sensitivity:** none persistent worth protecting — only ACME account keys +
issued certificates in the `caddy_data` volume, which are re-issuable (HTTP-01). No
user data, no secrets at rest. See backup record: `backup__state: false` (stateless).
## Checklist status
Each item from `docs/security/service-checklist.md`:
- [x] Secrets in vault; no default creds; nothing secret in git/images — ✅ n/a: HTTP-01
needs no credentials; the only config input is `reverse_proxy__acme_email` (not secret).
- [x] Non-root; no `privileged`/host-network unless justified; minimal mounts; caps
dropped — ⚠️ official `caddy:2` runs as root (to bind 80/443); no `privileged`, no host
network (bridge `boma`); mounts are the read-only Caddyfile + two named volumes. Root
inside the container is the upstream default; revisit if Caddy ships a rootless variant.
- [x] Ports declared in `group_vars`; behind reverse proxy + auth if exposed;
least-privilege inter-service reach — ✅ 80/443 in the catalog; Caddy *is* the proxy;
upstreams are not published, only reachable on the `boma` network.
- [x] Image pinned (tag/digest), update path known — ⚠️ pinned to the `caddy:2` major
tag (stateless tier, ADR-011/ADR-004), not a digest; refreshed deliberately and watched
by DIUN. Tighten to `tag@digest` if the proxy is reclassified as stateful.
- [x] Logs reviewable; backup/restore covered if stateful — ✅ stateless (no backup
needed); logs via `docker logs caddy` now, Loki labels declared for the ADR-018 pipeline.
## Service-specific hardening
- **HTTP-01 only, no DNS token:** vanilla `caddy:2`, no `caddy-dns/gandi` plugin and no
Gandi API token on the host — removes a credential and a custom-image supply chain
(ADR-024 revised Status).
- **Caddyfile is read-only** in the container (`:ro` mount); rendered solely by Ansible
from the `group_vars` route catalog — no dynamic label discovery, so no route exists
that wasn't declared (the reason Caddy was chosen over Traefik, ADR-024 §1).
- **Admin API not exposed:** Caddy's admin endpoint stays on container-localhost `:2019`;
never published, never in the firewall catalog (`access__api.enabled: false`).
- **Automatic HTTPS:** HTTP is redirected to HTTPS and modern TLS defaults are Caddy's
out-of-the-box behaviour (no manual cipher config needed).
## Residual / accepted risks
- **Container runs as root** — upstream `caddy:2` default (needs to bind low ports).
Rationale: official image, no rootless variant wired yet; blast radius limited to the
proxy container. Revisit: adopt a rootless Caddy image if upstream stabilises one.
- **Image pinned to a major tag, not a digest** — accepted for the stateless tier
(ADR-011). Revisit if the role gains state.
- **ACME re-issuance vs Let's Encrypt rate limits** — losing `caddy_data` triggers
re-issuance; rapid repeated rebuilds could hit LE rate limits. Acceptable for a handful
of askari hostnames; noted in the backup rationale.