antispam: rspamd strips a sender's own verdict for every client
test / test (push) Canceled after 0s
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user