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]
|
## [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
|
## [2.5.1] - 2026-10-14
|
||||||
|
|
||||||
Stage 2b of the 2026-10-12 audit: the log tailer and what the panel shows of
|
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
|
# 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.
|
# 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
|
# The rspamd image is pinned to one build, 3.11.1-3 - "3.11" and "3.11.1"
|
||||||
# deliberately. Default milter action is fail-open (accept) so a down sidecar
|
# 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
|
# does not block backup-MX. Set INBOUND_ANTISPAM_MILTER_ACTION=tempfail to
|
||||||
# defer instead.
|
# defer instead.
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@ services:
|
|||||||
# rspamd_proxy worker, which the image binds to *:11332 (rspamd-docker
|
# 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
|
# v3.11.1+3, patches/rspamd-docker-config.diff), and runs as uid 11333
|
||||||
# (its Dockerfile, USER 11333:11333).
|
# (its Dockerfile, USER 11333:11333).
|
||||||
image: rspamd/rspamd:3.11
|
image: rspamd/rspamd:3.11.1-3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# No host ports: only SelfPost talks to the milter on the compose network.
|
# No host ports: only SelfPost talks to the milter on the compose network.
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -6,8 +6,24 @@
|
|||||||
# and the upstream mailbox server receives them with the mail.
|
# and the upstream mailbox server receives them with the mail.
|
||||||
#
|
#
|
||||||
# rspamd removes headers of these names that a sender wrote himself before it
|
# 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
|
# adds its own - for every client, because of the two lines below. Without
|
||||||
# no verdict next to it.
|
# 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
|
# 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
|
# 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
|
# end-of-message (Postfix 3.7.11, src/global/mail_params.h, DEF_MILT_EOD_MACROS
|
||||||
# "i").
|
# "i").
|
||||||
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
|
||||||
|
skip_local = false;
|
||||||
|
skip_authenticated = false;
|
||||||
|
|
||||||
custom {
|
custom {
|
||||||
selfpost-signature = <<EOD
|
selfpost-signature = <<EOD
|
||||||
|
|||||||
@@ -10,10 +10,21 @@
|
|||||||
# internal/inbound writes with.)
|
# internal/inbound writes with.)
|
||||||
#
|
#
|
||||||
# Deny wins over allow, whichever entries match. A denied sender is refused
|
# 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
|
# whatever else is found (the rule's action is a pre-result, below). An
|
||||||
# accepted outright - rspamd would then let whichever of the two rules ran
|
# allowed one is not accepted outright - rspamd would then let whichever of
|
||||||
# first decide - but given a weight no spam score outweighs, which a refusal
|
# the two rules ran first decide - but given a weight no spam score
|
||||||
# by the deny list still overrides.
|
# 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,
|
# 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,
|
# src/libserver/maps/map_helpers.c: a hash map is looked up by the whole key,
|
||||||
@@ -34,24 +45,23 @@
|
|||||||
SELFPOST_DENY_IP {
|
SELFPOST_DENY_IP {
|
||||||
type = "ip";
|
type = "ip";
|
||||||
map = "/etc/rspamd/selfpost/deny_ip.map";
|
map = "/etc/rspamd/selfpost/deny_ip.map";
|
||||||
prefilter = true;
|
|
||||||
action = "reject";
|
action = "reject";
|
||||||
message = "Refused by this server's deny list";
|
message = "Refused by this server's deny list";
|
||||||
description = "Client address on the deny list";
|
description = "Client address on the deny list";
|
||||||
}
|
}
|
||||||
SELFPOST_DENY_FROM {
|
SELFPOST_DENY_FROM {
|
||||||
type = "from";
|
type = "from";
|
||||||
|
extract_from = "smtp";
|
||||||
map = "/etc/rspamd/selfpost/deny_from.map";
|
map = "/etc/rspamd/selfpost/deny_from.map";
|
||||||
prefilter = true;
|
|
||||||
action = "reject";
|
action = "reject";
|
||||||
message = "Refused by this server's deny list";
|
message = "Refused by this server's deny list";
|
||||||
description = "Sender address on the deny list";
|
description = "Sender address on the deny list";
|
||||||
}
|
}
|
||||||
SELFPOST_DENY_DOMAIN {
|
SELFPOST_DENY_DOMAIN {
|
||||||
type = "from";
|
type = "from";
|
||||||
|
extract_from = "smtp";
|
||||||
filter = "email:domain";
|
filter = "email:domain";
|
||||||
map = "/etc/rspamd/selfpost/deny_domain.map";
|
map = "/etc/rspamd/selfpost/deny_domain.map";
|
||||||
prefilter = true;
|
|
||||||
action = "reject";
|
action = "reject";
|
||||||
message = "Refused by this server's deny list";
|
message = "Refused by this server's deny list";
|
||||||
description = "Sender domain on the deny list";
|
description = "Sender domain on the deny list";
|
||||||
@@ -65,12 +75,14 @@ SELFPOST_ALLOW_IP {
|
|||||||
}
|
}
|
||||||
SELFPOST_ALLOW_FROM {
|
SELFPOST_ALLOW_FROM {
|
||||||
type = "from";
|
type = "from";
|
||||||
|
extract_from = "smtp";
|
||||||
map = "/etc/rspamd/selfpost/allow_from.map";
|
map = "/etc/rspamd/selfpost/allow_from.map";
|
||||||
score = -100.0;
|
score = -100.0;
|
||||||
description = "Sender address on the allow list";
|
description = "Sender address on the allow list";
|
||||||
}
|
}
|
||||||
SELFPOST_ALLOW_DOMAIN {
|
SELFPOST_ALLOW_DOMAIN {
|
||||||
type = "from";
|
type = "from";
|
||||||
|
extract_from = "smtp";
|
||||||
filter = "email:domain";
|
filter = "email:domain";
|
||||||
map = "/etc/rspamd/selfpost/allow_domain.map";
|
map = "/etc/rspamd/selfpost/allow_domain.map";
|
||||||
score = -100.0;
|
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
|
every change and on every start with the relay on. SelfPost never consults
|
||||||
them. The sidecar mounts the directory and rspamd's multimap rules
|
them. The sidecar mounts the directory and rspamd's multimap rules
|
||||||
([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf))
|
([deploy/antispam/rspamd/multimap.conf](../deploy/antispam/rspamd/multimap.conf))
|
||||||
read them: a deny entry is a prefilter `reject`, an allow entry a symbol
|
read them: a deny entry's rule carries the action `reject`, which rspamd
|
||||||
weighing -100. Allow is deliberately not a prefilter `accept`: with two
|
sets as a pre-result, an allow entry is a symbol weighing -100. Allow is
|
||||||
prefilters rspamd lets the one that ran first decide, and deny then won or
|
deliberately not an `accept` action: with two pre-results rspamd keeps the
|
||||||
lost by rule order (seen on rspamd 3.11). A refusal by the deny list
|
one set first, and deny then won or lost by rule order (seen on rspamd
|
||||||
overrides any score, so deny wins whichever entries match.
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
(`data/antispam/filter.key`) and the sidecar reads from the directory it
|
||||||
mounts. The journal milter recomputes it at end-of-message; with no
|
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
|
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.
|
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
|
- **An allow entry for a sender address or domain trusts what the sending
|
||||||
server says.** The filter matches the envelope sender, which is not
|
server says.** The filter matches the envelope sender (only that: a
|
||||||
authenticated: anyone who knows or guesses an allowed address can send as it
|
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
|
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
|
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
|
network instead. Deny entries have no such problem - claiming to be a denied
|
||||||
|
|||||||
Reference in New Issue
Block a user