antispam: rspamd strips a sender's own verdict for every client
test / test (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-14 19:48:07 +03:00
co-authored by Claude Opus 5.5
parent a658da11f7
commit 31c2b50457
6 changed files with 90 additions and 20 deletions
+27
View File
@@ -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
+5 -3
View File
@@ -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:
+20 -2
View File
@@ -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 = <<EOD
+19 -7
View File
@@ -10,10 +10,21 @@
# internal/inbound writes with.)
#
# Deny wins over allow, whichever entries match. A denied sender is refused
# before anything else is looked at (a prefilter). An 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.
# whatever else is found (the rule's action is a pre-result, below). An
# 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
# "normal" symbol, one with an action with the "passthrough" augmentation;
# no "prefilter" setting is read.)
#
# A sender rule looks at the envelope sender only, extract_from = "smtp":
# by default multimap.lua takes the header From: when the envelope sender is
# empty (the "from" callback, has_from('smtp') then has_from('mime')), so a
# bounce would be judged by an address the panel never counts it under
# (assumptions C-R4). Tried on 2026-10-14 in rspamd/rspamd:3.11.1-3: a
# 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,
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
@@ -34,24 +45,23 @@
SELFPOST_DENY_IP {
type = "ip";
map = "/etc/rspamd/selfpost/deny_ip.map";
prefilter = true;
action = "reject";
message = "Refused by this server's deny list";
description = "Client address on the deny list";
}
SELFPOST_DENY_FROM {
type = "from";
extract_from = "smtp";
map = "/etc/rspamd/selfpost/deny_from.map";
prefilter = true;
action = "reject";
message = "Refused by this server's deny list";
description = "Sender address on the deny list";
}
SELFPOST_DENY_DOMAIN {
type = "from";
extract_from = "smtp";
filter = "email:domain";
map = "/etc/rspamd/selfpost/deny_domain.map";
prefilter = true;
action = "reject";
message = "Refused by this server's deny list";
description = "Sender domain on the deny list";
@@ -65,12 +75,14 @@ SELFPOST_ALLOW_IP {
}
SELFPOST_ALLOW_FROM {
type = "from";
extract_from = "smtp";
map = "/etc/rspamd/selfpost/allow_from.map";
score = -100.0;
description = "Sender address on the allow list";
}
SELFPOST_ALLOW_DOMAIN {
type = "from";
extract_from = "smtp";
filter = "email:domain";
map = "/etc/rspamd/selfpost/allow_domain.map";
score = -100.0;
+9 -5
View File
@@ -118,11 +118,15 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
every change and on every start with the relay on. SelfPost never consults
them. The sidecar mounts the directory and rspamd's multimap rules
([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf))
read them: a deny entry is a prefilter `reject`, an allow entry a symbol
weighing -100. Allow is deliberately not a prefilter `accept`: with two
prefilters rspamd lets the one that ran first decide, and deny then won or
lost by rule order (seen on rspamd 3.11). A refusal by the deny list
overrides any score, so deny wins whichever entries match.
read them: a deny entry's rule carries the action `reject`, which rspamd
sets as a pre-result, an allow entry is a symbol weighing -100. Allow is
deliberately not an `accept` action: with two pre-results rspamd keeps the
one set first, and deny then won or lost by rule order (seen on rspamd
3.11, date not recorded). A refusal by the deny list overrides any score,
so deny wins whichever entries match. 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,
`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
deny rule's reply, the allow rule's symbol, which the journal milter keeps
+10 -3
View File
@@ -261,7 +261,13 @@ operator can act on it.
keeps of the filter's verdict only what its patterns recognise. **A verdict
is read only next to the filter's signature.** The filter is attached so
that mail flows when it is down, and then nothing removes verdict headers a
sender wrote himself. So rspamd adds a third header, `X-SelfPost-Filter`:
sender wrote himself. Nor would rspamd itself by default for a client it
counts as local - a private address, which every client has when Docker's
proxy relays port 25 - or an authenticated one: `skip_local` and
`skip_authenticated` (rspamd 3.11.1, `milter_headers.lua`, `skip_wanted()`)
leave the sender's headers in place, while the signing routine below runs
regardless; both are turned off in `milter_headers.conf` (assumptions
C-R7, tried on 2026-10-14). So rspamd adds a third header, `X-SelfPost-Filter`:
the HMAC-SHA256 of the message's queue id under a key SelfPost generates
(`data/antispam/filter.key`) and the sidecar reads from the directory it
mounts. The journal milter recomputes it at end-of-message; with no
@@ -427,8 +433,9 @@ deferred item from the roadmap.
Senders on the internet, whom the signature is for, cannot read it. Trigger
to revisit: the same as for the list files - a shared host.
- **An allow entry for a sender address or domain trusts what the sending
server says.** The filter matches the envelope sender, which is not
authenticated: anyone who knows or guesses an allowed address can send as it
server says.** The filter matches the envelope sender (only that: a
bounce's `From:` header is not looked at, `extract_from = "smtp"`), which
is not authenticated: anyone who knows or guesses an allowed address can send as it
and is given the entry's weight, which no spam score outweighs. The page's
Help and the guide say so and recommend an entry for the client's address or
network instead. Deny entries have no such problem - claiming to be a denied