deploy: a process ceiling on every container (audit I3)
Security audit 2026-10-17, finding I3 (low), stage 4. No compose file set pids_limit: a fork bomb from a compromised panel or postfix had the host's pid space as its limit. Now 1024 for SelfPost - the plan said 512, but Postfix starts up to default_process_limit (100) processes per service, and a busy server runs three smtpd services, cleanup and smtp at once - 256 for nginx, Caddy, Traefik and rspamd, 64 for certbot. The comment that said root runs "very briefly" now says which root processes stay. e2e: checkPidsLimit reads /sys/fs/cgroup/pids.max in the container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
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,
|
address gets the liveness answer - the container's own check, the host,
|
||||||
a monitor next to it; the internet gets `404`.
|
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
|
- **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
|
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
|
`postfix` is in that group, so every Postfix daemon - an smtpd talking to
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ services:
|
|||||||
# (its Dockerfile, USER 11333:11333).
|
# (its Dockerfile, USER 11333:11333).
|
||||||
image: rspamd/rspamd:3.11.1-3
|
image: rspamd/rspamd:3.11.1-3
|
||||||
restart: unless-stopped
|
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.
|
# No host ports: only SelfPost talks to the milter on the compose network.
|
||||||
volumes:
|
volumes:
|
||||||
# The verdict headers SelfPost's inbound journal reads.
|
# The verdict headers SelfPost's inbound journal reads.
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ services:
|
|||||||
caddy:
|
caddy:
|
||||||
image: caddy:2
|
image: caddy:2
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||||
|
pids_limit: 256
|
||||||
depends_on:
|
depends_on:
|
||||||
- selfpost
|
- selfpost
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -99,10 +99,12 @@ services:
|
|||||||
# when the variable is true.
|
# when the variable is true.
|
||||||
- "25:25"
|
- "25:25"
|
||||||
- "127.0.0.1:8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
# Hardening. SelfPost's entrypoint still needs to run as
|
# Hardening. SelfPost's entrypoint runs as root to fix /data ownership
|
||||||
# root very briefly to fix /data ownership and normalise permissions
|
# and normalise permissions under the shared `selfpost` group (see
|
||||||
# under the shared `selfpost` group (see build/entrypoint.sh) before
|
# build/entrypoint.sh) before supervisord drops the panel to an
|
||||||
# supervisord drops the panel to an unprivileged user — so this cannot be
|
# 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
|
# `user: panel` or a fully read-only rootfs without breaking that startup
|
||||||
# self-healing. What IS applied: no privilege escalation past what the
|
# self-healing. What IS applied: no privilege escalation past what the
|
||||||
# image already grants, and every Linux capability dropped except the
|
# image already grants, and every Linux capability dropped except the
|
||||||
@@ -151,3 +153,9 @@ services:
|
|||||||
- SETGID
|
- SETGID
|
||||||
- DAC_OVERRIDE
|
- DAC_OVERRIDE
|
||||||
- KILL
|
- 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
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ services:
|
|||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.27
|
image: nginx:1.27
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||||
|
pids_limit: 256
|
||||||
depends_on:
|
depends_on:
|
||||||
- selfpost
|
- selfpost
|
||||||
ports:
|
ports:
|
||||||
@@ -59,6 +61,7 @@ services:
|
|||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot:latest
|
image: certbot/certbot:latest
|
||||||
|
pids_limit: 64
|
||||||
volumes:
|
volumes:
|
||||||
- ./nginx/certbot-etc:/etc/letsencrypt
|
- ./nginx/certbot-etc:/etc/letsencrypt
|
||||||
- ./nginx/certbot-www:/var/www/certbot
|
- ./nginx/certbot-www:/var/www/certbot
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ services:
|
|||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.1
|
image: traefik:v3.1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# A ceiling on processes and threads (security audit 2026-10-17, I3).
|
||||||
|
pids_limit: 256
|
||||||
depends_on:
|
depends_on:
|
||||||
- selfpost
|
- selfpost
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -799,8 +799,11 @@ the argument.
|
|||||||
|
|
||||||
### Stage 4 — resources and imports (~5 h)
|
### Stage 4 — resources and imports (~5 h)
|
||||||
|
|
||||||
- [ ] **I3.** `pids_limit: 512` in every compose fragment; the comment
|
- [x] **I3.** `pids_limit: 512` in every compose fragment; the comment
|
||||||
corrected; one e2e run. — **Sonnet**, 0.5 h.
|
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
|
- [ ] **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`.
|
changed; a 2 s debounce for bursts); a per-user limiter on `dns-recheck`.
|
||||||
— **Opus**, 1.5 h.
|
— **Opus**, 1.5 h.
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ func TestE2E(t *testing.T) {
|
|||||||
if err := checkPostfixHardening(h); err != nil {
|
if err := checkPostfixHardening(h); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if err := checkPidsLimit(h); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
if err := checkInboundRelayOff(h); err != nil {
|
if err := checkInboundRelayOff(h); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,19 @@ func checkDKIMTree(s *stack) error {
|
|||||||
return nil
|
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
|
// checkPostfixHardening reads back what postfix-config.sh sets against
|
||||||
// downgrade and enumeration (audit 2026-10-12, L31).
|
// downgrade and enumeration (audit 2026-10-12, L31).
|
||||||
func checkPostfixHardening(s *stack) error {
|
func checkPostfixHardening(s *stack) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user