antispam: rspamd 4.2.2-0, no capabilities, only the milter on the network (audit S4)

Security audit 2026-10-17, finding S4 (low), stage 4. The sidecar ran
rspamd 3.11.1 (December 2024) with default capabilities; upstream is 4.2.2
(2026-10-10), and the line between fixed a heap overflow in
util.lower_utf8, a use-after-free on reload and parser crashes - rspamd
reads every byte a stranger sends to port 25.

A read-only reading of rspamd 3.11.1 and 4.2.2 and of rspamd-docker
v3.11.1+3 and v4.2.2+0, side by side (sources in dev/upstream), found every
function SelfPost relies on unchanged: milter_headers (skip_wanted, the
custom routine, remove 0, create_specific_keyed, get_queue_id from {i}),
the X-Spamd-Result text, multimap (from/smtp, ip, the reject pre-result,
file-map rereading), the image's uid 11333 and CMD. It also found that the
image binds the normal worker (which takes client-named file and shm
inputs) and the controller (default password "q1") on every address,
reachable from any container on the compose network - so:

- image rspamd/rspamd:4.2.2-0 (the one build of 4.2.2; the others float);
- cap_drop: [ALL], no-new-privileges (rspamd starts unprivileged, ports
  above 1024, nothing setuid);
- RSPAMD_LOCAL_ADDR=localhost keeps every worker on loopback, and a new
  antispam/rspamd/worker-proxy.inc puts the proxy alone on *:11332. rspamd
  expands its template syntax in comments too - the first version of that
  comment quoted it and the config failed to load; it quotes none now.

Checked in a throwaway container on the test server with the three files
and the fragment's options (dev/server/rspamd-try.sh): proxy on any
address, 11333/11334 on loopback; from a private address a sender's own
verdict headers removed and rspamd's added; X-SelfPost-Filter equal to
openssl's HMAC-SHA256 of the queue id; a denied address refused with the
deny list's message; healthy. The e2e has no rspamd; the live sidecar is
checked at the rollout. Citations re-pointed to 4.2.2.

Docs: guide § Inbound relay (the third file; copy antispam/ again);
CHANGELOG; plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-17 22:48:31 +03:00
co-authored by Claude Opus 5.5
parent ec1c3a6138
commit ecfe23504e
10 changed files with 83 additions and 26 deletions
+7
View File
@@ -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
+21 -9
View File
@@ -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
+5 -3
View File
@@ -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").
+3 -3
View File
@@ -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().)
+18
View File
@@ -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";
+2 -2
View File
@@ -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
+7
View File
@@ -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
+12 -2
View File
@@ -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)
+4 -4
View File
@@ -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 <message>" (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.
+4 -3
View File
@@ -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 (