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>
76 lines
3.8 KiB
Plaintext
76 lines
3.8 KiB
Plaintext
# Mounted into the sidecar as /etc/rspamd/local.d/milter_headers.conf.
|
|
#
|
|
# rspamd writes its verdict into two headers of every message it lets through:
|
|
# the action it chose and the score with the symbols behind it. SelfPost's
|
|
# inbound journal reads them (Inbound > Log shows them next to the message),
|
|
# 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 - 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
|
|
# what a sender wrote: he could send his own verdict. So rspamd signs each
|
|
# message - an HMAC-SHA256 of the message's queue id under a key SelfPost
|
|
# generates and leaves in the directory it shares with this container - and
|
|
# SelfPost reads a verdict only next to a signature it can recompute. The
|
|
# 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
|
|
# 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").
|
|
# src/lua/lua_cryptobox.c, rspamd_lua_hash_create(): "sha256" with a key is
|
|
# HMAC-SHA256 over EVP_sha256(), the key being the bytes given - here the hex
|
|
# text. task:get_queue_id() is the Queue-Id the proxy took from the milter
|
|
# macro "{i}" or "i" (src/libserver/milter.c, rspamd_milter_macro_http(); read
|
|
# back in src/libserver/protocol.c), which Postfix sends at
|
|
# 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
|
|
return function(task, common_meta)
|
|
local remove = { ['X-SelfPost-Filter'] = 0 }
|
|
-- The key is read for every message and kept nowhere: SelfPost may make a
|
|
-- new one (the file was lost, a backup was restored), and a key remembered
|
|
-- here would go on signing what SelfPost no longer accepts until someone
|
|
-- restarted this container. The file is one short line.
|
|
local key
|
|
local f = io.open('/etc/rspamd/selfpost/filter.key', 'r')
|
|
if f then
|
|
key = f:read('*l')
|
|
f:close()
|
|
end
|
|
local qid = task:get_queue_id()
|
|
if not key or key == '' or not qid or qid == '' then
|
|
return nil, {}, remove, {}
|
|
end
|
|
local hash = require "rspamd_cryptobox_hash"
|
|
local sig = hash.create_specific_keyed(key, 'sha256', qid):hex()
|
|
return nil, { ['X-SelfPost-Filter'] = sig }, remove, {}
|
|
end
|
|
EOD;
|
|
}
|