From 31c2b5045731fb66ace34392ec6ca71c64c38c2a Mon Sep 17 00:00:00 2001 From: Mix Date: Wed, 14 Oct 2026 19:48:07 +0300 Subject: [PATCH] antispam: rspamd strips a sender's own verdict for every client rspamd 3.11.1's milter_headers skips its verdict routines - removes none of the sender's X-Spamd-Result / X-Rspamd-Action, adds none - for a client it counts as local (loopback, a unix socket, local_addrs: the private ranges) or one that authenticated (skip_local, skip_authenticated, both on by default; milter_headers.lua skip_wanted(), lua_ip.c lua_ip_is_local()). The custom routine that signs X-SelfPost-Filter is never put through skip_wanted(), so it signed the sender's own verdict, and Inbound > Log showed it as rspamd's. Every sender is private when Docker's proxy relays port 25 from the bridge gateway. Both options set to false. Tried 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 on the test server (rspamc --ucl, a message carrying its own verdict headers): old config, from 10.0.0.5 and as an authenticated user, the milter reply removed and added neither and added the signature; new config, both removed and rspamd's own added, as from 203.0.113.9. Also: the sender rules take the envelope only (extract_from = "smtp"; by default a bounce's From: header is used - tried: a bounce whose From: domain is denied was refused before, is not now; a denied sender is refused either way); the image pinned to 3.11.1-3, the build read (3.11 and 3.11.1 move with each rebuild); the prefilter lines no longer read go. architecture.md and security.md say so. Assumptions C-R7, B-R1, C-R4, C-R12, C-R5. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 27 +++++++++++++++++++++ deploy/antispam/docker-compose.antispam.yml | 8 +++--- deploy/antispam/rspamd/milter_headers.conf | 22 +++++++++++++++-- deploy/antispam/rspamd/multimap.conf | 26 ++++++++++++++------ docs/architecture.md | 14 +++++++---- docs/security.md | 13 +++++++--- 6 files changed, 90 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e880d..499f002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ## [Unreleased] +### Security + +- **A sender on a private address could have his own spam verdict signed.** + rspamd's header module by default leaves a message's verdict headers + alone - removes none, adds none - when the client counts as local (a + private range, which every client has when Docker's proxy relays port 25) + or authenticated, while the routine that signs a verdict for SelfPost runs + for every message. Such a sender's `X-Spamd-Result` and `X-Rspamd-Action` + then reached Inbound > Log as rspamd's. `milter_headers.conf` now turns + both exceptions off; tried against rspamd 3.11.1 before and after + (audit 2026-10-12, stage 2c; assumptions C-R7). Copy the new + `antispam/rspamd/` files next to your compose fragment. + +### Fixed + +- **A bounce is judged by the filter lists as the panel counts it.** + rspamd's sender rules took a bounce's `From:` header when the envelope + sender was empty, so a bounce could be refused or allowed by an entry the + Filter lists page never showed as used. They now look at the envelope + sender only (assumptions C-R4). + +### Changed + +- **The rspamd image is pinned to one build**, `rspamd/rspamd:3.11.1-3`: + `3.11` and `3.11.1` move with every rebuild (assumptions C-R12). The rules + lose their `prefilter = true` lines, which rspamd 3.11.1 does not read. + ## [2.5.1] - 2026-10-14 Stage 2b of the 2026-10-12 audit: the log tailer and what the panel shows of diff --git a/deploy/antispam/docker-compose.antispam.yml b/deploy/antispam/docker-compose.antispam.yml index 753b0ea..4cb5f54 100644 --- a/deploy/antispam/docker-compose.antispam.yml +++ b/deploy/antispam/docker-compose.antispam.yml @@ -9,8 +9,10 @@ # 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. # -# Pin the rspamd tag the same way the SelfPost image is pinned; bump it -# deliberately. Default milter action is fail-open (accept) so a down sidecar +# 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 # does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to # defer instead. @@ -28,7 +30,7 @@ services: # 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 + image: rspamd/rspamd:3.11.1-3 restart: unless-stopped # No host ports: only SelfPost talks to the milter on the compose network. volumes: diff --git a/deploy/antispam/rspamd/milter_headers.conf b/deploy/antispam/rspamd/milter_headers.conf index 018a376..d0e72e1 100644 --- a/deploy/antispam/rspamd/milter_headers.conf +++ b/deploy/antispam/rspamd/milter_headers.conf @@ -6,8 +6,24 @@ # and the upstream mailbox server receives them with the mail. # # rspamd removes headers of these names that a sender wrote himself before it -# adds its own. Without this file the journal still lists accepted mail, with -# no verdict next to 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, +# 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 +# sender's own verdict would then pass untouched while the signature below, +# a custom routine that skip_wanted() never consults, signed it (assumptions +# C-R7). A private client address is ordinary here: a LAN sender, or every +# sender when Docker's proxy relays port 25 from the bridge gateway. +# Tried on 2026-10-14 in a throwaway rspamd/rspamd:3.11.1-3 (rspamc --ucl, a +# 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. +# +# Without this file the journal still lists accepted mail, with no verdict +# next to it. # # The third header is the proof that the other two are rspamd's. The filter is # attached so that mail still flows when it is down, and then nothing removes @@ -30,6 +46,8 @@ # end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS # "i"). use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"]; +skip_local = false; +skip_authenticated = false; custom { selfpost-signature = <