Files
selfpost/internal/milter/mailparse_test.go
T
mixandClaude Opus 5.5 3584adcbc2
test / test (push) Canceled after 0s
sender check: From: read with OpenDKIM's own parser; L8 closed
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>
2026-10-15 02:48:02 +03:00

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)
}