diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f9fa3..4a4b162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version - **A session made under a longer `PANEL_SESSION_IDLE_DAYS` is cut to the current window** at its next request (audit A10), not kept until its old expiry. +- **The antispam sidecar runs rspamd 4.2.2, hardened** (audit S4): from + 3.11.1 (December 2024; the line between fixed a heap overflow, a + use-after-free on reload and parser crashes), with no Linux capability, + `no-new-privileges`, and only the milter on the compose network - the + scanner and the controller, whose default password is public, stay on the + sidecar's loopback. A third file, `antispam/rspamd/worker-proxy.inc`, is + mounted; copy it with the fragment. - **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 6f65e2d..c5723fb 100644 --- a/deploy/antispam/docker-compose.antispam.yml +++ b/deploy/antispam/docker-compose.antispam.yml @@ -9,10 +9,11 @@ # Requires INBOUND_RELAY_ENABLE=true in .env. The milter sees the real client # IP, HELO and PTR — unlike the upstream, which only sees SelfPost's address. # -# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1" -# move with each rebuild (Docker Hub rspamd/rspamd tags 3.11.1-0 ... -3) - -# and it is the build the comments in antispam/rspamd/ were read against; -# bump it deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar +# The rspamd image is pinned to one build, 4.2.2-0 - "4.2", "4" and "latest" +# are rebuilt every night - and it is the build the comments in +# antispam/rspamd/ were read against (rspamd 4.2.2, rspamd-docker v4.2.2+0; +# 3.11.1-3 until 2.14.4, security audit 2026-10-17, S4); bump it +# deliberately, and read them again. Default milter action is fail-open (accept) so a down sidecar # does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to # defer instead. @@ -26,12 +27,21 @@ services: - antispam antispam: - # The milter: rspamd 3.11.1, conf/worker-proxy.inc, "milter = yes" for the - # rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker - # v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333 - # (its Dockerfile, USER 11333:11333). - image: rspamd/rspamd:3.11.1-3 + # The milter: rspamd 4.2.2, conf/worker-proxy.inc, "milter = yes" for the + # rspamd_proxy worker, bound to *:11332 by antispam/rspamd/worker-proxy.inc + # while every other worker stays on loopback (RSPAMD_LOCAL_ADDR below); + # it runs as uid 11333 (rspamd-docker v4.2.2+0, Dockerfile, USER + # 11333:11333) and needs no capability: it starts unprivileged, binds + # ports above 1024 and runs nothing setuid (src/rspamd.c, detect_priv()). + image: rspamd/rspamd:4.2.2-0 restart: unless-stopped + environment: + # Workers on loopback; the proxy alone is put back on every address. + RSPAMD_LOCAL_ADDR: localhost + security_opt: + - no-new-privileges:true + cap_drop: + - ALL # 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. @@ -41,4 +51,6 @@ services: # The allow and deny lists kept in the panel: the rules, and the files # SelfPost writes them to. - ./antispam/rspamd/multimap.conf:/etc/rspamd/local.d/multimap.conf:ro + # The proxy (the milter) on the compose network, nothing else. + - ./antispam/rspamd/worker-proxy.inc:/etc/rspamd/local.d/worker-proxy.inc:ro - ./data/antispam:/etc/rspamd/selfpost:ro diff --git a/deploy/antispam/rspamd/milter_headers.conf b/deploy/antispam/rspamd/milter_headers.conf index d59c418..bfd69c3 100644 --- a/deploy/antispam/rspamd/milter_headers.conf +++ b/deploy/antispam/rspamd/milter_headers.conf @@ -8,7 +8,7 @@ # rspamd removes headers of these names that a sender wrote himself before it # adds its own - for every client, because of the two lines below. Without # them it would do neither for a client it counts as local or one that -# authenticated: rspamd 3.11.1, src/plugins/lua/milter_headers.lua, +# authenticated: rspamd 4.2.2 (as 3.11.1), src/plugins/lua/milter_headers.lua, # skip_wanted() - skip_local and skip_authenticated are on by default, and # "local" is loopback, a unix socket, or an address in local_addrs, the # private ranges (src/lua/lua_ip.c, lua_ip_is_local(); conf/options.inc). The @@ -20,7 +20,9 @@ # message carrying its own X-Spamd-Result and X-Rspamd-Action): without the # two lines, from 10.0.0.5 or as an authenticated user, the milter reply # removed neither header, added neither, and added the signature; with -# them, both removed and rspamd's own added, as from a public address. +# them, both removed and rspamd's own added, as from a public address. The +# same again on 2026-10-17 in rspamd/rspamd:4.2.2-0 (from 10.0.0.5), and the +# signature equal to HMAC-SHA256 of the queue id computed with openssl. # # Without this file the journal still lists accepted mail, with no verdict # next to it. @@ -34,7 +36,7 @@ # signature travels on with the mail and is of no use to whoever sees it: it # fits that one queue id. # -# rspamd 3.11.1, src/plugins/lua/milter_headers.lua: a custom routine is called +# rspamd 4.2.2 (as 3.11.1), src/plugins/lua/milter_headers.lua: a custom routine is called # as function(task, common_meta) and returns error, headers to add, headers to # remove, metadata; a remove value of 0 removes every header of that name # (docs.rspamd.com/modules/milter_headers, "Custom routines"). diff --git a/deploy/antispam/rspamd/multimap.conf b/deploy/antispam/rspamd/multimap.conf index 64da88e..df79fa9 100644 --- a/deploy/antispam/rspamd/multimap.conf +++ b/deploy/antispam/rspamd/multimap.conf @@ -3,7 +3,7 @@ # The allow and deny lists kept in the panel (Inbound > Filter lists). SelfPost # writes them as six files under data/antispam/, which the fragment mounts at # /etc/rspamd/selfpost; rspamd rereads a file when it changes, no restart. -# (rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's +# (rspamd 4.2.2 (as 3.11.1), src/libserver/maps/map.c: a file map is watched with libev's # ev_stat - inotify where the kernel has it, else a stat poll every # map_watch_interval x map_file_watch_multiplier, 5min x 0.1, conf/options.inc; # docs.rspamd.com/configuration/maps asks for an atomic rename, which @@ -14,7 +14,7 @@ # allowed one is not accepted outright - rspamd would then let whichever of # the two rules ran first decide - but given a weight no spam score # outweighs, which a refusal by the deny list still overrides. (rspamd -# 3.11.1, src/plugins/lua/multimap.lua: every rule is registered as a +# 4.2.2 (as 3.11.1), src/plugins/lua/multimap.lua: every rule is registered as a # "normal" symbol, one with an action with the "passthrough" augmentation; # no "prefilter" setting is read. Measured with this file in a throwaway # rspamd/rspamd:3.11.1-3, 2026-10-15: every mix of an allow and a deny entry @@ -29,7 +29,7 @@ # bounce whose From: domain is on the deny list was refused by default and # is not with this setting; a sender on the list is refused either way. # -# A domain entry is that domain, not its subdomains. (rspamd 3.11.1, +# A domain entry is that domain, not its subdomains. (rspamd 4.2.2 (as 3.11.1), # src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key, # case-insensitively - rspamd_icase_hash, rspamd_lc_cmp; "email:domain" gives # the address's domain part, src/plugins/lua/multimap.lua, apply_addr_filter().) diff --git a/deploy/antispam/rspamd/worker-proxy.inc b/deploy/antispam/rspamd/worker-proxy.inc new file mode 100644 index 0000000..bfed360 --- /dev/null +++ b/deploy/antispam/rspamd/worker-proxy.inc @@ -0,0 +1,18 @@ +# Mounted into the sidecar as /etc/rspamd/local.d/worker-proxy.inc. +# +# The milter - the proxy worker - is the one listener SelfPost needs on the +# compose network. The fragment sets RSPAMD_LOCAL_ADDR=localhost, which keeps +# every worker on the container's loopback: the normal worker (11333), whose +# scan accepts a client-named file or shared-memory input, and the controller +# (11334), whose default password is the well-known "q1" (rspamd 4.2.2, +# conf/rspamd.conf: each worker's bind_socket is the env variable LOCAL_ADDR, +# localhost by default, and its port; conf/worker-controller.inc: password = +# "q1"; the rspamd-docker v4.2.2+0 Dockerfile sets RSPAMD_LOCAL_ADDR=* for all +# three). This line puts the proxy alone back on every address: a local.d +# scalar at priority 1 replaces the default one (contrib/libucl/ucl_parser.c, +# UCL_DUPLICATE_MERGE). The proxy still reaches the normal worker on +# localhost, as before (security audit 2026-10-17, S4). +# +# rspamd expands its template syntax in these files, comments included: +# quote none of it here. +bind_socket = "*:11332"; diff --git a/docs/architecture.md b/docs/architecture.md index 70f0ccf..0979c29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -130,7 +130,7 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the rspamd removes a sender's own copies of the three headers and writes and signs its own for every message it lets through: `skip_local` and `skip_authenticated` are off in that file, so this holds whatever the - sender's address (rspamd 3.11.1, `src/plugins/lua/milter_headers.lua`, + sender's address (rspamd 4.2.2 (as 3.11.1), `src/plugins/lua/milter_headers.lua`, `skip_wanted()`). With the filter down - it is attached fail-open - a sender's own verdict headers carry no signature and the row is that of mail nobody judged. @@ -171,7 +171,7 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the allowed IP with a denied address were all `reject` (assumptions D-arch:121). A sender entry is matched against the envelope sender only (`extract_from = "smtp"`; by default rspamd takes - a bounce's `From:` header instead - rspamd 3.11.1, + a bounce's `From:` header instead - rspamd 4.2.2 (as 3.11.1), `src/plugins/lua/multimap.lua`, the `from` callback). How often an entry decided a message is not stored: `Lists.Usage` reads it off the journal on each view of Filter lists - the rows a list decided (the diff --git a/docs/guide.md b/docs/guide.md index 9415280..8bcd673 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1300,6 +1300,13 @@ rereads the list files by itself and reads the key anew for every message. A restart is needed only for a change of its own configuration - the files of `antispam/rspamd/`. +The third file there, [worker-proxy.inc](../deploy/antispam/rspamd/worker-proxy.inc), +keeps rspamd's scanner and its controller - whose default password is public - +on the sidecar's own loopback and puts only the milter on the compose network; +the fragment runs rspamd with no Linux capability. Upgrading SelfPost to +`2.14.4` brings rspamd 4.2.2 with it: copy `antispam/` again, then +`docker compose ... up -d` recreates the sidecar. + The same directory holds [multimap.conf](../deploy/antispam/rspamd/multimap.conf), the rules for the **allow and deny lists** kept in the panel. SelfPost does not apply the lists diff --git a/docs/security-audit-2026-10-17.md b/docs/security-audit-2026-10-17.md index 25dba86..94ad79f 100644 --- a/docs/security-audit-2026-10-17.md +++ b/docs/security-audit-2026-10-17.md @@ -817,10 +817,20 @@ the argument. **Sonnet**, 1.5 h. *Done 2026-10-17:* TestImportKeyRefusesAnOversizedKey and the import cases (a key under 1024 bits the parser already refuses). -- [ ] **S4.** rspamd pin → `4.2.x` after reading `milter_headers`/`multimap` +- [x] **S4.** rspamd pin → `4.2.x` after reading `milter_headers`/`multimap` against the 4.x docs and one e2e run (`inbound_upstream_test`, `antispam_test`); `cap_drop: [ALL]`, `no-new-privileges`, `pids_limit` on - the sidecar. — **Opus**, 1.5 h. + the sidecar. — **Opus**, 1.5 h. *Done 2026-10-17:* rspamd + 4.2.2 and 3.11.1 read side by side (the functions SelfPost relies on are + unchanged); `rspamd/rspamd:4.2.2-0` with `cap_drop: [ALL]`, + `no-new-privileges`, and - recommended by that reading - the normal + worker and the controller (default password "q1") kept on loopback, the + proxy alone on the network (`worker-proxy.inc`, `RSPAMD_LOCAL_ADDR`). The + e2e has no rspamd, so a throwaway container on the test server with our + three files checked it: the listeners, a sender's own verdict headers + removed from a private address, rspamd's added, the signature equal to + openssl's HMAC, the deny list's 554 message, healthy. The live sidecar at + the rollout. ### Stage 5 — supply chain and currency (~2.5 h) diff --git a/internal/inbound/lists.go b/internal/inbound/lists.go index 5598904..2b08110 100644 --- a/internal/inbound/lists.go +++ b/internal/inbound/lists.go @@ -34,7 +34,7 @@ type ListStore interface { // a file when it changes. Which list wins when both match is the filter's // configuration: deny. // -// rspamd 3.11.1, src/libserver/maps/map.c: a file map is watched with libev's +// rspamd 4.2.2 (as 3.11.1), src/libserver/maps/map.c: a file map is watched with libev's // ev_stat (inotify, or a stat poll every map_watch_interval x // map_file_watch_multiplier = 30 s, conf/options.inc), so a change takes // effect within that, not at once; docs.rspamd.com/configuration/maps asks @@ -164,7 +164,7 @@ func (l *Lists) sync() error { // Values were validated on the way in; this is the writer's own guard // against a row that got into the table some other way. In a map file a // newline ends the entry, whitespace starts its value and # a comment - // (rspamd 3.11.1, src/libserver/maps/map_helpers.c, + // (rspamd 4.2.2 (as 3.11.1), src/libserver/maps/map_helpers.c, // rspamd_parse_kv_list()). An IP map takes bare v4 and v6 addresses and // CIDR networks (docs.rspamd.com/configuration/maps, IP maps). if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil { @@ -174,7 +174,7 @@ func (l *Lists) sync() error { } // The filter runs under another user in another container: the directory // and the files are readable by anyone. They hold addresses, not secrets. - // (The rspamd image runs as uid 11333: rspamd-docker v3.11.1+3, + // (The rspamd image runs as uid 11333: rspamd-docker v4.2.2+0 (as v3.11.1+3), // Dockerfile, USER 11333:11333.) if err := os.MkdirAll(l.dir, 0o755); err != nil { return fmt.Errorf("create %s: %w", l.dir, err) @@ -309,7 +309,7 @@ func normalizeNote(raw string) (string, error) { // rejected row. An allow rule adds a symbol named after the kind of entry, // which the journal milter keeps in the verdict of an accepted row. // -// rspamd 3.11.1: the deny message reaches Postfix as the milter reply "554 +// rspamd 4.2.2 (as 3.11.1): the deny message reaches Postfix as the milter reply "554 // 5.7.1 " (src/libserver/milter.c, rspamd_milter_send_task_results()); // src/plugins/lua/milter_headers.lua, routine x-spamd-result, writes each // symbol as "%s(%.2f)[%s]" - name, score, options. diff --git a/internal/milter/inbound.go b/internal/milter/inbound.go index b59fb7d..509d9c2 100644 --- a/internal/milter/inbound.go +++ b/internal/milter/inbound.go @@ -111,7 +111,7 @@ func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Respons // milter8_message(), reads it with rec_get(), which follows the pointer // records src/cleanup/cleanup_milter.c, cleanup_add_header(), leaves after // the last header for a header the milter before added. -// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routines x-spamd-result +// rspamd 4.2.2 (as 3.11.0), src/plugins/lua/milter_headers.lua, routines x-spamd-result // and x-rspamd-action: remove = 0, every instance // (src/libserver/milter.c, rspamd_milter_remove_header_safe()), and the new // one added at the end (order -1, ADDHEADER) - when skip_wanted() does not @@ -146,7 +146,8 @@ var signatureHeader = textproto.CanonicalMIMEHeaderKey(SignatureHeader) // // Measured, not read: what rspamd 3.11 writes for a key and a queue id was // taken in a throwaway container on 2026-10-12 -// (TestFilterSignatureIsWhatTheFilterWrites). +// (TestFilterSignatureIsWhatTheFilterWrites); rspamd 4.2.2-0 wrote the +// HMAC-SHA256 openssl computes, on 2026-10-17 (security audit, S4). func FilterSignature(key, queueID string) string { mac := hmac.New(sha256.New, []byte(key)) mac.Write([]byte(queueID)) @@ -253,7 +254,7 @@ const ( // (deploy/antispam/rspamd/multimap.conf): SELFPOST_ALLOW_IP, _FROM, _DOMAIN. const ListSymbolPrefix = "SELFPOST_" -// rspamd 3.11.0, src/plugins/lua/milter_headers.lua, routine x-spamd-result: +// rspamd 4.2.2 (as 3.11.0), src/plugins/lua/milter_headers.lua, routine x-spamd-result: // string.format('default: %s [%.2f / %.2f]', ...), then one // '%s(%.2f)[%s]' per symbol, joined by '; '. var (