sender check: From: read with OpenDKIM's own parser; L8 closed
test / test (push) Canceled after 0s

Audit 2026-10-12, stage 2c, the owner's decision of 2026-10-14: the check
decides by the address OpenDKIM signs for, whatever a display name or a
comment holds.

internal/milter/mailparse.go ports libopendkim's dkim_mail_parse() with
dkim_mail_first_special(), dkim_mail_token() and
dkim_mail_matching_paren() from Debian's opendkim 2.11.0~beta2-8+deb12u1,
line for line: the '<' form, group and list punctuation, an '@' before the
first ':', ';' or ',' deciding where the address starts, source routes
dropped, quoted strings and domain literals kept with their delimiters,
comments skipped, whitespace between atoms one space, no encoded word
decoded; and the C's own quirks ("@d" gives d as the local part too).
testdata/mailparse_harness.c compiles the C from the image's source; the
test feeds it and the port 200 019 lines (the hand-written cases and
200 000 random ones over the specials) - 93 443 parse and agree byte for
byte, 106 576 fail and agree on the error (on an error the C leaves its
in-place result unterminated, and OpenDKIM uses neither part then).

judge(): one From: field and one address stay required (moreAfter: only
list or group punctuation may follow); what OpenDKIM cannot read is
refused (mlfi_eoh() would not sign it); the address is lower-cased in
ASCII only, as dkimf_lowercase() and Postfix's tables do. Found reading
dkimf_apply_signtable(): OpenDKIM matches its refile SigningTable against
"user@domain" printed into MAXADDRESS + 1 = 257 bytes, so a longer address
is matched cut short - a quoted local part padded to end there in
"@another.tenant" would be signed with that tenant's key while its real
domain is the application's. Such an address (over 256 bytes) is refused;
one that fits is matched whole. The display-name and comment scan, the
"one @ outside quotes" rule and the SigningTable reader (Signers) go; the
check asks for header values with their leading space as OpenDKIM does
(OptHeaderLeadingSpace; Postfix keeps a v2 milter's event mask, milter8.c
milter8_negotiate()).

L8 closed: refused or passed no longer depends on other domains of the
server; security.md's accepted risk is struck. The shared other-sender
lines are now held to "never signed for the other sender": unit test (a
passed line is read at the application's domain) and e2e (a passed line
arrives signed for the application's domain, none for the other).
Documents: architecture "How the header is read", security's sender
check paragraph, the guide; the help topic per 2cf118c.

Stand (2026-10-15, make e2e, exit 0): sender_check_holds_the_from_line -
another sender's address in the display name now passes; a From: whose
quoted local part is padded so that, cut at 256 bytes, it ends in
"@other.e2e.test" is refused 550 5.7.1 "the address is longer than
OpenDKIM reads whole"; lines_naming_another_sender_never_signed_for_it
and passed_from_lines_signed_for_their_domain_only pass. go vet and go
test ./... pass on the server (24 packages); the differential test ran
there against the compiled C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-15 02:48:02 +03:00
co-authored by Claude Opus 5.5
parent 2cf118cafc
commit 3584adcbc2
16 changed files with 734 additions and 268 deletions
+327
View File
@@ -0,0 +1,327 @@
package milter
// The address OpenDKIM signs for, found as OpenDKIM finds it: a port of
// libopendkim's dkim_mail_parse() and its tokenizer, from Debian's
// opendkim 2.11.0~beta2-8+deb12u1 (libopendkim/dkim-mailparse.c, identical
// there to upstream's develop branch), line for line. OpenDKIM calls it on the
// value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(),
// dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders),
// lower-cases the domain it returns, and signs with the key whose SigningTable
// pattern matches "user@domain" (dkimf_apply_signtable()). Whatever the port
// returns, OpenDKIM does: quoted strings and domain literals are kept with
// their delimiters, comments are dropped, a source route resets the address,
// an '@' before the first ':', ';' or ',' decides where the address starts,
// and encoded words are not decoded.
//
// The C works in place on a NUL-terminated buffer, writing the result over
// the input; here the input is read through at(), which gives 0 at its end as
// the C finds the terminator, and the result goes to a buffer of its own, in
// which 0 ends a string as '\0' does there.
// mailparse errors, DKIM_MAILPARSE_ERR_* (dkim-mailparse.c).
const (
mailparseOK = 0
mailparsePUnbalanced = 1 // unbalanced parentheses
mailparseQUnbalanced = 2 // unbalanced quotes
mailparseSUnbalanced = 3 // unbalanced square brackets
)
// mailparseSpecials is SPECIALS: "<>@,;:\\\"/[]?=".
var mailparseSpecials = func() (t [256]bool) {
for _, c := range []byte("<>@,;:\\\"/[]?=") {
t[c] = true
}
return t
}()
// mailparseSpace is isascii(c) && isspace(c) in the C locale.
func mailparseSpace(c byte) bool {
return c == ' ' || (c >= '\t' && c <= '\r')
}
type mailparser struct {
b []byte // the input up to its first NUL, as strlen() sees it
}
// at is *p: the byte, or the terminator at and beyond the end.
func (m *mailparser) at(i int) byte {
if i >= len(m.b) {
return 0
}
return m.b[i]
}
// matchingParen is dkim_mail_matching_paren(): the position of the close
// character that balances the open one before s, or e. An open character of
// 0 is never met inside the string, so for quotes and brackets it only skips
// to the close, past backslash escapes.
func (m *mailparser) matchingParen(s, e int, open, close byte) int {
paren := 1
for ; s < e; s++ {
c := m.b[s]
if c == close {
paren--
if paren == 0 {
break
}
} else if c == open {
paren++
} else if c == '\\' {
if m.at(s+1) != 0 {
s++
}
}
}
return s
}
// firstSpecial is dkim_mail_first_special(): the '<' that opens an address,
// or the ':', ';' or ',' that ends a part - or the '@' seen before it - or
// the end.
func (m *mailparser) firstSpecial(p, e int) (int, int) {
atPtr := -1
for ; p < e && m.at(p) != 0; p++ {
// skip white space between tokens
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
if m.at(p) != '(' {
p++
} else {
p = m.matchingParen(p+1, e, '(', ')')
if m.at(p) == 0 {
return p, mailparsePUnbalanced
}
p++
}
}
if m.at(p) == 0 {
break
}
c := m.at(p)
switch {
case c == '"':
p = m.matchingParen(p+1, e, 0, '"')
if m.at(p) == 0 {
return p, mailparseQUnbalanced
}
case c == '[':
p = m.matchingParen(p+1, e, 0, ']')
if m.at(p) == 0 {
return p, mailparseSUnbalanced
}
case mailparseSpecials[c]:
if c == '<' {
return p, mailparseOK
} else if c == ':' || c == ';' || c == ',' {
if atPtr >= 0 {
return atPtr, mailparseOK
}
return p, mailparseOK
} else if c == '@' {
atPtr = p
}
default:
for m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
p++
}
p--
}
}
return p, mailparseOK
}
// token is dkim_mail_token(): the next token from s - a quoted string or a
// domain literal with its delimiters, a special character alone, or an atom
// ('x') - its type (0 at the end), and whether whitespace outside a comment
// came before it (ws is set, never cleared, as in the C).
func (m *mailparser) token(s, e int, ws *bool) (typ int, start, end int, err int) {
p := s
// skip white space between tokens
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
if m.at(p) != '(' {
*ws = true
p++
} else {
p = m.matchingParen(p+1, e, '(', ')')
if m.at(p) == 0 {
return 0, 0, 0, mailparsePUnbalanced
}
p++
}
}
if p >= e || m.at(p) == 0 {
return 0, 0, 0, mailparseOK
}
start = p
c := m.at(p)
switch {
case c == '"':
end = m.matchingParen(p+1, e, 0, '"')
typ = '"'
if m.at(end) != 0 {
end++
} else {
err = mailparseQUnbalanced
}
case c == '[':
end = m.matchingParen(p+1, e, 0, ']')
typ = '['
if m.at(end) != 0 {
end++
} else {
err = mailparseSUnbalanced
}
case mailparseSpecials[c]:
end = p + 1
typ = int(c)
default:
for p < e && m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
p++
}
end = p
typ = 'x'
}
return typ, start, end, err
}
// mailParsed is what dkim_mail_parse() returns: the local part and the
// domain, each absent (NULL) or a string, and the error. rest is not the
// C's: where in the line it stopped reading, for the caller's question
// whether anything follows the address.
type mailParsed struct {
user, domain string
hasUser, hasDomain bool
err int
rest int
}
// dkimMailParse is dkim_mail_parse().
func dkimMailParse(line string) mailParsed {
if i := indexByte(line, 0); i >= 0 {
line = line[:i] // strlen()
}
m := &mailparser{b: []byte(line)}
e := len(m.b)
var out []byte
user, domain := -1, -1
result := func(err, rest int) mailParsed {
r := mailParsed{err: err, rest: rest}
if user >= 0 {
r.user, r.hasUser = cstring(out[user:]), true
}
if domain >= 0 {
r.domain, r.hasDomain = cstring(out[domain:]), true
}
return r
}
ws := false
pos := 0
for {
special, err := m.firstSpecial(pos, e)
if err != 0 {
return result(err, special)
}
switch m.at(special) {
case '<':
// display name <address>
pos = special + 1
for {
typ, ts, te, err := m.token(pos, e, &ws)
if err != 0 {
return result(err, pos)
}
if typ == '>' || typ == 0 {
out = append(out, 0)
if typ == 0 {
te = e
}
return result(mailparseOK, te)
} else if typ == '@' {
out = append(out, 0)
domain = len(out)
} else if typ == ',' || typ == ':' {
// source route punctuation
user, domain = -1, -1
} else {
if user < 0 {
user = len(out)
}
out = append(out, m.b[ts:te]...)
}
pos = te
}
case ';', ':', ',':
// skip a group name or result
pos = special + 1
default:
// (display name) addr(display name)ess
ws = false
for {
typ, ts, te, err := m.token(pos, e, &ws)
if err != 0 {
return result(err, pos)
}
if typ == 0 || typ == ',' || typ == ';' {
out = append(out, 0)
if typ == 0 {
te = e
}
return result(mailparseOK, te)
} else if typ == '@' {
out = append(out, 0)
domain = len(out)
ws = false
} else {
if user < 0 {
user = len(out)
} else if typ == 'x' && ws {
out = append(out, ' ')
}
out = append(out, m.b[ts:te]...)
ws = false
}
pos = te
}
}
}
}
// moreAfter says whether anything but list and group punctuation follows
// position rest of line: another address, or text after the one found.
func moreAfter(line string, rest int) bool {
if i := indexByte(line, 0); i >= 0 {
line = line[:i]
}
m := &mailparser{b: []byte(line)}
ws := false
for pos := rest; ; {
typ, _, te, err := m.token(pos, len(m.b), &ws)
if err != 0 {
return true
}
switch typ {
case 0:
return false
case ',', ';':
pos = te
default:
return true
}
}
}
func indexByte(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
// cstring reads a NUL-terminated string from b.
func cstring(b []byte) string {
if i := indexByte(string(b), 0); i >= 0 {
return string(b[:i])
}
return string(b)
}
+163
View File
@@ -0,0 +1,163 @@
package milter
import (
"bufio"
"encoding/hex"
"fmt"
"math/rand"
"os"
"os/exec"
"strings"
"testing"
)
// mailparseCases are lines whose result is written down here, read off
// dkim-mailparse.c by hand and confirmed against it by
// TestMailParseMatchesOpenDKIM.
var mailparseCases = []struct {
line string
user, domain string // "-" for absent
err int
}{
{"user@example.com", "user", "example.com", 0},
{" Name <user@example.com>", "user", "example.com", 0},
{`"Some One" <user@example.com>`, "user", "example.com", 0},
{"user@example.com (Some One)", "user", "example.com", 0},
{"Some One <user@ex(comment)ample.com>", "user", "example.com", 0},
// An '@' before the first ',' decides: the address is read from the start.
{"ceo@other.com <me@mine.com>", "me", "mine.com", 0},
{"me@mine.com, you@yours.com", "me", "mine.com", 0},
// Quoted local parts keep their quotes; an '@' inside them is no '@'.
{`"x@other.com"@mine.com`, `"x@other.com"`, "mine.com", 0},
{`<"a b"@mine.com>`, `"a b"`, "mine.com", 0},
// A source route is dropped.
{"<@relay.example,@r2.example:u@mine.com>", "u", "mine.com", 0},
// Group syntax: the name is skipped.
{"group: u@mine.com;", "u", "mine.com", 0},
// No local part: the domain is taken for it, so OpenDKIM has both.
{"@mine.com", "mine.com", "mine.com", 0},
// Whitespace between atoms of the default form becomes one space.
{"a b@mine.com", "a b", "mine.com", 0},
// Unbalanced.
{"Name (comment <u@mine.com>", "-", "-", mailparsePUnbalanced},
{`"open <u@mine.com>`, "-", "-", mailparseQUnbalanced},
{"u@[1.2.3.4", "-", "-", mailparseSUnbalanced},
// Encoded words are not decoded.
{"=?utf-8?q?boss=40other.com?= <u@mine.com>", "u", "mine.com", 0},
// No address at all.
{"Undisclosed recipients:;", "-", "-", 0},
{"", "-", "-", 0},
}
func parsedParts(r mailParsed) (string, string) {
u, d := "-", "-"
if r.hasUser {
u = r.user
}
if r.hasDomain {
d = r.domain
}
return u, d
}
func TestMailParseCases(t *testing.T) {
for _, c := range mailparseCases {
r := dkimMailParse(c.line)
u, d := parsedParts(r)
if r.err != c.err || (c.err == 0 && (u != c.user || d != c.domain)) {
t.Errorf("%q: user %q domain %q err %d; want %q %q %d", c.line, u, d, r.err, c.user, c.domain, c.err)
}
}
}
func TestMoreAfter(t *testing.T) {
for line, want := range map[string]bool{
"user@example.com": false,
"Name <user@example.com>": false,
"Name <user@example.com> (note)": false,
"group: u@mine.com;": false,
"me@mine.com, you@yours.com": true,
"Name <a@x>, b@y": true,
"Name <a@x> trailing": true,
} {
r := dkimMailParse(line)
if got := moreAfter(line, r.rest); got != want {
t.Errorf("%q: moreAfter %v, want %v", line, got, want)
}
}
}
// mailparseCorpus is the cases above and n random lines over an alphabet
// weighted toward what the parser treats specially.
func mailparseCorpus(n int) []string {
lines := []string{}
for _, c := range mailparseCases {
lines = append(lines, c.line)
}
alphabet := []string{"<", ">", "@", ",", ";", ":", "\\", `"`, "/", "[", "]", "?", "=", "(", ")",
" ", "\t", "\n", "\r", ".", "a", "b", "x", "mine.com", "other.com", "\xc3\xa9", "\xff", "=?utf-8?q?", "?="}
rng := rand.New(rand.NewSource(20261015))
for i := 0; i < n; i++ {
var b strings.Builder
for j := rng.Intn(24); j >= 0; j-- {
b.WriteString(alphabet[rng.Intn(len(alphabet))])
}
lines = append(lines, b.String())
}
return lines
}
// TestMailParseMatchesOpenDKIM feeds the corpus to OpenDKIM's own C
// dkim_mail_parse() through testdata/mailparse_harness.c, built against the
// opendkim source the image runs, and wants the port's answer for every line.
// It runs where MAILPARSE_C names the built harness.
func TestMailParseMatchesOpenDKIM(t *testing.T) {
bin := os.Getenv("MAILPARSE_C")
if bin == "" {
t.Skip("MAILPARSE_C not set (see testdata/mailparse_harness.c)")
}
lines := mailparseCorpus(200000)
var in strings.Builder
for _, l := range lines {
in.WriteString(hex.EncodeToString([]byte(l)))
in.WriteByte('\n')
}
cmd := exec.Command(bin)
cmd.Stdin = strings.NewReader(in.String())
out, err := cmd.Output()
if err != nil {
t.Fatalf("harness: %v", err)
}
sc := bufio.NewScanner(strings.NewReader(string(out)))
sc.Buffer(make([]byte, 1<<20), 1<<20)
enc := func(present bool, s string) string {
if !present {
return "-"
}
return "=" + hex.EncodeToString([]byte(s))
}
diffs, failed := 0, 0
for i := 0; sc.Scan(); i++ {
r := dkimMailParse(lines[i])
got := fmt.Sprintf("%d %s %s", r.err, enc(r.hasUser, r.user), enc(r.hasDomain, r.domain))
want := sc.Text()
if r.err != 0 {
// On an error the C returns before it writes the result's end, so
// what it prints of user and domain runs on into the unread
// input; OpenDKIM uses neither then (mlfi_eoh(): status != 0 is
// BADFORMAT, no signature). The error itself must agree.
failed++
got, want = fmt.Sprint(r.err), strings.Fields(want)[0]
}
if got != want {
diffs++
if diffs <= 20 {
t.Errorf("line %q: port %s, OpenDKIM %s", lines[i], got, want)
}
}
}
if diffs > 0 {
t.Fatalf("%d of %d lines differ", diffs, len(lines))
}
t.Logf("%d lines, the port and OpenDKIM agree on every one (%d of them errors, compared by code)", len(lines), failed)
}
+55 -147
View File
@@ -3,11 +3,8 @@ package milter
import (
"context"
"fmt"
"io"
"log"
"mime"
"net"
"net/mail"
"net/textproto"
"os"
"strings"
@@ -26,9 +23,13 @@ import (
// that line, so without the check an application could have mail signed as
// another domain of the server, or as the server itself.
//
// The line is read with OpenDKIM's own parser, ported (mailparse.go), so the
// address judged is the one OpenDKIM signs for.
//
// 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.
// answer, Postfix answers the client with a temporary error. 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, read the way Postfix reads it and looked up the way Postfix looks
@@ -258,51 +259,6 @@ func (b *Bindings) DomainOf(login string) string {
return b.domainOf[strings.ToLower(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.
//
// OpenDKIM 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_open(), case
// DKIMF_DB_TYPE_REFILE: the pattern is a line up to its first blank, "#"
// starts a comment; opendkim/util.c, dkimf_mkregexp(): "^" + the pattern
// ("*" as ".*", "." escaped) + "$", compiled REG_ICASE (DKIMF_DB_FLAG_ICASE,
// opendkim.c). So "*@d" signs user@d in any case, and no subdomain of d.
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 {
@@ -313,7 +269,6 @@ type Refusals interface {
type senderSession struct {
milter.NoOpMilter
bind *Bindings
signers *Signers
refused chan<- store.SendLogEntry
login string
@@ -411,58 +366,58 @@ func (s *senderSession) judge() (shown, why string, err error) {
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])
// The value is the one OpenDKIM gets - Postfix hands both milters the
// field as it came, folded lines and all, and with the leading space each
// asked for (OptHeaderLeadingSpace, ServeSender) - and it is read with
// OpenDKIM's own parser (mailparse.go). So the address judged here is the
// address OpenDKIM signs for, whatever the display name, a comment or a
// quoted string around it holds; the domain of the key it signs with is
// that address's (assumptions B-PM12; audit 2026-10-12, stage 2c).
raw := s.froms[0]
shown = printable(raw)
list, perr := fromParser.ParseList(raw)
if perr != nil || len(list) != 1 || list[0].Address == "" {
r := dkimMailParse(raw)
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
// OpenDKIM does not sign then either (mlfi_eoh(): BADFORMAT).
return shown, "it holds no address OpenDKIM can read", nil
}
if moreAfter(raw, r.rest) {
// OpenDKIM takes the first; a reader may show the others.
return shown, "it must be one address", nil
}
addr := strings.ToLower(list[0].Address)
addr := r.user + "@" + asciiLower(r.domain)
// OpenDKIM matches its SigningTable against "user@domain" as
// snprintf() leaves it in a buffer of MAXADDRESS + 1 = 257 bytes
// (opendkim.c, dkimf_apply_signtable(); libopendkim/dkim-internal.h).
// Cut there, a quoted local part could end in another tenant's
// "@domain" and pick that tenant's key; an address that fits is read
// whole, and its domain (DKIM_MAXHOSTNAMELEN, 256) with it.
if len(addr) > 256 {
return printable(addr), "the address is longer than OpenDKIM reads whole", nil
}
shown = printable(addr)
ok, err := s.bind.Allowed(s.login, addr)
ok, err := s.bind.Allowed(s.login, asciiLower(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
}
// asciiLower lower-cases A-Z only, as OpenDKIM's dkimf_lowercase() and
// Postfix's table lookups do; strings.ToLower would also fold characters
// outside ASCII - the Kelvin sign to "k" - into names they are not.
func asciiLower(v string) string {
b := []byte(v)
for i, c := range b {
if c >= 'A' && c <= 'Z' {
b[i] = c + 'a' - 'A'
}
}
return string(b)
}
// 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
@@ -496,37 +451,6 @@ func (s *senderSession) record(shown, queueID string) {
}
}
// 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
@@ -543,29 +467,6 @@ func (s *senderSession) domain() string {
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.
@@ -600,7 +501,7 @@ func printable(v string) string {
// ServeSender runs the sender check on ln until ctx is cancelled. rec may be
// nil: refusals are then not logged. What it relies on of go-milter and
// Postfix is cited at Serve in milter.go.
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *Signers, rec Refusals) error {
func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refusals) error {
var refused chan store.SendLogEntry
if rec != nil {
refused = make(chan store.SendLogEntry, 256)
@@ -619,10 +520,17 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, signers *
}
srv := &milter.Server{
NewMilter: func() milter.Milter {
return &senderSession{bind: bind, signers: signers, refused: refused}
return &senderSession{bind: bind, refused: refused}
},
Actions: 0, // read-only
Protocol: milter.OptNoBody, // the headers decide
Actions: 0, // read-only
// The headers decide; and each header value with its leading space,
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(),
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix
// 3.7.11, src/milter/milter8.c: milter8_negotiate() keeps the event
// mask a milter of any version asks for (go-milter v0.4.1 says
// version 2), and milter8_header() drops the space only for a milter
// without SMFIP_HDR_LEADSPC.
Protocol: milter.OptNoBody | milter.OptHeaderLeadingSpace,
}
go func() {
+30 -52
View File
@@ -24,22 +24,6 @@ invoices@example.org billing
billing@example.org billing
`
// signingTable is a SigningTable for the same server, composed by hand: the
// three sending domains, one more that has no application yet, and the
// server's own name. Its key-name column is not what internal/domain
// renderTables writes (the domain name); the check reads only the pattern.
const signingTable = `*@news.example.net selfpost._domainkey.news.example.net
*@shop.example.org selfpost._domainkey.shop.example.org
*@example.org selfpost._domainkey.example.org
*@empty.example.com selfpost._domainkey.empty.example.com
*@mail.example.org selfpost._domainkey.mail.example.org
`
func signers(t *testing.T) *Signers {
t.Helper()
return NewSigners(writeMaps(t, signingTable))
}
func writeMaps(t *testing.T, content string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "table")
@@ -54,7 +38,7 @@ func writeMaps(t *testing.T, content string) string {
func offer(t *testing.T, bind *Bindings, login, envelope string, headers ...[2]string) (milter.Response, []store.SendLogEntry) {
t.Helper()
refused := make(chan store.SendLogEntry, 8)
s := &senderSession{bind: bind, signers: signers(t), refused: refused}
s := &senderSession{bind: bind, refused: refused}
if _, err := s.MailFrom(envelope, mods(map[string]string{"{auth_authen}": login})); err != nil {
t.Fatal(err)
}
@@ -109,6 +93,18 @@ func TestSenderCheck(t *testing.T) {
{"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""},
{"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""},
{"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""},
{"the leading space Postfix keeps for OpenDKIM", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
// Whatever the name, a comment or a quoted local part holds: OpenDKIM
// signs for the address it reads, and that is the one judged.
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, true, ""},
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, true, ""},
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, true, ""},
{"a stranger's address in the display name", "shop", [][2]string{from(`"someone@gmail.example" <a@shop.example.org>`)}, true, ""},
{"another sender in an encoded display name", "shop", [][2]string{from("=?utf-8?q?director=40example=2Eorg?= <a@shop.example.org>")}, true, ""},
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, true, ""},
{"an unquoted address as the display name", "shop", [][2]string{from("director@example.org <a@shop.example.org>")}, true, ""},
{"another domain inside a quoted local part", "shop", [][2]string{from(`"director@example.org"@shop.example.org`)}, true, ""},
{"no local part: OpenDKIM takes the domain for it", "shop", [][2]string{from("@shop.example.org")}, true, ""},
{"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
{"the server's own name", "shop", [][2]string{from("postmaster@mail.example.org")}, false, "not an address this application sends as"},
@@ -120,16 +116,14 @@ func TestSenderCheck(t *testing.T) {
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
{"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"},
{"not an address", "shop", [][2]string{from("the shop")}, false, "one address"},
{"not an address", "shop", [][2]string{from("the shop")}, false, "no address OpenDKIM can read"},
{"an address before a list comma, then another", "shop", [][2]string{from("director@example.org, <a@shop.example.org>")}, false, "one address"},
// Past 256 bytes OpenDKIM matches its SigningTable against a cut
// "user@domain" - which could end in another tenant's domain.
{"an address longer than OpenDKIM reads", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, false, "longer than OpenDKIM reads whole"},
{"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"},
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, false, "names another sender of this server"},
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, false, "names another sender of this server"},
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, false, "names another sender of this server"},
{"a stranger's address in the display name", "shop", [][2]string{from(`"someone@gmail.example" <a@shop.example.org>`)}, true, ""},
{"another sender in an encoded display name", "shop", [][2]string{from("=?utf-8?q?director=40example=2Eorg?= <a@shop.example.org>")}, false, "names another sender of this server"},
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, false, "one @"},
{"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"},
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "one address"},
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "no address OpenDKIM can read"},
{"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"},
{"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"},
}
@@ -290,30 +284,10 @@ func TestBindingsDuplicateKeyBreaksTheTable(t *testing.T) {
}
}
// 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.
func TestSenderCheckDefersWithoutSigningTable(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
play := func(v string) milter.Response {
s := &senderSession{bind: bind, signers: NewSigners(filepath.Join(t.TempDir(), "absent"))}
_, _ = s.MailFrom("shop@shop.example.org", mods(map[string]string{"{auth_authen}": "shop"}))
_, _ = s.Header("From", v, mods(nil))
resp, _ := s.Headers(nil, mods(nil))
return resp
}
if resp := play(`"x@example.org" <a@shop.example.org>`); resp != milter.RespTempFail {
t.Fatalf("reply %s, want a temporary failure", replyText(resp))
}
if resp := play(`a@shop.example.org`); resp != milter.RespContinue {
t.Fatalf("a plain own address: %s", replyText(resp))
}
}
// A second message on the connection is judged by its own headers.
func TestSenderCheckResetsBetweenMessages(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
s := &senderSession{bind: bind, signers: signers(t)}
s := &senderSession{bind: bind}
auth := mods(map[string]string{"{auth_authen}": "shop"})
for i, v := range []string{"director@example.org", "a@shop.example.org", "director@example.org"} {
_, _ = s.MailFrom("shop@shop.example.org", auth)
@@ -329,7 +303,7 @@ func TestSenderCheckResetsBetweenMessages(t *testing.T) {
func TestSenderCheckRefusalIsOneRow(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
refused := make(chan store.SendLogEntry, 8)
s := &senderSession{bind: bind, signers: signers(t), refused: refused}
s := &senderSession{bind: bind, refused: refused}
_, _ = s.MailFrom("shop@shop.example.org", mods(map[string]string{"{auth_authen}": "shop"}))
for _, r := range []string{"a@x.example", "b@x.example", "c@x.example"} {
_, _ = s.RcptTo(r, mods(nil))
@@ -392,14 +366,18 @@ func fromLines(t *testing.T, file, domain, other string) []string {
return out
}
// Every line of the shared file of lines naming another sender is refused:
// none may be passed to be signed with that sender's key.
func TestSenderCheckRefusesTheSharedLinesNamingAnother(t *testing.T) {
// No line of the shared file of lines naming another sender is passed to be
// signed with that sender's key: a line the check passes is one OpenDKIM
// reads the application's own address in (the e2e looks at the signature).
func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") {
resp, _ := offer(t, bind, "shop", "shop@shop.example.org", [2]string{"From", line})
if resp == milter.RespContinue {
t.Errorf("From: %q passed", line)
if resp != milter.RespContinue {
continue
}
if r := dkimMailParse(line); asciiLower(r.domain) != "shop.example.org" {
t.Errorf("From: %q passed, and OpenDKIM reads the domain %q in it", line, r.domain)
}
}
}
+7 -5
View File
@@ -1,10 +1,12 @@
# From: lines that name another sender of this server, {other}, beside or in
# place of the application's own address at {domain} (review of audit stage
# 2). The check must refuse each: a line it read as the application's own and
# OpenDKIM as {other}'s would be signed with {other}'s key. The unit test
# (internal/milter/sender_test.go) wants each refused; the e2e
# (test/e2e/dkim_from_test.go) sends each and wants nothing signed for
# {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
# 2). None may be signed with {other}'s key. Since stage 2c the check reads a
# line with OpenDKIM's own parser, so it passes those whose address OpenDKIM
# reads at {domain} - {other} in a display name, a comment or a quoted local
# part - and refuses the rest. The unit test
# (internal/milter/sender_test.go) wants every line it passes read at
# {domain}; the e2e (test/e2e/dkim_from_test.go) sends each and wants
# nothing signed for {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
# e2e on every run.
+55
View File
@@ -0,0 +1,55 @@
/*
* Differential harness for internal/milter/mailparse.go: OpenDKIM's own
* dkim_mail_parse() (libopendkim/dkim-mailparse.c of the opendkim source the
* image runs, compiled in by #include), fed one hex-encoded line per input
* line. Output per line: the error, then the local part and the domain, each
* "-" when NULL and "=" followed by its bytes in hex otherwise.
*
* cc -o mailparse_harness -I <opendkim>/libopendkim mailparse_harness.c
* MAILPARSE_C=./mailparse_harness go test -run TestMailParseMatchesOpenDKIM ./internal/milter
*/
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "dkim-mailparse.c"
static int hexval(int c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
static void put(const unsigned char *s)
{
if (s == NULL) {
fputs(" -", stdout);
return;
}
fputs(" =", stdout);
for (; *s != '\0'; s++)
printf("%02x", *s);
}
int main(void)
{
static char hex[1 << 20];
static unsigned char buf[1 << 19];
while (fgets(hex, sizeof hex, stdin) != NULL) {
size_t n = strcspn(hex, "\r\n"), i;
unsigned char *user, *domain;
int err;
for (i = 0; i + 1 < n; i += 2)
buf[i / 2] = (unsigned char) (hexval(hex[i]) << 4 | hexval(hex[i + 1]));
buf[n / 2] = '\0';
err = dkim_mail_parse(buf, &user, &domain);
printf("%d", err);
put(user);
put(domain);
putchar('\n');
}
return 0;
}