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:
@@ -17,6 +17,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version
|
|||||||
both exceptions off; tried against rspamd 3.11.1 before and after
|
both exceptions off; tried against rspamd 3.11.1 before and after
|
||||||
(audit 2026-10-12, stage 2c; assumptions C-R7). Copy the new
|
(audit 2026-10-12, stage 2c; assumptions C-R7). Copy the new
|
||||||
`antispam/rspamd/` files next to your compose fragment.
|
`antispam/rspamd/` files next to your compose fragment.
|
||||||
|
- **An address listed for one application could be put in `From:` by
|
||||||
|
another of its domain.** The sender check accepted a `From:` address when
|
||||||
|
any of its keys in the sender map - the address, the address without its
|
||||||
|
extension, the domain - named the application; Postfix takes the first
|
||||||
|
key it finds and asks only its owners. An application set to any address
|
||||||
|
of a domain could therefore write in `From:` an address listed for
|
||||||
|
another application of that domain - which Postfix refuses it in the
|
||||||
|
envelope - and have it signed. The check now reads the map as Postfix
|
||||||
|
does: the first key found decides, logins are compared without regard to
|
||||||
|
case, owners may be separated by blanks or continue on the next line, and
|
||||||
|
a key written twice makes the check defer, as it makes Postfix refuse the
|
||||||
|
whole table (assumptions B-PS1, B-PS4).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -242,10 +242,16 @@ server itself.
|
|||||||
its domain may write any address of that domain, and one with a list only
|
its domain may write any address of that domain, and one with a list only
|
||||||
the listed ones; everything else is refused - another registered domain,
|
the listed ones; everything else is refused - another registered domain,
|
||||||
the server's name, a domain the server knows nothing of. One table for
|
the server's name, a domain the server knows nothing of. One table for
|
||||||
both senders and one lookup: the address, the address without its
|
both senders, read as Postfix reads a texthash table and looked up as
|
||||||
`+extension` (`recipient_delimiter`, which `build/postfix-config.sh` sets
|
Postfix looks it up: the address, the address without its `+extension`
|
||||||
for that reason), then the domain. Postfix compares logins without regard
|
(`recipient_delimiter`, which `build/postfix-config.sh` sets for that
|
||||||
to case and the check as written, which is how the panel writes them.
|
reason), then `@domain` - and the **first of these keys in the table
|
||||||
|
decides**, its owners alone: an address listed for one application is not
|
||||||
|
open to another that holds the whole domain. Logins are compared without
|
||||||
|
regard to case, as Postfix compares them (Postfix 3.7.11,
|
||||||
|
`src/smtpd/smtpd_check.c`, `reject_auth_sender_login_mismatch()`, over
|
||||||
|
`src/global/mail_addr_find.c`, `mail_addr_find_opt()`). A key written
|
||||||
|
twice makes Postfix refuse the whole table, and the check defers then too.
|
||||||
- **How the header is read.** Exactly one `From:` field (none, or more than
|
- **How the header is read.** Exactly one `From:` field (none, or more than
|
||||||
one, is refused), parsed as an address list that must hold exactly one
|
one, is refused), parsed as an address list that must hold exactly one
|
||||||
address. Then a cruder second look at the raw value: OpenDKIM finds the
|
address. Then a cruder second look at the raw value: OpenDKIM finds the
|
||||||
|
|||||||
@@ -1122,6 +1122,10 @@ An address with an extension counts as the address: an application listed as
|
|||||||
`invoices@…` may also send as `invoices+march@…`, in the envelope and in
|
`invoices@…` may also send as `invoices+march@…`, in the envelope and in
|
||||||
`From:` alike.
|
`From:` alike.
|
||||||
|
|
||||||
|
An address listed for an application is that application's alone: another
|
||||||
|
application of the same domain, set to any address, may not send as it -
|
||||||
|
Postfix asks who owns the address before it asks who owns the domain.
|
||||||
|
|
||||||
A message whose `From:` is anything else is refused with `550 5.7.1` and a
|
A message whose `From:` is anything else is refused with `550 5.7.1` and a
|
||||||
text naming the line: another of your domains, the server's own name, an
|
text naming the line: another of your domains, the server's own name, an
|
||||||
address at a domain that is not on this server, a line with two addresses,
|
address at a domain that is not on this server, a line with two addresses,
|
||||||
|
|||||||
+117
-44
@@ -31,10 +31,9 @@ import (
|
|||||||
// answer, Postfix defers mail. It therefore depends on as little as it can.
|
// 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
|
// 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
|
// 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
|
// one table, read the way Postfix reads it and looked up the way Postfix looks
|
||||||
// "+extension", the domain. (Postfix compares logins without regard to case;
|
// it up (Bindings). The send-log row of a refusal is written by another
|
||||||
// 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
|
// goroutine and is not waited for.
|
||||||
// is not waited for.
|
|
||||||
//
|
//
|
||||||
// Postfix 3.7.11, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
// 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();
|
// 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 {
|
type Bindings struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
file tableFile
|
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
|
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.
|
// 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.
|
// fresh makes the table match the file.
|
||||||
func (b *Bindings) fresh() error {
|
func (b *Bindings) fresh() error {
|
||||||
lines, again, err := b.file.changed()
|
lines, again, err := b.file.changed()
|
||||||
if err != nil || !again {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
logins := make(map[string]map[string]bool)
|
if again {
|
||||||
domains := make(map[string]string)
|
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 {
|
for _, line := range lines {
|
||||||
// texthash: <key><whitespace><value>, the value a comma-separated
|
line = strings.TrimRight(line, "\r")
|
||||||
// list of logins (internal/postfix renderSenderLoginMaps).
|
trimmed := strings.TrimLeft(line, " \t")
|
||||||
// Postfix 3.7.11, src/util/dict_thash.c, dict_thash_open(): the key
|
switch {
|
||||||
// ends at the first blank; src/util/readlline.c skips a line whose
|
case trimmed == "" || trimmed[0] == '#':
|
||||||
// first non-blank is "#"; src/util/dict_ht.c folds the key to lower
|
continue
|
||||||
// case (DICT_FLAG_FOLD_FIX, set for smtpd_sender_login_maps in
|
case len(trimmed) < len(line) && len(logical) > 0:
|
||||||
// src/smtpd/smtpd_check.c).
|
logical[len(logical)-1] += line
|
||||||
fields := strings.Fields(line)
|
default:
|
||||||
if len(fields) < 2 || strings.HasPrefix(fields[0], "#") {
|
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
|
continue
|
||||||
}
|
}
|
||||||
sender := strings.ToLower(fields[0])
|
sender := strings.ToLower(key)
|
||||||
set := logins[sender]
|
if _, dup := logins[sender]; dup {
|
||||||
if set == nil {
|
return nil, nil, fmt.Errorf("sender map: duplicate entry %q, which Postfix refuses the whole table for", sender)
|
||||||
set = make(map[string]bool)
|
|
||||||
logins[sender] = set
|
|
||||||
}
|
}
|
||||||
for _, l := range strings.Split(strings.Join(fields[1:], ""), ",") {
|
set := make(map[string]bool)
|
||||||
if l != "" {
|
logins[sender] = set
|
||||||
set[l] = true
|
for _, l := range strings.FieldsFunc(value, func(r rune) bool {
|
||||||
if _, known := domains[l]; !known {
|
return r == ',' || r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||||||
domains[l] = domainOf(sender)
|
}) {
|
||||||
}
|
l = strings.ToLower(l)
|
||||||
|
set[l] = true
|
||||||
|
if _, known := domains[l]; !known {
|
||||||
|
domains[l] = domainOf(sender)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.logins, b.domainOf = logins, domains
|
return logins, domains, nil
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allowed reports whether login may send as addr: the address itself is bound
|
// splitTableLine splits a logical texthash line into its key and its value,
|
||||||
// to it, or its whole domain is. This is the lookup Postfix makes for the
|
// as dict_thash_open() does: the key ends at the first blank outside double
|
||||||
// envelope sender.
|
// 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) {
|
func (b *Bindings) Allowed(login, addr string) (bool, error) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
if err := b.fresh(); err != nil {
|
if err := b.fresh(); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
addr = strings.ToLower(addr)
|
login, addr = strings.ToLower(login), strings.ToLower(addr)
|
||||||
if b.logins[addr][login] {
|
keys := []string{addr}
|
||||||
return true, nil
|
// 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
|
if d := domainOf(addr); d != "" {
|
||||||
// application listed as invoices@ may also send as invoices+march@
|
keys = append(keys, "@"+d)
|
||||||
// (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] {
|
for _, k := range keys {
|
||||||
return true, nil
|
if owners, found := b.logins[k]; found {
|
||||||
|
return owners[login], nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -182,7 +255,7 @@ func (b *Bindings) DomainOf(login string) string {
|
|||||||
if b.fresh() != nil {
|
if b.fresh() != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return b.domainOf[login]
|
return b.domainOf[strings.ToLower(login)]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signers is OpenDKIM's SigningTable as the check reads it: the names this
|
// 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
|
// 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
|
// would have to judge is deferred; a line that names nobody else is not held
|
||||||
// up by it.
|
// up by it.
|
||||||
|
|||||||
@@ -100,10 +100,17 @@ func renderSenderLoginMaps(bindings []Binding) ([]byte, error) {
|
|||||||
if err := assertMapSafe(b.Address, b.Login); err != nil {
|
if err := assertMapSafe(b.Address, b.Login); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, seen := byAddr[b.Address]; !seen {
|
// One line per key as Postfix folds it: smtpd opens the map with
|
||||||
order = append(order, b.Address)
|
// 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)
|
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) {
|
func TestAssertMapSafeRejectsInjection(t *testing.T) {
|
||||||
bad := []struct{ addr, login string }{
|
bad := []struct{ addr, login string }{
|
||||||
{"@exa mple.com", "log"},
|
{"@exa mple.com", "log"},
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ func testSenderCheck(t *testing.T, sc *scenario) {
|
|||||||
{"the server's own name", wildcard, "postmaster@" + selfpostHostname, false},
|
{"the server's own name", wildcard, "postmaster@" + selfpostHostname, false},
|
||||||
{"a domain the server knows nothing of", wildcard, "someone@elsewhere.e2e.test", false},
|
{"a domain the server knows nothing of", wildcard, "someone@elsewhere.e2e.test", false},
|
||||||
{"an address off the list", list, "director@" + senderDomain, false},
|
{"an address off the list", list, "director@" + senderDomain, false},
|
||||||
|
// The first key Postfix finds decides: billing@ is listed for
|
||||||
|
// app-list, so the wildcard application may not have it, in the
|
||||||
|
// header as in the envelope (assumptions B-PS1; the envelope below).
|
||||||
|
{"the address listed for another application", wildcard, "billing@" + senderDomain, false},
|
||||||
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, false},
|
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, false},
|
||||||
} {
|
} {
|
||||||
token := uniqueToken("from")
|
token := uniqueToken("from")
|
||||||
@@ -98,14 +102,27 @@ func testSenderCheck(t *testing.T, sc *scenario) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the header case above holds to: Postfix itself refuses the
|
||||||
|
// wildcard application billing@ as envelope sender, at RCPT, because the
|
||||||
|
// address's own key names app-list only (Postfix 3.7.11,
|
||||||
|
// src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch(): "553
|
||||||
|
// 5.7.1 <%s>: Sender address rejected: not owned by user %s").
|
||||||
|
if res := attemptSend(sendAttempt{
|
||||||
|
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||||
|
from: "billing@" + senderDomain, headerFrom: "alerts@" + senderDomain, to: recipient,
|
||||||
|
subject: "e2e " + uniqueToken("envelope"), body: "sender check: another application's address in the envelope",
|
||||||
|
}); res.refusedAt("RCPT", 553, "not owned by user") != nil {
|
||||||
|
t.Errorf("the address listed for another application, in the envelope: %v", res.refusedAt("RCPT", 553, "not owned by user"))
|
||||||
|
}
|
||||||
|
|
||||||
// A refusal is a row of the Outbound log whose tag says why.
|
// A refusal is a row of the Outbound log whose tag says why.
|
||||||
if err := waitFor("the refusals to appear in the Outbound log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
if err := waitFor("the refusals to appear in the Outbound log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||||
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if n := strings.Count(rows, `">wrong From</span>`); n < 5 {
|
if n := strings.Count(rows, `">wrong From</span>`); n < 6 {
|
||||||
return false, fmt.Errorf("%d rows tagged wrong From, want 5", n)
|
return false, fmt.Errorf("%d rows tagged wrong From, want 6", n)
|
||||||
}
|
}
|
||||||
return true, nil
|
return true, nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user