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>
164 lines
5.3 KiB
Go
164 lines
5.3 KiB
Go
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)
|
|
}
|