inbound-antispam-panel: the filter signs what it judged
test / test (push) Canceled after 0s

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:
mix
2026-10-12 08:34:29 +03:00
co-authored by Claude Opus 5.5
parent 89774ab847
commit d398f488e2
13 changed files with 340 additions and 52 deletions
+10 -3
View File
@@ -24,9 +24,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
a connection's later messages had no client address; two changes of the a connection's later messages had no client address; two changes of the
filter lists at once could leave the filter's files behind the page; a held filter lists at once could leave the filter's files behind the page; a held
message was logged twice; after an unclean stop of the panel the refusals of message was logged twice; after an unclean stop of the panel the refusals of
its last seconds were logged twice. Recorded as accepted in its last seconds were logged twice; with the spam filter down, a sender's
[docs/security.md](docs/security.md): the filter's verdict in the log is the own verdict headers were recorded as the filter's. Recorded as accepted in
sender's own while the filter is down; an allow entry for a sender address [docs/security.md](docs/security.md): an allow entry for a sender address
trusts an address anyone can claim; the list files are readable by anyone on trusts an address anyone can claim; the list files are readable by anyone on
the host. the host.
- `INBOUND_ANTISPAM_MILTER` and `INBOUND_JOURNAL_MILTER_SOCKET` are checked - `INBOUND_ANTISPAM_MILTER` and `INBOUND_JOURNAL_MILTER_SOCKET` are checked
@@ -91,6 +91,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
relay on. relay on.
- Help: *Inbound log* and *Filter lists*. - Help: *Inbound log* and *Filter lists*.
- New setting `ANTISPAM_DIR` (default `/data/antispam`). - New setting `ANTISPAM_DIR` (default `/data/antispam`).
- **The spam filter signs what it judged.** rspamd adds `X-SelfPost-Filter` to
every message it lets through - an HMAC of the message's queue id under a
key SelfPost creates in `data/antispam/filter.key` - and the inbound log
shows a verdict only next to a signature that fits. Nothing to configure,
but the sidecar needs the new `antispam/rspamd/milter_headers.conf` and a
restart after SelfPost's first start; until then accepted mail is logged
as *selfpost*, with no verdict.
- A full backup carries the inbound log, the filter lists and their files - A full backup carries the inbound log, the filter lists and their files
under `data/antispam/`. It does not carry the `antispam/` directory with the under `data/antispam/`. It does not carry the `antispam/` directory with the
compose fragment and the rspamd configuration - copy that from compose fragment and the rspamd configuration - copy that from
+12 -1
View File
@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/mixeme/selfpost/internal/inbound"
"github.com/mixeme/selfpost/internal/milter" "github.com/mixeme/selfpost/internal/milter"
"github.com/mixeme/selfpost/internal/store" "github.com/mixeme/selfpost/internal/store"
) )
@@ -33,8 +34,18 @@ func serveInboundJournal(ctx context.Context, cfg config, st *store.Store) error
if err != nil { if err != nil {
return err return err
} }
// With a filter attached, the key it signs with. Without the key no
// verdict can be told from a sender's own, so none is read: the journal
// still records the mail, and the log says why it shows no verdicts.
key := ""
if cfg.inboundAntispamMilter != "" {
if key, err = inbound.FilterKey(cfg.antispamDir); err != nil {
log.Printf("inbound-journal-milter: no key for the filter's signature, verdicts will not be recorded: %v", err)
key = ""
}
}
log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket) log.Printf("inbound-journal-milter listening on %s", cfg.inboundJournalSocket)
return milter.ServeInbound(ctx, ln, st, cfg.inboundAntispamMilter != "") return milter.ServeInbound(ctx, ln, st, key)
} }
// listenMilter opens a milter's Unix socket where Postfix can reach it. // listenMilter opens a milter's Unix socket where Postfix can reach it.
+7
View File
@@ -312,6 +312,13 @@ func run() error {
log.Printf("filter lists bootstrap: %v", err) log.Printf("filter lists bootstrap: %v", err)
} }
} }
// The key the filter signs with has to be in the shared directory before
// the filter reads it, whether or not a filter is attached yet.
if cfg.inboundEnabled {
if _, err := inbound.FilterKey(cfg.antispamDir); err != nil {
log.Printf("filter key bootstrap: %v", err)
}
}
if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil { if err := st.EnsureSendLogRetentionDays(cfg.retentionDays); err != nil {
return fmt.Errorf("bootstrap send-log retention: %w", err) return fmt.Errorf("bootstrap send-log retention: %w", err)
+33 -1
View File
@@ -8,4 +8,36 @@
# 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. Without this file the journal still lists accepted mail, with
# no verdict next to it. # no verdict next to it.
use = ["x-spamd-result", "x-rspamd-action"]; #
# 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.
use = ["x-spamd-result", "x-rspamd-action", "selfpost-signature"];
custom {
selfpost-signature = <<EOD
local key
return function(task, common_meta)
local remove = { ['X-SelfPost-Filter'] = 0 }
if not key then
local f = io.open('/etc/rspamd/selfpost/filter.key', 'r')
if f then
key = f:read('*l')
f:close()
end
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;
}
+8 -1
View File
@@ -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 `inbound_spam_log` row per recipient at an inbound domain - a DMARC report
address is not one - and never refuses anything address is not one - and never refuses anything
([internal/milter/inbound.go](../internal/milter/inbound.go)). The headers ([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` Refusals come from `mail.log`: this listener logs as `postfix/inbound`
(`syslog_name`), and the log tailer turns a `reject:` line of (`syslog_name`), and the log tailer turns a `reject:` line of
`postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` / `postfix/inbound/smtpd` (Postfix's own policy) and a `milter-reject` /
+11
View File
@@ -860,6 +860,17 @@ with the message, and the upstream receives them with the mail. Copy the
`antispam/rspamd/` directory next to the fragment; without the file accepted `antispam/rspamd/` directory next to the fragment; without the file accepted
mail is still recorded, with no verdict. mail is still recorded, with no verdict.
The same file makes rspamd **sign** each message, in a third header,
`X-SelfPost-Filter`. The filter is attached so that mail still flows when it
is down, and then a sender could write a verdict of his own into those
headers; SelfPost therefore records a verdict only next to a signature it can
recompute. The key is `data/antispam/filter.key`: SelfPost creates it on its
first start with the inbound relay on, and the sidecar reads it from the
directory it already mounts - there is nothing to configure. If Inbound › Log
shows *selfpost* for mail the filter did judge, the sidecar is running without
this file or started before the key existed: copy `antispam/rspamd/` again and
restart the sidecar.
The same directory holds The same directory holds
[multimap.conf](../deploy/antispam/rspamd/multimap.conf), the rules for the [multimap.conf](../deploy/antispam/rspamd/multimap.conf), the rules for the
**allow and deny lists** kept in the panel. SelfPost does not apply the lists **allow and deny lists** kept in the panel. SelfPost does not apply the lists
+7 -2
View File
@@ -59,7 +59,12 @@ smtpd_milters = { antispam, inbound-journal }
Antispam runs first; inbound-journal runs at end-of-message on the **accept** Antispam runs first; inbound-journal runs at end-of-message on the **accept**
path and reads rspamd-added headers (`X-Rspamd-Action`, and `X-Spamd-Result` path and reads rspamd-added headers (`X-Rspamd-Action`, and `X-Spamd-Result`
for the score and symbols - rspamd has no `X-Rspamd-Score`; the sidecar adds for the score and symbols - rspamd has no `X-Rspamd-Score`; the sidecar adds
both through `deploy/antispam/rspamd/milter_headers.conf`). both through `deploy/antispam/rspamd/milter_headers.conf`). As built they are
read only next to rspamd's signature of the message, `X-SelfPost-Filter`
(owner, 2026-10-12: "А rspamd не может дописывать какой-то заголовок, по
которому мы будем знать, что это заголовки нашего спам фильтра?") - an HMAC
of the queue id, since a fixed mark could be written by a sender as easily as
the verdict itself.
**Antispam rejects** do not reach end-of-message on the journal milter. v1 also **Antispam rejects** do not reach end-of-message on the journal milter. v1 also
tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`** tails `mail.log` for inbound `postfix/smtpd` **`milter-reject`** / **`reject`**
@@ -317,4 +322,4 @@ Target version cut: **`2.1.0`** (MINOR), after `2.0.0`. One commit per step;
- [x] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** (kept up with each step; [security.md](../security.md) follows the security review) - [x] [guide.md](../guide.md), [architecture.md](../architecture.md) — **Composer** (kept up with each step; [security.md](../security.md) follows the security review)
- [x] `go vet`, `go test`, e2e if applicable — **Composer** (on the test server for every step; last on `7192c38`: vet ok, 22 packages ok, e2e ok, which relays, refuses, reads the log page and edits a list through the panel) - [x] `go vet`, `go test`, e2e if applicable — **Composer** (on the test server for every step; last on `7192c38`: vet ok, 22 packages ok, e2e ok, which relays, refuses, reads the log page and edits a list through the panel)
- [x] Technical review of inbound antispam changes — **Opus** (2026-10-12; findings fixed: client address of a connection's later messages, the lists' writer under a lock, the tailer's queue-id memory, hold rows written twice, the cap by time; and, outside the feature, the level-2 limit and client-address list skipped after a connection's first message) - [x] Technical review of inbound antispam changes — **Opus** (2026-10-12; findings fixed: client address of a connection's later messages, the lists' writer under a lock, the tailer's queue-id memory, hold rows written twice, the cap by time; and, outside the feature, the level-2 limit and client-address list skipped after a connection's first message)
- [x] Security review inbound antispam path — **Fable** (2026-10-12; no Critical or High; HELO-forged journal rows and the six-hourly row cap fixed, three accepted risks recorded in [security.md](../security.md)) - [x] Security review inbound antispam path — **Fable** (2026-10-12; no Critical or High; HELO-forged journal rows and the six-hourly row cap fixed; a sender's own verdict while the filter is down closed by the filter's signature, the owner's idea; accepted risks recorded in [security.md](../security.md))
+24 -18
View File
@@ -45,9 +45,12 @@ HELO name could supply the sender, recipient and reply of a journal row and so
make a refusal look like the deny list's (the reply now ends at Postfix's own make a refusal look like the deny list's (the reply now ends at Postfix's own
`; from=<`); the journal's row cap was applied only every six hours, between `; from=<`); the journal's row cap was applied only every six hours, between
which refusals were written at whatever rate a stranger sent recipients (the which refusals were written at whatever rate a stranger sent recipients (the
tailer now cuts to the cap every 500 rows); and three behaviours are recorded tailer now cuts to the cap every 500 rows); and two behaviours are recorded
under Accepted risks (verdict headers while the filter is down, the allow list under Accepted risks (the allow list keyed on an address anyone can claim, the
keyed on an address anyone can claim, the list files readable by anyone). list files readable by anyone). A third it named - with the filter down, a
sender's own verdict headers were recorded as the filter's - was closed the
same day at the owner's suggestion: the filter signs each message and a
verdict is read only next to its signature.
(2) **Accepted risks** — (2) **Accepted risks** —
deliberate departures beyond the mandatory, recorded so the decision is not deliberate departures beyond the mandatory, recorded so the decision is not
lost. lost.
@@ -208,7 +211,16 @@ operator can act on it.
message it lets through. `test/e2e` sends two `MAIL FROM` on one connection. message it lets through. `test/e2e` sends two `MAIL FROM` on one connection.
- **The inbound journal is written from what strangers send.** The inbound - **The inbound journal is written from what strangers send.** The inbound
journal milter is the last on port 25, read-only and attached fail-open; it journal milter is the last on port 25, read-only and attached fail-open; it
keeps of the filter's verdict only what its patterns recognise. Refusals are 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`:
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
signature, or one that does not fit, the row is that of mail nobody judged.
The signature goes on to the upstream with the mail and is of no use there:
it fits one queue id. Refusals are
parsed from `mail.log` from the head of the line, with the reply ending at parsed from `mail.log` from the head of the line, with the reply ending at
Postfix's own `; from=<` so that the HELO name after it - the client's text, Postfix's own `; from=<` so that the HELO name after it - the client's text,
logged as written - cannot stand in for the sender, the recipient or the logged as written - cannot stand in for the sender, the recipient or the
@@ -347,20 +359,14 @@ deferred item from the roadmap.
the journal from recording sends at all. The trigger to revisit is a the journal from recording sends at all. The trigger to revisit is a
requirement for the allow-list to hold as a security boundary — at which requirement for the allow-list to hold as a security boundary — at which
point it must move to the SASL/smtpd layer and fail closed. point it must move to the SASL/smtpd layer and fail closed.
- **With the spam filter down, a sender can write his own verdict into the - **The key the spam filter signs with is readable by anyone on the host.**
inbound journal.** The filter is attached fail-open by default, and the The inbound journal reads the filter's verdict only next to the filter's
inbound journal milter reads its verdict from two headers. Up, rspamd removes signature (see Mail path), and the key for it, `data/antispam/filter.key`,
headers of those names that a sender wrote and adds its own (seen on the is 0644 like the list files beside it: the filter is another user in another
test server: a forged pair was replaced). Down, the sender's pair is all container. Someone with a shell on the host could therefore sign a verdict
there is: the row shows his action and score, a forged `quarantine` shows a of his own - into a monitoring page, on a host where he could do far more.
hold that never happened, and a forged allow symbol counts as a match of an Senders on the internet, whom the signature is for, cannot read it. Trigger
allow entry that fits him in Filter lists. Mail is not affected - the to revisit: the same as for the list files - a shared host.
journal decides nothing - and the page that says the filter is down is
Server › Health. Accepted because the alternative is a live check of the
filter on every message for the sake of a monitoring page. Trigger to
revisit: the journal or the match counts being used to decide something
(alerts, automatic removal of entries), or `INBOUND_ANTISPAM_MILTER_ACTION`
staying `accept` on an installation whose filter is often down.
- **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, which is not
authenticated: anyone who knows or guesses an allowed address can send as it authenticated: anyone who knows or guesses an allowed address can send as it
+44
View File
@@ -1,6 +1,8 @@
package inbound package inbound
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"net/netip" "net/netip"
"os" "os"
@@ -401,3 +403,45 @@ func (l *Lists) Usage() (map[int64]Usage, error) {
} }
return out, nil return out, nil
} }
// FilterKeyFile is the file, beside the list files, that holds the key the
// spam filter signs messages with.
const FilterKeyFile = "filter.key"
// FilterKey returns the key the spam filter signs each message with, creating
// it on first use: 32 random bytes as 64 hex characters on one line, in the
// directory the filter's container mounts.
//
// The filter is attached so that mail flows when it is down. Then nothing
// removes verdict headers a sender wrote himself, and the inbound journal
// would record his verdict as the filter's. So the filter signs each message
// with this key and the journal reads a verdict only next to a signature it
// can recompute (internal/milter, FilterSignature).
//
// The file is readable by anyone on the host, like the list files and for the
// same reason: the filter is another user in another container. What the key
// protects is the truth of a monitoring page, against senders on the internet;
// someone with a shell on the host is not who it is kept from.
func FilterKey(dir string) (string, error) {
path := filepath.Join(dir, FilterKeyFile)
if raw, err := os.ReadFile(path); err == nil {
if key := strings.TrimSpace(string(raw)); len(key) >= 32 && !strings.ContainsAny(key, " \t\r\n") {
return key, nil
}
// Empty or cut short: not a key anyone should sign with. Replaced below.
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("read %s: %w", path, err)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("create %s: %w", dir, err)
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", fmt.Errorf("generate the filter key: %w", err)
}
key := hex.EncodeToString(buf)
if err := atomicfile.Write(path, []byte(key+"\n"), 0o644); err != nil {
return "", err
}
return key, nil
}
+43
View File
@@ -395,3 +395,46 @@ func TestListable(t *testing.T) {
} }
} }
} }
// The key the filter signs with is made once and then read, and is readable by
// the filter, which is another user in another container.
func TestFilterKey(t *testing.T) {
dir := filepath.Join(t.TempDir(), "antispam")
key, err := FilterKey(dir)
if err != nil || len(key) != 64 || strings.Trim(key, "0123456789abcdef") != "" {
t.Fatalf("FilterKey = %q, %v", key, err)
}
again, err := FilterKey(dir)
if err != nil || again != key {
t.Errorf("a second call = %q, %v; want the same key", again, err)
}
raw, err := os.ReadFile(filepath.Join(dir, FilterKeyFile))
if err != nil || string(raw) != key+"\n" {
t.Errorf("file = %q, %v", raw, err)
}
if info, _ := os.Stat(filepath.Join(dir, FilterKeyFile)); os.PathSeparator == '/' && info.Mode().Perm() != 0o644 {
t.Errorf("mode = %o", info.Mode().Perm())
}
// A file that is not a key is replaced, not signed with.
if err := os.WriteFile(filepath.Join(dir, FilterKeyFile), []byte("short\n"), 0o644); err != nil {
t.Fatal(err)
}
fresh, err := FilterKey(dir)
if err != nil || len(fresh) != 64 || fresh == key {
t.Errorf("after a damaged file: %q, %v", fresh, err)
}
// The lists leave it alone.
st, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := NewLists(st, dir).Sync(); err != nil {
t.Fatal(err)
}
if kept, _ := FilterKey(dir); kept != fresh {
t.Errorf("the key changed when the lists were written")
}
}
+59 -22
View File
@@ -2,6 +2,9 @@ package milter
import ( import (
"context" "context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"log" "log"
"net" "net"
"net/textproto" "net/textproto"
@@ -33,10 +36,12 @@ type InboundStore interface {
type inboundSession struct { type inboundSession struct {
milter.NoOpMilter milter.NoOpMilter
rec InboundStore rec InboundStore
// filter says whether an external filter is attached in front of this // key is what the filter signs each message with (FilterSignature), or
// milter. Without one the verdict headers below can only have been written // empty when no filter is attached. A verdict is read only next to a
// by the sender, and are not read. // signature that fits: the filter is attached so that mail flows when it is
filter bool // down, and then the verdict headers of a message are whatever its sender
// wrote.
key string
// clientIP is taken at Connect and again from {client_addr} with every // clientIP is taken at Connect and again from {client_addr} with every
// MAIL FROM: a session the library made after a non-continue reply never // MAIL FROM: a session the library made after a non-continue reply never
@@ -49,6 +54,7 @@ type inboundSession struct {
action string // X-Rspamd-Action action string // X-Rspamd-Action
result string // X-Spamd-Result result string // X-Spamd-Result
seen bool // any verdict header of the filter seen bool // any verdict header of the filter
sig string // X-SelfPost-Filter
} }
func (s *inboundSession) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) { func (s *inboundSession) Connect(host, family string, port uint16, addr net.IP, m *milter.Modifier) (milter.Response, error) {
@@ -73,10 +79,12 @@ func (s *inboundSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Respons
return milter.RespContinue, nil return milter.RespContinue, nil
} }
// Header keeps the first Subject and the filter's verdict. Postfix hands the // Header keeps the first Subject, the filter's verdict and its signature.
// message to its milters one after another at end-of-message, each seeing what // Postfix hands the message to its milters one after another at
// the one before added, which is how the filter's headers arrive here. The // end-of-message, each seeing what the one before added, which is how the
// filter replaces headers of these names, so the last one read is its own. // filter's headers arrive here. The filter replaces headers of these names, so
// the last one read is its own - when it is up. Whether it was is decided at
// end-of-message, by the signature.
func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) { func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
switch textproto.CanonicalMIMEHeaderKey(name) { switch textproto.CanonicalMIMEHeaderKey(name) {
case "Subject": case "Subject":
@@ -84,21 +92,50 @@ func (s *inboundSession) Header(name, value string, m *milter.Modifier) (milter.
s.subject = mailhdr.DecodeSubject(value) s.subject = mailhdr.DecodeSubject(value)
} }
case "X-Rspamd-Action": case "X-Rspamd-Action":
if s.filter { s.action, s.seen = value, true
s.action, s.seen = value, true
}
case "X-Spamd-Result": case "X-Spamd-Result":
if s.filter { s.result, s.seen = value, true
s.result, s.seen = value, true case signatureHeader:
} s.sig = strings.TrimSpace(value)
} }
return milter.RespContinue, nil return milter.RespContinue, nil
} }
// SignatureHeader is the header the filter signs a message in
// (deploy/antispam/rspamd/milter_headers.conf writes it).
const SignatureHeader = "X-SelfPost-Filter"
var signatureHeader = textproto.CanonicalMIMEHeaderKey(SignatureHeader)
// FilterSignature is what the filter writes into SignatureHeader for a message:
// the HMAC-SHA256 of its queue id under the key SelfPost shares with the
// filter, in hex. The key is used as the text it is stored as. A signature
// fits one queue id, so the header going on to the upstream with the mail
// gives nothing to whoever reads it there.
func FilterSignature(key, queueID string) string {
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(queueID))
return hex.EncodeToString(mac.Sum(nil))
}
// signed reports whether the message carries the filter's signature for its
// queue id. No key, no queue id or no signature is "no".
func (s *inboundSession) signed(queueID string) bool {
if s.key == "" || queueID == "" || s.sig == "" {
return false
}
return hmac.Equal([]byte(strings.ToLower(s.sig)), []byte(FilterSignature(s.key, queueID)))
}
// Body fires at end-of-message: the message is about to be queued for its // Body fires at end-of-message: the message is about to be queued for its
// upstream. "Continue" lets it through as "accept" would, and keeps this // upstream. "Continue" lets it through as "accept" would, and keeps this
// session for the connection's next message. // session for the connection's next message.
func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) { func (s *inboundSession) Body(m *milter.Modifier) (milter.Response, error) {
// The queue id is known only now, and with it whether the verdict headers
// read on the way here are the filter's.
if !s.signed(macro(m, "i")) {
s.seen = false
}
s.record() s.record()
s.reset() s.reset()
return milter.RespContinue, nil return milter.RespContinue, nil
@@ -111,7 +148,7 @@ func (s *inboundSession) Abort(m *milter.Modifier) error {
func (s *inboundSession) reset() { func (s *inboundSession) reset() {
s.from, s.rcpts, s.subject = "", nil, "" s.from, s.rcpts, s.subject = "", nil, ""
s.action, s.result, s.seen = "", "", false s.action, s.result, s.seen, s.sig = "", "", false, ""
} }
// record writes one row per recipient at an inbound domain. Other recipients // record writes one row per recipient at an inbound domain. Other recipients
@@ -169,9 +206,8 @@ var (
// rspamdVerdict reduces the filter's headers to an action and a one-line // rspamdVerdict reduces the filter's headers to an action and a one-line
// summary: the action, the score against the threshold, and the first few // summary: the action, the score against the threshold, and the first few
// symbols that carried weight. It reads only what it recognises - the headers // symbols that carried weight. It reads only what it recognises - the headers
// are the filter's when it is up, but the sender's own when it is not (it is // are read only next to the filter's signature, and even so nothing of them is
// attached fail-open), so nothing of them is kept verbatim except through // kept verbatim except through these patterns.
// these patterns.
func rspamdVerdict(actionHeader, resultHeader string) (action, detail string) { func rspamdVerdict(actionHeader, resultHeader string) (action, detail string) {
action = strings.ToLower(strings.TrimSpace(actionHeader)) action = strings.ToLower(strings.TrimSpace(actionHeader))
for _, r := range action { for _, r := range action {
@@ -229,11 +265,12 @@ func rspamdVerdict(actionHeader, resultHeader string) (action, detail string) {
} }
// ServeInbound runs the inbound journal milter on ln until ctx is cancelled. // ServeInbound runs the inbound journal milter on ln until ctx is cancelled.
// filter says whether an external filter is attached in front of it // key is what the external filter in front of it signs messages with, or
// (INBOUND_ANTISPAM_MILTER). It returns nil on a clean shutdown. // empty when none is attached (INBOUND_ANTISPAM_MILTER): no verdict is then
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, filter bool) error { // read at all. It returns nil on a clean shutdown.
func ServeInbound(ctx context.Context, ln net.Listener, rec InboundStore, key string) error {
srv := &milter.Server{ srv := &milter.Server{
NewMilter: func() milter.Milter { return &inboundSession{rec: rec, filter: filter} }, NewMilter: func() milter.Milter { return &inboundSession{rec: rec, key: key} },
Actions: 0, // read-only Actions: 0, // read-only
Protocol: milter.OptNoBody, // headers and end-of-message, not the body Protocol: milter.OptNoBody, // headers and end-of-message, not the body
} }
+75 -4
View File
@@ -60,9 +60,18 @@ func message(t *testing.T, s *inboundSession, from string, rcpts []string, heade
return resp return resp
} }
// testKey is the key the tests' filter signs with; the messages they replay
// have the queue id ABC123.
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
var testSig = [2]string{SignatureHeader, FilterSignature(testKey, "ABC123")}
func inboundSessionFor(t *testing.T, rec InboundStore, filter bool) *inboundSession { func inboundSessionFor(t *testing.T, rec InboundStore, filter bool) *inboundSession {
t.Helper() t.Helper()
s := &inboundSession{rec: rec, filter: filter} s := &inboundSession{rec: rec}
if filter {
s.key = testKey
}
if _, err := s.Connect("mx.example.net", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil)); err != nil { if _, err := s.Connect("mx.example.net", "tcp4", 0, net.ParseIP("203.0.113.7"), mods(nil)); err != nil {
t.Fatalf("Connect: %v", err) t.Fatalf("Connect: %v", err)
} }
@@ -116,6 +125,7 @@ func TestInboundReadsTheFiltersVerdict(t *testing.T) {
{"Subject", "Cheap pills"}, {"Subject", "Cheap pills"},
{"X-Rspamd-Action", "add header"}, {"X-Rspamd-Action", "add header"},
{"X-Spamd-Result", spamdResult}, {"X-Spamd-Result", spamdResult},
testSig,
}) })
if len(rec.rows) != 1 { if len(rec.rows) != 1 {
t.Fatalf("rows = %+v", rec.rows) t.Fatalf("rows = %+v", rec.rows)
@@ -133,7 +143,7 @@ func TestInboundQuarantineAction(t *testing.T) {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}} rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := inboundSessionFor(t, rec, true) s := inboundSessionFor(t, rec, true)
message(t, s, "sender@example.net", []string{"a@in.example.com"}, message(t, s, "sender@example.net", []string{"a@in.example.com"},
[][2]string{{"x-rspamd-action", "quarantine"}}) [][2]string{{"x-rspamd-action", "quarantine"}, {"x-selfpost-filter", strings.ToUpper(testSig[1])}})
if len(rec.rows) != 1 || rec.rows[0].Decision != store.SpamDecisionQuarantine || rec.rows[0].Detail != "quarantine" { if len(rec.rows) != 1 || rec.rows[0].Decision != store.SpamDecisionQuarantine || rec.rows[0].Detail != "quarantine" {
t.Errorf("rows = %+v", rec.rows) t.Errorf("rows = %+v", rec.rows)
} }
@@ -180,7 +190,7 @@ func TestInboundResetsBetweenMessages(t *testing.T) {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}} rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := inboundSessionFor(t, rec, true) s := inboundSessionFor(t, rec, true)
message(t, s, "one@example.net", []string{"a@in.example.com"}, [][2]string{ message(t, s, "one@example.net", []string{"a@in.example.com"}, [][2]string{
{"Subject", "First"}, {"X-Rspamd-Action", "add header"}, {"Subject", "First"}, {"X-Rspamd-Action", "add header"}, testSig,
}) })
message(t, s, "two@example.net", []string{"b@in.example.com"}, nil) message(t, s, "two@example.net", []string{"b@in.example.com"}, nil)
if len(rec.rows) != 2 { if len(rec.rows) != 2 {
@@ -259,7 +269,7 @@ func TestInboundClientAddressFromTheMailMacro(t *testing.T) {
// cost it its session. // cost it its session.
func TestInboundOnlyEverContinues(t *testing.T) { func TestInboundOnlyEverContinues(t *testing.T) {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}} rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := &inboundSession{rec: rec, filter: true} s := &inboundSession{rec: rec, key: testKey}
check := func(stage string, resp milter.Response, err error) { check := func(stage string, resp milter.Response, err error) {
t.Helper() t.Helper()
if err != nil || resp != milter.RespContinue { if err != nil || resp != milter.RespContinue {
@@ -277,3 +287,64 @@ func TestInboundOnlyEverContinues(t *testing.T) {
resp, err = s.Body(mods(nil)) resp, err = s.Body(mods(nil))
check("end of message", resp, err) check("end of message", resp, err)
} }
// The filter is attached so that mail flows when it is down, and then the
// verdict headers of a message are its sender's own. A verdict is recorded only
// next to the filter's signature for this message's queue id.
func TestInboundReadsAVerdictOnlyWithTheFiltersSignature(t *testing.T) {
verdict := [][2]string{{"X-Rspamd-Action", "quarantine"}, {"X-Spamd-Result", spamdResult}}
for name, c := range map[string]struct {
extra [][2]string
signed bool
}{
"signed": {[][2]string{testSig}, true},
"no signature": {nil, false},
"a made-up signature": {[][2]string{{SignatureHeader, strings.Repeat("ab", 32)}}, false},
"another message's signature": {[][2]string{{SignatureHeader, FilterSignature(testKey, "OTHER1")}}, false},
"a signature under another key": {[][2]string{{SignatureHeader, FilterSignature(strings.Repeat("f", 64), "ABC123")}}, false},
"the sender's first, then the real": {[][2]string{{SignatureHeader, "forged"}, testSig}, true},
} {
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := inboundSessionFor(t, rec, true)
message(t, s, "sender@example.net", []string{"a@in.example.com"}, append(append([][2]string{}, verdict...), c.extra...))
if len(rec.rows) != 1 {
t.Fatalf("%s: rows = %+v", name, rec.rows)
}
got := rec.rows[0]
if c.signed && (got.Engine != store.SpamEngineRspamd || got.Decision != store.SpamDecisionQuarantine) {
t.Errorf("%s: a signed verdict was not read: %+v", name, got)
}
if !c.signed && (got.Engine != store.SpamEngineSelfPost || got.Decision != store.SpamDecisionAccept || got.Detail != "") {
t.Errorf("%s: an unsigned verdict was recorded: %+v", name, got)
}
}
// A signature is of no use to the next message on the connection.
rec := &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s := inboundSessionFor(t, rec, true)
message(t, s, "one@example.net", []string{"a@in.example.com"}, append(append([][2]string{}, verdict...), testSig))
message(t, s, "two@example.net", []string{"a@in.example.com"}, verdict)
if len(rec.rows) != 2 || rec.rows[1].Engine != store.SpamEngineSelfPost {
t.Errorf("rows = %+v", rec.rows)
}
// With no queue id nothing can be checked, so nothing is trusted.
rec = &fakeInbound{domains: map[string]bool{"in.example.com": true}}
s = inboundSessionFor(t, rec, true)
_, _ = s.MailFrom("sender@example.net", mods(nil))
_, _ = s.RcptTo("a@in.example.com", mods(nil))
_, _ = s.Header("X-Rspamd-Action", "reject", mods(nil))
_, _ = s.Header(SignatureHeader, FilterSignature(testKey, ""), mods(nil))
_, _ = s.Body(mods(nil))
if len(rec.rows) != 1 || rec.rows[0].Engine != store.SpamEngineSelfPost {
t.Errorf("no queue id: rows = %+v", rec.rows)
}
}
// What rspamd 3.11 wrote for this key and queue id in a throwaway container
// (dev trial, 2026-10-12): the two ends must compute the same thing.
func TestFilterSignatureIsWhatTheFilterWrites(t *testing.T) {
if got := FilterSignature(testKey, "4A1B2C3D5E"); got != "2aa1ca6dd1545d3f16c8c0c29ba59bfc28c050224ac2c5c953bd2617f73b6de2" {
t.Errorf("FilterSignature = %s", got)
}
}
+7
View File
@@ -283,6 +283,13 @@ func testInboundRelay(t *testing.T, sc *scenario) {
} }
// The inbound journal milter saw it go through, and the Inbound log shows it. // The inbound journal milter saw it go through, and the Inbound log shows it.
// Beside the maps, the key the filter signs each message with: there from
// the first start with the relay on, whether or not a filter is attached
// yet, one line of 64 hex characters, readable by the filter.
if key, err := h.execIn("selfpost", "stat", "-c", "%a %s", "/data/antispam/filter.key"); err != nil || strings.TrimSpace(key) != "644 65" {
t.Fatalf("/data/antispam/filter.key: %q, %v; want mode 644 and 65 bytes", key, err)
}
// No filter is attached on the stand: the row is an accept by nobody, so its // No filter is attached on the stand: the row is an accept by nobody, so its
// reason is the engine alone. // reason is the engine alone.
logRows := func(want string) []string { logRows := func(want string) []string {