Technical review (Opus) and security review (Fable) of 5be109b..5154bba;
reports in the session's scratchpad. Nothing above low from security; one
bug and three risks from the technical review. Migration 0009 is not
deployed, so it was extended.
- Bug: two refusals of one second swapped their NOQUEUE lines (each line
went to the newest lineless row). AttachRefusalLine now matches the
client address too (send_log.client_ip, written by the journal milter -
Postfix's line names the client and the sender, never the login: the
sasl_username part of the pattern never matched and could be fed by HELO,
security F1/F2), takes the nearest row in time and the oldest of equals,
and a line already on a row goes on no other (security F6).
- Waiting lines and recoveries lived only in memory while the saved offset
moved past them: a stop within 30 s lost them. The tracker now never saves
an offset past the oldest waiting item in its file (tracker.hold,
refusalLines.oldest) - for a recovery, the client= line that names its
login - so they are read again; the "once" guards make that harmless.
- A recovered row whose login the tailer did not see was nobody's: its
domain_id falls back to the envelope domain's.
- The fingerprint's file identity is the inode alone: the device number can
change across a host reboot and make the same log look rotated.
- A refused row carries its queue id; UpdateStatus's "newest row" now skips
refused rows, so a later message with that id does not flip it to sent.
- The alive mark: its age is read first and kept from then on, so asking
supervisord for 30 s cannot make it stale; the entrypoint removes it with
the sockets - a container restart is not the check's downtime, and a mark
from before would have counted the whole outage as "≈".
- Security F3: the sender check prints no ";" of the client's in the From:
text of its reply, and the tailer reads the envelope after the first ";"
- Postfix's own text.
- A refused row with a queue id and no stored lines says so (it was told the
mail.log text of 2.2.0); the fact's em dash is the house hyphen.
- Docs: architecture (client address, no login), guide § Health (warns on
either count), help topics log and sender-check (design 1ca87f2).
- e2e: refusals_show_postfixs_line wants the refusal's own sender in the
line; refusal_recovered_from_the_log waits past the tailer's 30 s and
checks that a refusal the panel wrote got no recovered twin.
Not changed, written down: review 9 (mail.log.1 read to its end at that
moment; postlogd may still write to it until the reload), 10 (a log under
512 bytes has no fingerprint, so places fall back to time briefly after a
rotation), 6 (a 451 at MAIL FROM of another milter reads the same - the
client and sender must match too now), security F5/F12 (info). Review 12:
the refused and closed-by-the-panel variants of out-message were shown to
the owner as pictures of the real templates, not drawn in out-message.html:
the guard ties every drawn screen to a page of the engine, and a variant
has the page's outline.
Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (refusal_recovered_from_the_log 70 s).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
518 lines
16 KiB
Go
518 lines
16 KiB
Go
package milter
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net"
|
|
"net/mail"
|
|
"net/textproto"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/emersion/go-milter"
|
|
|
|
"github.com/mixeme/selfpost/internal/mailhdr"
|
|
"github.com/mixeme/selfpost/internal/store"
|
|
)
|
|
|
|
// The sender check: a message's From: line is held against the application
|
|
// that sends it, under the rule Postfix holds the envelope sender to
|
|
// (docs/plans/from-header-binding.md). OpenDKIM chooses the signing key by
|
|
// that line, so without the check an application could have mail signed as
|
|
// another domain of the server, or as the server itself.
|
|
//
|
|
// Unlike the journal milter this one is attached strictly
|
|
// (default_action=tempfail in build/postfix-config.sh): while it does not
|
|
// answer, Postfix defers mail. It therefore depends on as little as it can.
|
|
// It reads no database: what an application may send as comes from the file
|
|
// of sender bindings Postfix itself reads for the envelope, so the two go by
|
|
// one table, by one lookup: the address, the address without its
|
|
// "+extension", the domain. (Postfix compares logins without regard to case;
|
|
// the check compares them as written, which is how the panel writes them.) The send-log row of a refusal is written by another goroutine and
|
|
// is not waited for.
|
|
|
|
// tableFile is a table of the mail path that the panel rewrites and the check
|
|
// reads: it is read again when it has changed. An unreadable file is an error
|
|
// and the check then defers mail - a table from before would let a deleted
|
|
// application go on sending, and no table would refuse everyone for good.
|
|
type tableFile struct {
|
|
path string
|
|
modTime time.Time
|
|
size int64
|
|
loaded bool
|
|
}
|
|
|
|
// changed returns the file's lines when they have to be read (again), and
|
|
// nil when what was read last still stands.
|
|
func (f *tableFile) changed() (lines []string, again bool, err error) {
|
|
fi, err := os.Stat(f.path)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if f.loaded && fi.ModTime().Equal(f.modTime) && fi.Size() == f.size {
|
|
return nil, false, nil
|
|
}
|
|
raw, err := os.ReadFile(f.path)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
f.modTime, f.size, f.loaded = fi.ModTime(), fi.Size(), true
|
|
return strings.Split(string(raw), "\n"), true, nil
|
|
}
|
|
|
|
// Bindings is Postfix's sender_login_maps file as the check reads it: each
|
|
// sender - an address, or "@domain" for any address of a domain - with the
|
|
// logins that may use it.
|
|
type Bindings struct {
|
|
mu sync.Mutex
|
|
file tableFile
|
|
logins map[string]map[string]bool // sender -> logins
|
|
domainOf map[string]string // login -> the domain it sends as
|
|
}
|
|
|
|
// NewBindings reads the file at path on first use.
|
|
func NewBindings(path string) *Bindings {
|
|
return &Bindings{file: tableFile{path: path}}
|
|
}
|
|
|
|
// fresh makes the table match the file.
|
|
func (b *Bindings) fresh() error {
|
|
lines, again, err := b.file.changed()
|
|
if err != nil || !again {
|
|
return err
|
|
}
|
|
logins := make(map[string]map[string]bool)
|
|
domains := make(map[string]string)
|
|
for _, line := range lines {
|
|
// texthash: <key><whitespace><value>, the value a comma-separated
|
|
// list of logins (internal/postfix renderSenderLoginMaps).
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
|
continue
|
|
}
|
|
sender := strings.ToLower(fields[0])
|
|
set := logins[sender]
|
|
if set == nil {
|
|
set = make(map[string]bool)
|
|
logins[sender] = set
|
|
}
|
|
for _, l := range strings.Split(strings.Join(fields[1:], ""), ",") {
|
|
if l != "" {
|
|
set[l] = true
|
|
if _, known := domains[l]; !known {
|
|
domains[l] = domainOf(sender)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
b.logins, b.domainOf = logins, domains
|
|
return nil
|
|
}
|
|
|
|
// Allowed reports whether login may send as addr: the address itself is bound
|
|
// to it, or its whole domain is. This is the lookup Postfix makes for the
|
|
// envelope sender.
|
|
func (b *Bindings) Allowed(login, addr string) (bool, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if err := b.fresh(); err != nil {
|
|
return false, err
|
|
}
|
|
addr = strings.ToLower(addr)
|
|
if b.logins[addr][login] {
|
|
return true, nil
|
|
}
|
|
// Postfix's second try: the address without its extension, so that an
|
|
// application listed as invoices@ may also send as invoices+march@
|
|
// (recipient_delimiter in build/postfix-config.sh).
|
|
if bare := withoutExtension(addr); bare != addr && b.logins[bare][login] {
|
|
return true, nil
|
|
}
|
|
if d := domainOf(addr); d != "" && b.logins["@"+d][login] {
|
|
return true, nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// extensionDelimiter is Postfix's recipient_delimiter, which
|
|
// build/postfix-config.sh sets to this.
|
|
const extensionDelimiter = "+"
|
|
|
|
// withoutExtension drops the extension of an address's local part: what
|
|
// follows its first delimiter. "user+tag@example.org" is "user@example.org";
|
|
// an address whose local part begins with the delimiter has no extension.
|
|
func withoutExtension(addr string) string {
|
|
at := strings.LastIndexByte(addr, '@')
|
|
if at < 0 {
|
|
return addr
|
|
}
|
|
if i := strings.Index(addr[:at], extensionDelimiter); i > 0 {
|
|
return addr[:i] + addr[at:]
|
|
}
|
|
return addr
|
|
}
|
|
|
|
// DomainOf returns the domain login sends as, from the first of its bindings
|
|
// in the file's order; "" for a login that has none. An application belongs
|
|
// to one domain, so all of its bindings name the same one.
|
|
func (b *Bindings) DomainOf(login string) string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if b.fresh() != nil {
|
|
return ""
|
|
}
|
|
return b.domainOf[login]
|
|
}
|
|
|
|
// Signers is OpenDKIM's SigningTable as the check reads it: the names this
|
|
// server signs for - every sending domain, with applications or without, and
|
|
// the server's own name.
|
|
type Signers struct {
|
|
mu sync.Mutex
|
|
file tableFile
|
|
names map[string]bool
|
|
}
|
|
|
|
// NewSigners reads the file at path on first use.
|
|
func NewSigners(path string) *Signers {
|
|
return &Signers{file: tableFile{path: path}}
|
|
}
|
|
|
|
// Signs reports whether mail whose From: is at domain gets a signature.
|
|
func (s *Signers) Signs(domain string) (bool, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
lines, again, err := s.file.changed()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if again {
|
|
names := make(map[string]bool)
|
|
for _, line := range lines {
|
|
// "*@<name> <key name>" (internal/domain renderTables).
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
|
continue
|
|
}
|
|
if d := domainOf(fields[0]); d != "" {
|
|
names[d] = true
|
|
}
|
|
}
|
|
s.names = names
|
|
}
|
|
return s.names[strings.ToLower(domain)], nil
|
|
}
|
|
|
|
// Refusals is where the check leaves the send-log rows of what it refused.
|
|
// *store.Store satisfies it.
|
|
type Refusals interface {
|
|
InsertRejected(e store.SendLogEntry) error
|
|
}
|
|
|
|
// senderSession is one connection of the sender check.
|
|
type senderSession struct {
|
|
milter.NoOpMilter
|
|
bind *Bindings
|
|
signers *Signers
|
|
refused chan<- store.SendLogEntry
|
|
|
|
login string
|
|
from string
|
|
rcpts []string
|
|
subject string
|
|
froms []string // the value of every From: field of the message
|
|
}
|
|
|
|
// MailFrom starts a message and forgets the one before it: a connection may
|
|
// carry several, and after a temporary failure the library hands the
|
|
// connection a new session.
|
|
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
|
s.from = cleanAddress(from)
|
|
s.login = macro(m, "auth_authen")
|
|
s.rcpts, s.subject, s.froms = nil, "", nil
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
func (s *senderSession) RcptTo(rcpt string, m *milter.Modifier) (milter.Response, error) {
|
|
s.rcpts = append(s.rcpts, cleanAddress(rcpt))
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.Response, error) {
|
|
// The name is trimmed: the obsolete syntax allows blanks before the colon.
|
|
switch textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)) {
|
|
case "From":
|
|
s.froms = append(s.froms, value)
|
|
case "Subject":
|
|
if s.subject == "" {
|
|
s.subject = mailhdr.DecodeSubject(value)
|
|
}
|
|
}
|
|
return milter.RespContinue, nil
|
|
}
|
|
|
|
// Headers is the end of the headers, where the message is judged.
|
|
func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) {
|
|
shown, why, err := s.judge()
|
|
if err != nil {
|
|
// Strict: what cannot be checked is not let through.
|
|
log.Printf("sender-check: %v - deferring mail of %q", err, s.login)
|
|
return milter.RespTempFail, nil
|
|
}
|
|
if why == "" {
|
|
return milter.RespContinue, nil
|
|
}
|
|
log.Printf("sender-check: application %q refused: %s (From: %s)", s.login, why, shown)
|
|
// cleanup has given the message a queue id by the end of its headers
|
|
// (milter_end_of_header_macros has {i}); Postfix's lines about it - the
|
|
// refusal among them - are found by it on the message's page.
|
|
s.record(shown, macro(m, "i"))
|
|
// A reply code of its own, so that the application is told what is wrong
|
|
// and, the refusal being permanent, does not send the message again.
|
|
return milter.NewResponseStr('y', "550 5.7.1 From: "+shown+" - "+why), nil
|
|
}
|
|
|
|
func (s *senderSession) Abort(m *milter.Modifier) error {
|
|
s.rcpts, s.subject, s.froms = nil, "", nil
|
|
return nil
|
|
}
|
|
|
|
// judge decides about the message at hand. why is empty when it may pass;
|
|
// otherwise it says what is wrong, and shown is the From: line in a form that
|
|
// is safe to print - both go into the reply to the client.
|
|
func (s *senderSession) judge() (shown, why string, err error) {
|
|
if s.login == "" {
|
|
// Not reachable while the submission ports demand authentication; a
|
|
// message nobody answers for is not signed either way.
|
|
return "(none)", "no authenticated application", nil
|
|
}
|
|
switch len(s.froms) {
|
|
case 0:
|
|
return "(none)", "the message has no From: line", nil
|
|
case 1:
|
|
default:
|
|
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
|
|
}
|
|
// Postfix hands a milter a header as it came, folded lines and all; the
|
|
// address parser reads one line.
|
|
raw := unfold(s.froms[0])
|
|
shown = printable(raw)
|
|
list, perr := fromParser.ParseList(raw)
|
|
if perr != nil || len(list) != 1 || list[0].Address == "" {
|
|
return shown, "it must be one address", nil
|
|
}
|
|
addr := strings.ToLower(list[0].Address)
|
|
shown = printable(addr)
|
|
ok, err := s.bind.Allowed(s.login, addr)
|
|
if err != nil {
|
|
return shown, "", fmt.Errorf("sender bindings: %w", err)
|
|
}
|
|
if !ok {
|
|
return shown, "not an address this application sends as", nil
|
|
}
|
|
// A cruder second look. OpenDKIM finds the address in this line with a
|
|
// parser of its own, and the key it signs with follows what that parser
|
|
// finds. Wherever the two could read the line differently - a display
|
|
// name, a comment, a quoted string - the other address would have to be
|
|
// spelled out in it, so no other name this server signs for may appear.
|
|
//
|
|
// The display name is looked at once more as a reader will see it: a mail
|
|
// program decodes an encoded word, and "=?utf-8?q?director=40example.org?="
|
|
// shows as an address of another sender though no "@" is written in it.
|
|
own := domainOf(addr)
|
|
for _, d := range append(mentionedDomains(raw), mentionedDomains(list[0].Name)...) {
|
|
if d == own {
|
|
continue
|
|
}
|
|
signs, err := s.signers.Signs(d)
|
|
if err != nil {
|
|
return shown, "", fmt.Errorf("signing table: %w", err)
|
|
}
|
|
if signs {
|
|
// Which one is not said: the reply would confirm to whoever
|
|
// tries names that another tenant sends from this server (audit
|
|
// 2026-10-12, L8). The line as written is in the reply and on
|
|
// the refused message's page.
|
|
return printable(raw), "it names another sender of this server", nil
|
|
}
|
|
}
|
|
// And one that needs to know nothing of OpenDKIM's parser: outside quoted
|
|
// strings the line has the one "@" of its address. A second one - in a
|
|
// comment, in an unquoted name - is where two parsers could part ways.
|
|
if strings.Count(unquoted(raw), "@") != 1 {
|
|
return printable(raw), "outside quotes it must have one @, the address's own", nil
|
|
}
|
|
return shown, "", nil
|
|
}
|
|
|
|
// record leaves a row for the Outbound log. It never waits: a database that
|
|
// is slow must not make the check slow. One row for the message, not one for
|
|
// each recipient as an accepted message has: nothing was sent to anybody, and
|
|
// a refused message with a thousand recipients would otherwise be a thousand
|
|
// rows at the price of one.
|
|
func (s *senderSession) record(shown, queueID string) {
|
|
if s.refused == nil {
|
|
return
|
|
}
|
|
to := ""
|
|
if len(s.rcpts) > 0 {
|
|
to = s.rcpts[0]
|
|
}
|
|
if more := len(s.rcpts) - 1; more > 0 {
|
|
to += fmt.Sprintf(" and %d more", more)
|
|
}
|
|
e := store.SendLogEntry{
|
|
QueueID: queueID,
|
|
Domain: s.domain(),
|
|
AppLogin: s.login,
|
|
From: s.from,
|
|
To: to,
|
|
Subject: s.subject,
|
|
Reason: store.ReasonFrom,
|
|
HeaderFrom: shown,
|
|
}
|
|
select {
|
|
case s.refused <- e:
|
|
default:
|
|
log.Printf("sender-check: the refusal of %s -> %s is not in the log: too many are waiting to be written", s.from, to)
|
|
}
|
|
}
|
|
|
|
// fromParser reads the From: line. A display name encoded in a charset Go
|
|
// does not convert (windows-1251, koi8-r, ...) is still a display name: its
|
|
// bytes are passed through unread, since only the address is judged.
|
|
var fromParser = mail.AddressParser{WordDecoder: &mime.WordDecoder{
|
|
CharsetReader: func(charset string, input io.Reader) (io.Reader, error) { return input, nil },
|
|
}}
|
|
|
|
// unfold joins the lines of a folded header value (RFC 5322 2.2.3): a line
|
|
// break is dropped, the white space that follows it stays.
|
|
func unfold(v string) string {
|
|
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
|
|
}
|
|
|
|
// unquoted returns v without its quoted strings, a backslash inside one
|
|
// escaping the next character.
|
|
func unquoted(v string) string {
|
|
var sb strings.Builder
|
|
in := false
|
|
for i := 0; i < len(v); i++ {
|
|
switch c := v[i]; {
|
|
case in && c == '\\':
|
|
i++
|
|
case c == '"':
|
|
in = !in
|
|
case !in:
|
|
sb.WriteByte(c)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// domain is the sending domain of the refused message for its row in the
|
|
// log: the envelope sender's, which Postfix has held against the login - or,
|
|
// for the null sender of a bounce an application writes itself, which Postfix
|
|
// holds against nobody, the domain the login is bound to. Without it the row
|
|
// would belong to no domain and its administrator would never see it.
|
|
func (s *senderSession) domain() string {
|
|
if d := domainOf(s.from); d != "" {
|
|
return d
|
|
}
|
|
return s.bind.DomainOf(s.login)
|
|
}
|
|
|
|
// mentionedDomains returns, lower-cased, what follows every "@" of v as far
|
|
// as it reads as a host name.
|
|
func mentionedDomains(v string) []string {
|
|
var out []string
|
|
for i := 0; i < len(v); i++ {
|
|
if v[i] != '@' {
|
|
continue
|
|
}
|
|
j := i + 1
|
|
for j < len(v) && isHostByte(v[j]) {
|
|
j++
|
|
}
|
|
if d := strings.Trim(strings.ToLower(v[i+1:j]), ".-"); d != "" {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func isHostByte(c byte) bool {
|
|
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '-'
|
|
}
|
|
|
|
// printable reduces what a client wrote to printable ASCII without the
|
|
// characters that mean something in an SMTP reply or a log line, and cuts it:
|
|
// it is said back to the client, logged and stored.
|
|
func printable(v string) string {
|
|
const max = 120
|
|
var sb strings.Builder
|
|
for _, r := range v {
|
|
if sb.Len() >= max {
|
|
sb.WriteString("...")
|
|
break
|
|
}
|
|
switch {
|
|
case r == '%' || r < 0x20 || r > 0x7e:
|
|
sb.WriteByte('?')
|
|
case r == ';':
|
|
// Postfix logs the reply and then "; from=<...> to=<...>" of
|
|
// its own; a ";" of the client's would let its text pass for
|
|
// Postfix's where the log tailer reads that line.
|
|
sb.WriteByte(',')
|
|
default:
|
|
sb.WriteRune(r)
|
|
}
|
|
}
|
|
return strings.TrimSpace(sb.String())
|
|
}
|
|
|
|
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
|
|
// nil: refusals are then not logged.
|
|
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
|
|
var refused chan store.SendLogEntry
|
|
if rec != nil {
|
|
refused = make(chan store.SendLogEntry, 256)
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case e := <-refused:
|
|
if err := rec.InsertRejected(e); err != nil {
|
|
log.Printf("sender-check: record refused %s: %v", e.From, err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
srv := &milter.Server{
|
|
NewMilter: func() milter.Milter {
|
|
return &senderSession{bind: bind, signers: signers, refused: refused}
|
|
},
|
|
Actions: 0, // read-only
|
|
Protocol: milter.OptNoBody, // the headers decide
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = srv.Close()
|
|
}()
|
|
|
|
if err := srv.Serve(ln); err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|