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:
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -169,7 +169,7 @@ var outboundTopics = []HelpTopic{
|
||||
Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ",
|
||||
Code("From:"), " line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
|
||||
Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ",
|
||||
Code("550 5.7.1"), ". It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.",
|
||||
Code("550 5.7.1"), ". The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.",
|
||||
Br(), Br(),
|
||||
"The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."),
|
||||
}},
|
||||
|
||||
Reference in New Issue
Block a user