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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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().)
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user