Files
selfpost/internal/inbound/lists.go
T
mixandClaude Opus 5.5 c731a98a0f
test / test (push) Canceled after 0s
inbound-antispam-panel: findings of the technical and security reviews
Steps 11 and 12 of docs/plans/inbound-antispam-panel.md: 12/12. Two
read-only reviews of d65d632..HEAD by different models (Opus,
technical; Fable, security), each given the code and questions, not
conclusions. Nothing Critical or High in the feature; the High the
technical review found outside it is the commit before this one.

Log tailer (internal/logtail/inbound.go):
  - the reply of a refusal ends at the first "; from=<", Postfix's own,
    and the HELO name after it is whatever is left. Taking the longest
    reply let a HELO name that spelt "; from=<..> to=<..> proto=.."
    supply the row's sender, recipient and reply - and so make a
    refusal read as the deny list's;
  - an address with a quote, a bracket or a space is not kept: a quoted
    sender could otherwise supply the recipient;
  - the journal is cut to its row cap every 500 rows written, not only
    every six hours;
  - a queue id is forgotten when the message is queued or the id is
    opened by another listener, and a stale place in the queue of
    arrivals no longer drops a live id;
  - milter-hold lines are not read: the held message reaches the
    journal milter, which writes its row - it was written twice.
Lists (internal/inbound/lists.go): a change of the table and the
rewrite of the files are one step under a mutex; a failed rewrite puts
a removed entry back and rewrites after taking a new one out.
Store: the row cap removes the oldest by time, as the page orders them.
Page: on a sheet a refusal's text is a Reply whatever it starts with.

Help and the guide say that an allow entry for a sender address trusts
an address anyone can claim (design commit before this one).

docs/security.md: the two reviews; the roles table gains Inbound > Log
and Filter lists; Mail path describes the milters, the tailer and the
lists; four accepted risks - the filter's verdict while it is down, an
allow entry by sender address, the list files' mode, log rows held by
domain name. The plan's stale lines (size and rate refusals, who reads
the journal) are corrected.

Left as found, told to the owner: the journal's rows after an unclean
stop may be written twice (the tailer saves its place every five
seconds); a "Deny sender" link is offered for an address the form then
refuses; 1.2.3.4 and 1.2.3.4/32 are two entries.

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>
2026-10-12 07:51:50 +03:00

391 lines
13 KiB
Go

package inbound
import (
"fmt"
"net/netip"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/mixeme/selfpost/internal/atomicfile"
"github.com/mixeme/selfpost/internal/configsafe"
"github.com/mixeme/selfpost/internal/store"
)
// ListStore is the slice of the store the filter lists need. *store.Store
// satisfies it.
type ListStore interface {
AddSpamListEntry(list, kind, value, note string) (store.SpamListEntry, error)
ListSpamListEntries() ([]store.SpamListEntry, error)
DeleteSpamListEntry(id int64) error
ListSpamListHits(marks ...string) ([]store.SpamListHit, error)
}
// Lists owns the allow and deny lists of the external spam filter: the rows in
// SQLite and the map files the filter reads. SelfPost does not consult the
// lists itself - it writes them out, six files in one directory, one entry a
// line, and the filter (rspamd, deploy/antispam/rspamd/multimap.conf) rereads
// a file when it changes. Which list wins when both match is the filter's
// configuration: deny.
type Lists struct {
store ListStore
dir string
// mu makes a change of the table and the rewrite of the files one step.
// Two requests at once could otherwise write the files in the other order
// than they changed the table - the first to read the entries being the
// last to write them - and leave the filter with an entry the page no
// longer lists. One Lists serves the panel, so a mutex is enough.
mu sync.Mutex
}
// NewLists builds the filter lists over the store and the map directory
// (/data/antispam).
func NewLists(st ListStore, dir string) *Lists {
return &Lists{store: st, dir: dir}
}
// noteMaxRunes caps the operator's comment on an entry.
const noteMaxRunes = 200
// mapFiles names the file of every (list, kind). An IP map and a sender map
// are different things to the filter, and a domain entry is matched against
// another part of the sender than an address, so each has its own file.
var mapFiles = map[[2]string]string{
{store.SpamListAllow, store.SpamEntryIP}: "allow_ip.map",
{store.SpamListAllow, store.SpamEntryAddress}: "allow_from.map",
{store.SpamListAllow, store.SpamEntryDomain}: "allow_domain.map",
{store.SpamListDeny, store.SpamEntryIP}: "deny_ip.map",
{store.SpamListDeny, store.SpamEntryAddress}: "deny_from.map",
{store.SpamListDeny, store.SpamEntryDomain}: "deny_domain.map",
}
// Entries returns every entry of both lists.
func (l *Lists) Entries() ([]store.SpamListEntry, error) {
return l.store.ListSpamListEntries()
}
// Add validates an entry, stores it normalised and rewrites the maps. A
// refusal is a *ValidationError; an entry already on that list is
// store.ErrSpamListEntryExists.
func (l *Lists) Add(list, kind, rawValue, rawNote string) (store.SpamListEntry, error) {
if list != store.SpamListAllow && list != store.SpamListDeny {
return store.SpamListEntry{}, invalid("choose the allow or the deny list")
}
value, err := normalizeListValue(kind, rawValue)
if err != nil {
return store.SpamListEntry{}, err
}
note, err := normalizeNote(rawNote)
if err != nil {
return store.SpamListEntry{}, err
}
l.mu.Lock()
defer l.mu.Unlock()
e, err := l.store.AddSpamListEntry(list, kind, value, note)
if err != nil {
return store.SpamListEntry{}, err
}
if err := l.sync(); err != nil {
// An entry the filter never got is worse than no entry: the page
// would show a rule that does nothing. The files are written again
// after the row is taken back, since some of the six may already
// hold it; if that fails too, the next change or start puts it right.
_ = l.store.DeleteSpamListEntry(e.ID)
_ = l.sync()
return store.SpamListEntry{}, err
}
return e, nil
}
// Delete removes an entry and rewrites the maps. If the maps cannot be
// written the entry is put back: an entry that is gone from the page and
// still in the filter's file could not even be removed a second time.
func (l *Lists) Delete(id int64) error {
l.mu.Lock()
defer l.mu.Unlock()
entries, err := l.store.ListSpamListEntries()
if err != nil {
return err
}
var gone *store.SpamListEntry
for i := range entries {
if entries[i].ID == id {
gone = &entries[i]
}
}
if err := l.store.DeleteSpamListEntry(id); err != nil {
return err
}
if err := l.sync(); err != nil {
if gone != nil {
_, _ = l.store.AddSpamListEntry(gone.List, gone.Kind, gone.Value, gone.Note)
}
return err
}
return nil
}
// Sync writes all six map files from SQLite, each atomically, so the filter
// reads a whole old file or a whole new one. Every file is written even when
// empty: the filter's configuration names all six.
func (l *Lists) Sync() error {
l.mu.Lock()
defer l.mu.Unlock()
return l.sync()
}
// sync is Sync for a caller that holds mu.
func (l *Lists) sync() error {
entries, err := l.store.ListSpamListEntries()
if err != nil {
return err
}
lines := make(map[string][]string, len(mapFiles))
for _, name := range mapFiles {
lines[name] = nil
}
for _, e := range entries {
name, ok := mapFiles[[2]string{e.List, e.Kind}]
if !ok {
return fmt.Errorf("filter list entry %d: unknown list %q or kind %q", e.ID, e.List, e.Kind)
}
// Values were validated on the way in; this is the writer's own guard
// against a row that got into the table some other way. In a map file a
// newline ends the entry, whitespace starts its value and # a comment.
if err := configsafe.Token("filter list entry", e.Value, " \t\r\n#"); err != nil {
return err
}
lines[name] = append(lines[name], e.Value)
}
// The filter runs under another user in another container: the directory
// and the files are readable by anyone. They hold addresses, not secrets.
if err := os.MkdirAll(l.dir, 0o755); err != nil {
return fmt.Errorf("create %s: %w", l.dir, err)
}
for name, vals := range lines {
sort.Strings(vals)
var b strings.Builder
b.WriteString("# Written by SelfPost (Inbound > Filter lists). Do not edit: the next change in the panel replaces this file.\n")
for _, v := range vals {
b.WriteString(v)
b.WriteByte('\n')
}
if err := atomicfile.Write(filepath.Join(l.dir, name), []byte(b.String()), 0o644); err != nil {
return err
}
}
return nil
}
// LastWritten is when the maps were last written: the newest modification
// time of the six files, the zero time while none exists (the filter lists
// have never been touched, or the directory is gone).
func (l *Lists) LastWritten() time.Time {
var newest time.Time
for _, name := range mapFiles {
if fi, err := os.Stat(filepath.Join(l.dir, name)); err == nil && fi.ModTime().After(newest) {
newest = fi.ModTime()
}
}
return newest
}
// normalizeListValue validates one entry and returns the form it is stored and
// written in: lower case for an address or a domain, the canonical text of an
// address or a network for an IP.
func normalizeListValue(kind, raw string) (string, error) {
v := strings.ToLower(strings.TrimSpace(raw))
switch kind {
case store.SpamEntryAddress:
if v == "" {
return "", invalid("sender address is required")
}
if len(v) > 254 {
return "", invalid("sender address must be at most 254 characters")
}
at := strings.LastIndexByte(v, '@')
if at <= 0 || at >= len(v)-1 {
return "", invalid("%q is not a valid email address", v)
}
local, domain := v[:at], v[at+1:]
if local[0] == '.' || local[len(local)-1] == '.' || len(local) > 64 {
return "", invalid("%q: invalid local part", v)
}
for i := 0; i < len(local); i++ {
c := local[i]
lower := c >= 'a' && c <= 'z'
digit := c >= '0' && c <= '9'
// '=' for the rewritten senders of forwarders (SRS0=…).
if !lower && !digit && !strings.ContainsRune("._+-=", rune(c)) {
return "", invalid("%q: local part contains invalid characters", v)
}
}
if err := checkDomain(domain); err != nil {
return "", err
}
return v, nil
case store.SpamEntryDomain:
// "@example.com" is how a domain is often written in such a list.
v = strings.TrimPrefix(v, "@")
if err := checkDomain(v); err != nil {
return "", err
}
return v, nil
case store.SpamEntryIP:
if v == "" {
return "", invalid("address or network is required")
}
if strings.Contains(v, "/") {
p, err := netip.ParsePrefix(v)
if err != nil {
return "", invalid("%q is not a network in CIDR form (e.g. 192.0.2.0/24)", v)
}
if p.Addr().Is4In6() {
return "", invalid("%q: write an IPv4 network as IPv4 (e.g. 192.0.2.0/24)", v)
}
if p.Bits() == 0 {
return "", invalid("%q is every address there is", v)
}
return p.Masked().String(), nil
}
a, err := netip.ParseAddr(v)
if err != nil || a.Zone() != "" {
return "", invalid("%q is not an IP address or a network in CIDR form", v)
}
return a.Unmap().String(), nil
default:
return "", invalid("choose what the entry is: a sender address, a sender domain or a client address")
}
}
// normalizeNote trims the operator's comment and refuses what a single line of
// text cannot be.
func normalizeNote(raw string) (string, error) {
note := strings.TrimSpace(raw)
if utf8.RuneCountInString(note) > noteMaxRunes {
return "", invalid("note must be at most %d characters", noteMaxRunes)
}
for _, r := range note {
if r < 0x20 || r == 0x7f || r == utf8.RuneError {
return "", invalid("note must be one line of plain text")
}
}
return note, nil
}
// What a list leaves in the journal when it decides a message - the two ends
// of deploy/antispam/rspamd/multimap.conf this package depends on. A deny rule
// refuses with a message, which the log tailer files as the reply of a
// rejected row. An allow rule adds a symbol named after the kind of entry,
// which the journal milter keeps in the verdict of an accepted row.
const (
denyReplyMark = "this server's deny list"
allowMark = "SELFPOST_ALLOW_"
)
var allowSymbol = map[string]string{
store.SpamEntryIP: "SELFPOST_ALLOW_IP(",
store.SpamEntryAddress: "SELFPOST_ALLOW_FROM(",
store.SpamEntryDomain: "SELFPOST_ALLOW_DOMAIN(",
}
// Usage is how often an entry decided a message, as far back as the journal
// reaches, and when it last did. The zero value is an entry that never did.
type Usage struct {
Hits int
Last time.Time
}
// Usage counts, for every entry, the journal rows it decided. Nothing is
// stored for this: the journal already says which list decided a message and
// who the message was from, and an entry is matched against that the way the
// filter matches it - an address whole, a domain as the sender's domain, an
// address or network against the client's. So the count reaches back as far
// as the journal does (its retention, or less when it is full), and a row
// counts for every entry of the deciding list that fits it: the journal does
// not say which of two fitting entries the filter met first.
//
// A counter kept with each entry would not depend on the journal; the plan
// (docs/plans/inbound-antispam-panel.md, Filter list usage) says when that is
// worth a migration.
func (l *Lists) Usage() (map[int64]Usage, error) {
entries, err := l.store.ListSpamListEntries()
if err != nil {
return nil, err
}
out := make(map[int64]Usage, len(entries))
if len(entries) == 0 {
return out, nil
}
hits, err := l.store.ListSpamListHits(denyReplyMark, allowMark)
if err != nil {
return nil, err
}
type matcher struct {
id int64
list string
kind string
value string
prefix netip.Prefix
}
matchers := make([]matcher, 0, len(entries))
for _, e := range entries {
m := matcher{id: e.ID, list: e.List, kind: e.Kind, value: e.Value}
if e.Kind == store.SpamEntryIP {
if p, err := netip.ParsePrefix(e.Value); err == nil {
m.prefix = p
} else if a, err := netip.ParseAddr(e.Value); err == nil {
m.prefix = netip.PrefixFrom(a, a.BitLen())
} else {
continue // not an entry this package wrote
}
}
matchers = append(matchers, m)
}
for _, h := range hits {
from := strings.ToLower(h.From)
domain := ""
if i := strings.LastIndexByte(from, '@'); i >= 0 {
domain = from[i+1:]
}
client, clientErr := netip.ParseAddr(h.ClientIP)
client = client.Unmap()
denied := h.Decision != store.SpamDecisionAccept && strings.Contains(h.Detail, denyReplyMark)
for _, m := range matchers {
switch m.list {
case store.SpamListDeny:
if !denied {
continue
}
case store.SpamListAllow:
if h.Decision != store.SpamDecisionAccept || !strings.Contains(h.Detail, allowSymbol[m.kind]) {
continue
}
}
fits := false
switch m.kind {
case store.SpamEntryAddress:
fits = from == m.value
case store.SpamEntryDomain:
fits = domain == m.value
case store.SpamEntryIP:
fits = clientErr == nil && m.prefix.Contains(client)
}
if !fits {
continue
}
u := out[m.id]
u.Hits++
if h.CreatedAt.After(u.Last) {
u.Last = h.CreatedAt
}
out[m.id] = u
}
}
return out, nil
}