Closes what the security review left as an accepted risk: with the
spam filter down - it is attached fail-open - nothing removes verdict
headers a sender wrote himself, and the inbound journal recorded his
verdict as the filter's. The owner asked whether rspamd could add a
header that proves the others are ours (2026-10-12) and agreed to a
signature: a fixed mark could be written by a sender as easily as the
verdict.
- inbound.FilterKey: a key of 32 random bytes, 64 hex characters, in
/data/antispam/filter.key, made on the first start with the relay
on. 0644 like the list files beside it: the filter is another user
in another container.
- deploy/antispam/rspamd/milter_headers.conf: a custom routine reads
the key through the directory the sidecar already mounts and adds
X-SelfPost-Filter, the HMAC-SHA256 of the message's queue id, hex;
a header of that name from the sender is removed.
- internal/milter/inbound.go: the verdict headers are kept as they
are read, and at end-of-message - when the queue id is known - they
count only if the signature fits (FilterSignature, hmac.Equal). No
key, no queue id, no signature or a wrong one: the row is that of
mail nobody judged (accept, selfpost, no detail).
The signature goes on to the upstream with the mail and is of no use
there: it fits one queue id.
Tried in a throwaway rspamd 3.11 container on the test server before
the Go side was written: the routine loads, signs
(2aa1ca6d... for key 0123...cdef and queue id 4A1B2C3D5E, the value
python's hmac gives and TestFilterSignatureIsWhatTheFilterWrites
pins), removes a sender's header of the same name, and adds nothing
without a queue id.
Tests: signed, unsigned, made-up, another message's, another key's,
the sender's followed by the real one, the next message on the
connection, no queue id; the key made once, read after, replaced when
damaged, left alone by the lists. e2e: filter.key is there, 644, 65
bytes.
docs/security.md: the accepted risk is replaced by a narrower one (the
key is readable on the host); Mail path describes the signature.
Checked on the test server, uncommitted tree: go vet ./... ok,
go test ./... ok (22 packages), make e2e ok.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,14 @@ smtp/inet` removes Debian's stock smtpd). Outbound delivery still uses the
|
||||
`inbound_spam_log` row per recipient at an inbound domain - a DMARC report
|
||||
address is not one - and never refuses anything
|
||||
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers
|
||||
are read only while a filter is attached; refused mail never reaches it.
|
||||
are read only while a filter is attached, and only next to the filter's
|
||||
signature: a third header, `X-SelfPost-Filter`, the HMAC-SHA256 of the
|
||||
message's queue id under the key in `/data/antispam/filter.key`
|
||||
(`inbound.FilterKey` makes it on first start; rspamd reads it through the
|
||||
mounted directory and signs in a custom routine of `milter_headers.conf`).
|
||||
With the filter down - it is attached fail-open - a sender's own verdict
|
||||
headers carry no signature and the row is that of mail nobody judged.
|
||||
Refused mail never reaches this milter.
|
||||
Refusals come from `mail.log`: this listener logs as `postfix/inbound`
|
||||
(`syslog_name`), and the log tailer turns a `reject:` line of
|
||||
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
|
||||
|
||||
Reference in New Issue
Block a user