sender check: the sender map read and looked up as Postfix does it
Postfix 3.7.11 takes the owners of the first key mail_addr_find() finds -
the address, then without its extension, then @domain, each tried only
when the one before is absent (src/global/mail_addr_find.c,
mail_addr_find_opt()) - and compares the login with them by
strcasecmp_utf8() (src/smtpd/smtpd_check.c,
reject_auth_sender_login_mismatch()). The check accepted a From: address
when any of the three keys named the application: an application holding
@domain could write an address listed for another application of the
domain, which Postfix refuses it in the envelope, and have it signed
(assumptions B-PS1).
The table is now read as dict_thash_open() reads it: continuation lines,
owners split on commas and blanks (B-PS4), the key ending at the first
blank outside quotes, keys folded; a key given twice makes Postfix refuse
the whole table (no DICT_FLAG_DUP_* flag), and the check then defers too.
The writer folds keys, so two cases of one address cannot be two lines.
e2e on the stand (2026-10-14, make e2e, exit 0): the wildcard application
is refused billing@ - listed for app-list - in From: (550 5.7.1), and in
the envelope by Postfix at RCPT ("553 5.7.1 <billing@...>: Sender address
rejected: not owned by user"). go vet and go test ./... pass on the
server (23 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+117
-44
@@ -31,10 +31,9 @@ import (
|
||||
// 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.
|
||||
// one table, read the way Postfix reads it and looked up the way Postfix looks
|
||||
// it up (Bindings). The send-log row of a refusal is written by another
|
||||
// goroutine and is not waited for.
|
||||
//
|
||||
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
||||
// the SASL login is compared with an owner by strcasecmp_utf8();
|
||||
@@ -78,8 +77,9 @@ func (f *tableFile) changed() (lines []string, again bool, err error) {
|
||||
type Bindings struct {
|
||||
mu sync.Mutex
|
||||
file tableFile
|
||||
logins map[string]map[string]bool // sender -> logins
|
||||
logins map[string]map[string]bool // sender -> logins, both in lower case
|
||||
domainOf map[string]string // login -> the domain it sends as
|
||||
broken error // why Postfix would not use the table
|
||||
}
|
||||
|
||||
// NewBindings reads the file at path on first use.
|
||||
@@ -90,63 +90,136 @@ func NewBindings(path string) *Bindings {
|
||||
// fresh makes the table match the file.
|
||||
func (b *Bindings) fresh() error {
|
||||
lines, again, err := b.file.changed()
|
||||
if err != nil || !again {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logins := make(map[string]map[string]bool)
|
||||
domains := make(map[string]string)
|
||||
if again {
|
||||
b.logins, b.domainOf, b.broken = parseSenderMap(lines)
|
||||
}
|
||||
return b.broken
|
||||
}
|
||||
|
||||
// parseSenderMap reads the lines of a sender_login_maps file as Postfix 3.7.11
|
||||
// reads a texthash table (src/util/dict_thash.c, dict_thash_open(), over
|
||||
// src/util/readlline.c, readllines()):
|
||||
// - a line that starts with a blank continues the one before; an empty
|
||||
// line, or one whose first non-blank is "#", is skipped;
|
||||
// - the key ends at the first blank outside double quotes, a backslash
|
||||
// escaping the next character; a line with an unbalanced quote, or
|
||||
// without a key or a value, is ignored (with a warning, in Postfix);
|
||||
// - the key is folded to lower case (DICT_FLAG_FOLD_FIX, which
|
||||
// src/smtpd/smtpd_check.c, pre_jail_init(), gives
|
||||
// smtpd_sender_login_maps);
|
||||
// - a key given twice makes the whole table unusable: no DICT_FLAG_DUP_*
|
||||
// flag is given, so dict_thash_open() returns a surrogate that fails
|
||||
// every lookup, and smtpd answers 451 (reject_server_error()). The check
|
||||
// then defers mail as well.
|
||||
//
|
||||
// The owners are split on commas and blanks (smtpd_check.c,
|
||||
// reject_auth_sender_login_mismatch(), mystrtok(CHARS_COMMA_SP)).
|
||||
func parseSenderMap(lines []string) (logins map[string]map[string]bool, domains map[string]string, broken error) {
|
||||
var logical []string
|
||||
for _, line := range lines {
|
||||
// texthash: <key><whitespace><value>, the value a comma-separated
|
||||
// list of logins (internal/postfix renderSenderLoginMaps).
|
||||
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key
|
||||
// ends at the first blank; src/util/readlline.c skips a line whose
|
||||
// first non-blank is "#"; src/util/dict_ht.c folds the key to lower
|
||||
// case (DICT_FLAG_FOLD_FIX, set for smtpd_sender_login_maps in
|
||||
// src/smtpd/smtpd_check.c).
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
trimmed := strings.TrimLeft(line, " \t")
|
||||
switch {
|
||||
case trimmed == "" || trimmed[0] == '#':
|
||||
continue
|
||||
case len(trimmed) < len(line) && len(logical) > 0:
|
||||
logical[len(logical)-1] += line
|
||||
default:
|
||||
logical = append(logical, trimmed)
|
||||
}
|
||||
}
|
||||
logins = make(map[string]map[string]bool)
|
||||
domains = make(map[string]string)
|
||||
for _, line := range logical {
|
||||
key, value, ok := splitTableLine(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sender := strings.ToLower(fields[0])
|
||||
set := logins[sender]
|
||||
if set == nil {
|
||||
set = make(map[string]bool)
|
||||
logins[sender] = set
|
||||
sender := strings.ToLower(key)
|
||||
if _, dup := logins[sender]; dup {
|
||||
return nil, nil, fmt.Errorf("sender map: duplicate entry %q, which Postfix refuses the whole table for", sender)
|
||||
}
|
||||
for _, l := range strings.Split(strings.Join(fields[1:], ""), ",") {
|
||||
if l != "" {
|
||||
set[l] = true
|
||||
if _, known := domains[l]; !known {
|
||||
domains[l] = domainOf(sender)
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
logins[sender] = set
|
||||
for _, l := range strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||||
}) {
|
||||
l = strings.ToLower(l)
|
||||
set[l] = true
|
||||
if _, known := domains[l]; !known {
|
||||
domains[l] = domainOf(sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.logins, b.domainOf = logins, domains
|
||||
return nil
|
||||
return logins, domains, 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.
|
||||
// splitTableLine splits a logical texthash line into its key and its value,
|
||||
// as dict_thash_open() does: the key ends at the first blank outside double
|
||||
// quotes (a backslash escaping the character after it), and the value is the
|
||||
// rest without the blanks around it. ok is false where Postfix ignores the
|
||||
// line: an unbalanced quote, no key, or no value.
|
||||
func splitTableLine(line string) (key, value string, ok bool) {
|
||||
inQuotes := false
|
||||
i := 0
|
||||
for ; i < len(line); i++ {
|
||||
c := line[i]
|
||||
if c == '\\' {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inQuotes = !inQuotes
|
||||
continue
|
||||
}
|
||||
if (c == ' ' || c == '\t') && !inQuotes {
|
||||
break
|
||||
}
|
||||
}
|
||||
if inQuotes {
|
||||
return "", "", false
|
||||
}
|
||||
if i > len(line) {
|
||||
i = len(line)
|
||||
}
|
||||
key, value = line[:i], strings.Trim(line[i:], " \t")
|
||||
return key, value, key != "" && value != ""
|
||||
}
|
||||
|
||||
// Allowed reports whether login may send as addr - the lookup Postfix makes
|
||||
// for the envelope sender. Postfix 3.7.11, src/smtpd/smtpd_check.c,
|
||||
// reject_auth_sender_login_mismatch(), takes the owners of the first key
|
||||
// mail_addr_find() finds (src/global/mail_addr_find.c, mail_addr_find_opt()
|
||||
// with MA_FIND_DEFAULT): the address, then the address without its
|
||||
// "+extension", then "@domain" - each tried only when the one before is not
|
||||
// in the table - and compares the login with them by strcasecmp_utf8(). So
|
||||
// an address bound to one application is not open to another that holds its
|
||||
// whole domain (assumptions B-PS1). The bare local part MA_FIND_DEFAULT also
|
||||
// tries for a local domain is never a key here: every key the panel writes
|
||||
// has an "@" (internal/postfix renderSenderLoginMaps).
|
||||
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
|
||||
login, addr = strings.ToLower(login), strings.ToLower(addr)
|
||||
keys := []string{addr}
|
||||
// recipient_delimiter is "+" (build/postfix-config.sh), so an
|
||||
// application listed as invoices@ may also send as invoices+march@.
|
||||
if bare := withoutExtension(addr); bare != addr {
|
||||
keys = append(keys, bare)
|
||||
}
|
||||
// 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 != "" {
|
||||
keys = append(keys, "@"+d)
|
||||
}
|
||||
if d := domainOf(addr); d != "" && b.logins["@"+d][login] {
|
||||
return true, nil
|
||||
for _, k := range keys {
|
||||
if owners, found := b.logins[k]; found {
|
||||
return owners[login], nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
@@ -182,7 +255,7 @@ func (b *Bindings) DomainOf(login string) string {
|
||||
if b.fresh() != nil {
|
||||
return ""
|
||||
}
|
||||
return b.domainOf[login]
|
||||
return b.domainOf[strings.ToLower(login)]
|
||||
}
|
||||
|
||||
// Signers is OpenDKIM's SigningTable as the check reads it: the names this
|
||||
|
||||
@@ -216,6 +216,80 @@ func TestBindingsFollowTheFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The owners of the first key Postfix finds decide, not any key that lists
|
||||
// the login: an address bound to one application is not open to another that
|
||||
// holds its whole domain (Postfix 3.7.11, smtpd_check.c,
|
||||
// reject_auth_sender_login_mismatch(), over mail_addr_find(); assumptions
|
||||
// B-PS1). Composed by hand in internal/postfix's format.
|
||||
func TestBindingsFirstKeyWins(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, `@example.org shop
|
||||
billing@example.org billing
|
||||
invoices+march@example.org other
|
||||
`))
|
||||
for _, c := range []struct {
|
||||
login, addr string
|
||||
want bool
|
||||
}{
|
||||
{"shop", "a@example.org", true},
|
||||
{"billing", "billing@example.org", true},
|
||||
{"shop", "billing@example.org", false}, // the address's own key decides
|
||||
{"billing", "billing+q3@example.org", true}, // without the extension
|
||||
{"shop", "billing+q3@example.org", false}, // ... before @domain
|
||||
{"shop", "invoices+march@example.org", false}, // the full key first
|
||||
{"other", "invoices+march@example.org", true},
|
||||
{"shop", "invoices+april@example.org", true}, // no key without "+": @domain
|
||||
{"billing", "a@example.org", false},
|
||||
{"SHOP", "A@Example.ORG", true}, // strcasecmp_utf8, a folded key
|
||||
} {
|
||||
if ok, err := bind.Allowed(c.login, c.addr); err != nil || ok != c.want {
|
||||
t.Errorf("Allowed(%q, %q) = %v, %v; want %v", c.login, c.addr, ok, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The table is read the way dict_thash_open() reads it: continuation lines,
|
||||
// owners split on blanks as well as commas, comments, lines Postfix ignores.
|
||||
// Composed by hand.
|
||||
func TestBindingsReadAsPostfixReadsThem(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, "# a comment\n"+
|
||||
"@shop.example.org shop,\n"+
|
||||
" shop2 shop3\n"+
|
||||
"\n"+
|
||||
"\"a b\"@x.example.org quoted\n"+
|
||||
"nokey-value-only\n"+
|
||||
"\"open@x.example.org broken\n"+
|
||||
"@news.example.net\tnews\r\n"))
|
||||
for _, c := range []struct {
|
||||
login, addr string
|
||||
want bool
|
||||
}{
|
||||
{"shop", "a@shop.example.org", true},
|
||||
{"shop2", "a@shop.example.org", true},
|
||||
{"shop3", "a@shop.example.org", true},
|
||||
{"news", "a@news.example.net", true},
|
||||
{"quoted", "\"a b\"@x.example.org", true},
|
||||
{"broken", "\"open@x.example.org", false},
|
||||
} {
|
||||
if ok, err := bind.Allowed(c.login, c.addr); err != nil || ok != c.want {
|
||||
t.Errorf("Allowed(%q, %q) = %v, %v; want %v", c.login, c.addr, ok, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key given twice - in any case, since Postfix folds keys - makes Postfix
|
||||
// refuse the whole table (dict_thash_open(), no DICT_FLAG_DUP_* flag), and
|
||||
// the check defers as smtpd does. Composed by hand.
|
||||
func TestBindingsDuplicateKeyBreaksTheTable(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, "@shop.example.org shop\nA@Shop.example.org other\n"))
|
||||
if _, err := bind.Allowed("shop", "b@shop.example.org"); err != nil {
|
||||
t.Fatalf("two different keys: %v", err)
|
||||
}
|
||||
bind = NewBindings(writeMaps(t, "@shop.example.org shop\n@SHOP.example.org other\n"))
|
||||
if ok, err := bind.Allowed("shop", "b@shop.example.org"); err == nil || ok {
|
||||
t.Fatalf("duplicate key: %v, %v; want an error", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without the signing table the second look cannot be taken, and a line it
|
||||
// would have to judge is deferred; a line that names nobody else is not held
|
||||
// up by it.
|
||||
|
||||
@@ -100,10 +100,17 @@ func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
|
||||
if err := assertMapSafe(b.Address, b.Login); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, seen := byAddr[b.Address]; !seen {
|
||||
order = append(order, b.Address)
|
||||
// One line per key as Postfix folds it: smtpd opens the map with
|
||||
// DICT_FLAG_FOLD_FIX and no DICT_FLAG_DUP_* flag, so two keys that
|
||||
// differ only in case would make the whole table unusable (Postfix
|
||||
// 3.7.11, src/util/dict_thash.c, dict_thash_open()). The panel stores
|
||||
// addresses in lower case already; this keeps the file safe if a row
|
||||
// ever is not.
|
||||
addr := strings.ToLower(b.Address)
|
||||
if _, seen := byAddr[addr]; !seen {
|
||||
order = append(order, addr)
|
||||
}
|
||||
byAddr[b.Address] = appendUnique(byAddr[b.Address], b.Login)
|
||||
byAddr[addr] = appendUnique(byAddr[addr], b.Login)
|
||||
}
|
||||
sort.Strings(order)
|
||||
|
||||
|
||||
@@ -50,6 +50,22 @@ func TestRenderSenderLoginMapsDedupesLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keys differing only in case are one key to Postfix, which folds them, and
|
||||
// two of them would make it refuse the whole table (dict_thash_open() with no
|
||||
// DICT_FLAG_DUP_* flag): they are written as one line.
|
||||
func TestRenderSenderLoginMapsFoldsKeys(t *testing.T) {
|
||||
got, err := renderSenderLoginMaps([]Binding{
|
||||
{"Billing@Example.org", "billing"},
|
||||
{"billing@example.org", "billing2"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "billing@example.org billing,billing2\n" {
|
||||
t.Errorf("map = %q, want one folded key", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssertMapSafeRejectsInjection(t *testing.T) {
|
||||
bad := []struct{ addr, login string }{
|
||||
{"@exa mple.com", "log"},
|
||||
|
||||
Reference in New Issue
Block a user