diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1b9aa..a09f861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version only a request with no `X-Forwarded-For` from loopback or a private address gets the liveness answer - the container's own check, the host, a monitor next to it; the internet gets `404`. +- **Every container has a process ceiling** (audit I3): `pids_limit` 1024 + for SelfPost, 256 for the proxies and rspamd, 64 for certbot. - **The TLS private key no longer has to be readable by Postfix** (audit I1): the guide gave it to group 10998 for the panel's backup, and `postfix` is in that group, so every Postfix daemon - an smtpd talking to diff --git a/deploy/antispam/docker-compose.antispam.yml b/deploy/antispam/docker-compose.antispam.yml index 4cb5f54..6f65e2d 100644 --- a/deploy/antispam/docker-compose.antispam.yml +++ b/deploy/antispam/docker-compose.antispam.yml @@ -32,6 +32,8 @@ services: # (its Dockerfile, USER 11333:11333). image: rspamd/rspamd:3.11.1-3 restart: unless-stopped + # A ceiling on processes and threads (security audit 2026-10-17, I3). + pids_limit: 256 # No host ports: only SelfPost talks to the milter on the compose network. volumes: # The verdict headers SelfPost's inbound journal reads. diff --git a/deploy/caddy/docker-compose.caddy.yml b/deploy/caddy/docker-compose.caddy.yml index 10fd95c..0984ee0 100644 --- a/deploy/caddy/docker-compose.caddy.yml +++ b/deploy/caddy/docker-compose.caddy.yml @@ -48,6 +48,8 @@ services: caddy: image: caddy:2 restart: unless-stopped + # A ceiling on processes and threads (security audit 2026-10-17, I3). + pids_limit: 256 depends_on: - selfpost ports: diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index dcc5c63..279259f 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -99,10 +99,12 @@ services: # when the variable is true. - "25:25" - "127.0.0.1:8080:8080" - # Hardening. SelfPost's entrypoint still needs to run as - # root very briefly to fix /data ownership and normalise permissions - # under the shared `selfpost` group (see build/entrypoint.sh) before - # supervisord drops the panel to an unprivileged user — so this cannot be + # Hardening. SelfPost's entrypoint runs as root to fix /data ownership + # and normalise permissions under the shared `selfpost` group (see + # build/entrypoint.sh) before supervisord drops the panel to an + # unprivileged user; supervisord, Postfix's master, cert-reload and + # logrotate stay root for the container's life, which is inherent to + # them (security audit 2026-10-17, I3) — so this cannot be # `user: panel` or a fully read-only rootfs without breaking that startup # self-healing. What IS applied: no privilege escalation past what the # image already grants, and every Linux capability dropped except the @@ -151,3 +153,9 @@ services: - SETGID - DAC_OVERRIDE - KILL + # A ceiling on processes and threads, so that a runaway or compromised + # program cannot use up the host's (security audit 2026-10-17, I3). + # Postfix starts up to 100 processes per service by default + # (default_process_limit), and a busy server runs three smtpd services, + # cleanup and smtp at once; 1024 leaves them room. + pids_limit: 1024 diff --git a/deploy/nginx/docker-compose.nginx.yml b/deploy/nginx/docker-compose.nginx.yml index 71ba325..297b11d 100644 --- a/deploy/nginx/docker-compose.nginx.yml +++ b/deploy/nginx/docker-compose.nginx.yml @@ -47,6 +47,8 @@ services: nginx: image: nginx:1.27 restart: unless-stopped + # A ceiling on processes and threads (security audit 2026-10-17, I3). + pids_limit: 256 depends_on: - selfpost ports: @@ -59,6 +61,7 @@ services: certbot: image: certbot/certbot:latest + pids_limit: 64 volumes: - ./nginx/certbot-etc:/etc/letsencrypt - ./nginx/certbot-www:/var/www/certbot diff --git a/deploy/traefik/docker-compose.traefik.yml b/deploy/traefik/docker-compose.traefik.yml index 2079833..6004170 100644 --- a/deploy/traefik/docker-compose.traefik.yml +++ b/deploy/traefik/docker-compose.traefik.yml @@ -40,6 +40,8 @@ services: traefik: image: traefik:v3.1 restart: unless-stopped + # A ceiling on processes and threads (security audit 2026-10-17, I3). + pids_limit: 256 depends_on: - selfpost command: diff --git a/docs/security-audit-2026-10-17.md b/docs/security-audit-2026-10-17.md index 4ceb374..dd57ec4 100644 --- a/docs/security-audit-2026-10-17.md +++ b/docs/security-audit-2026-10-17.md @@ -799,8 +799,11 @@ the argument. ### Stage 4 — resources and imports (~5 h) -- [ ] **I3.** `pids_limit: 512` in every compose fragment; the comment - corrected; one e2e run. — **Sonnet**, 0.5 h. +- [x] **I3.** `pids_limit: 512` in every compose fragment; the comment + corrected; one e2e run. — **Sonnet**, 0.5 h. *Done 2026-10-17:* 1024 for + SelfPost (Postfix's default_process_limit of 100 per service, three smtpd + services), 256 for the proxies and rspamd, 64 for certbot; e2e reads + `pids.max` back (checkPidsLimit). - [ ] **W6/M9.** Coalesce Postfix reloads (reload only when the rendered map changed; a 2 s debounce for bursts); a per-user limiter on `dns-recheck`. — **Opus**, 1.5 h. diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 3d63993..1e04cdc 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -111,6 +111,9 @@ func TestE2E(t *testing.T) { if err := checkPostfixHardening(h); err != nil { t.Fatal(err) } + if err := checkPidsLimit(h); err != nil { + t.Fatal(err) + } if err := checkInboundRelayOff(h); err != nil { t.Fatal(err) } diff --git a/test/e2e/process_check.go b/test/e2e/process_check.go index 007d072..e25db64 100644 --- a/test/e2e/process_check.go +++ b/test/e2e/process_check.go @@ -146,6 +146,19 @@ func checkDKIMTree(s *stack) error { return nil } +// checkPidsLimit reads the container's process ceiling back from the kernel: +// the shipped compose sets pids_limit (security audit 2026-10-17, I3). +func checkPidsLimit(s *stack) error { + out, err := s.execIn("selfpost", "cat", "/sys/fs/cgroup/pids.max") + if err != nil { + return fmt.Errorf("read pids.max: %v\n%s", err, out) + } + if got := strings.TrimSpace(out); got != "1024" { + return fmt.Errorf("the container's pids.max is %q, want the compose file's 1024", got) + } + return nil +} + // checkPostfixHardening reads back what postfix-config.sh sets against // downgrade and enumeration (audit 2026-10-12, L31). func checkPostfixHardening(s *stack) error {