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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user