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:
@@ -89,9 +89,11 @@ func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
|
||||
}
|
||||
}
|
||||
|
||||
// testNeverSignedForAnother: the lines that name another sender of the server
|
||||
// are refused by the check; whatever happens to one, nothing arrives signed
|
||||
// with that sender's key (review of audit stage 2).
|
||||
// testNeverSignedForAnother: no line that names another sender of the server
|
||||
// arrives signed with that sender's key (review of audit stage 2). Since stage
|
||||
// 2c the check reads a line as OpenDKIM does: one whose address is the
|
||||
// application's passes, wherever the other sender's name is written, and must
|
||||
// arrive signed for the application's domain; the rest are refused.
|
||||
func testNeverSignedForAnother(t *testing.T, sc *scenario) {
|
||||
lines, err := fromLines(h.repoRoot, "from-lines-other.txt", senderDomain, otherDomain)
|
||||
if err != nil {
|
||||
@@ -107,16 +109,25 @@ func testNeverSignedForAnother(t *testing.T, sc *scenario) {
|
||||
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err == nil {
|
||||
continue
|
||||
}
|
||||
t.Errorf("From: %q was not refused by the check: %v", line, res.firstErr())
|
||||
if !res.ok() {
|
||||
t.Errorf("From: %q: neither passed nor refused by the check: %v", line, res.firstErr())
|
||||
continue
|
||||
}
|
||||
raw, err := findSinkMessage(h.stageDir, token, 15*time.Second)
|
||||
if err != nil {
|
||||
t.Errorf("From: %q passed and never arrived: %v", line, err)
|
||||
continue
|
||||
}
|
||||
verifications, _ := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{LookupTXT: lookupTXTFunc()})
|
||||
own := false
|
||||
for _, v := range verifications {
|
||||
if v.Domain == otherDomain {
|
||||
t.Errorf("From: %q arrived signed for %s", line, otherDomain)
|
||||
}
|
||||
own = own || (v.Domain == senderDomain && v.Err == nil)
|
||||
}
|
||||
if !own {
|
||||
t.Errorf("From: %q passed and did not arrive signed for %s: %+v", line, senderDomain, verifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,15 @@ func testSenderCheck(t *testing.T, sc *scenario) {
|
||||
// app-list, so the wildcard application may not have it, in the
|
||||
// header as in the envelope (assumptions B-PS1; the envelope below).
|
||||
{"the address listed for another application", wildcard, "billing@" + senderDomain, false},
|
||||
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, false},
|
||||
// Since 2.5.2 the address OpenDKIM reads decides (OpenDKIM's parser,
|
||||
// internal/milter/mailparse.go): another sender's name in the display
|
||||
// name passes, and is signed for the application's own domain
|
||||
// (passed_from_lines_signed_for_their_domain_only looks at the key).
|
||||
{"another sender in the display name", wildcard, `"director@` + otherDomain + `" <alerts@` + senderDomain + `>`, true},
|
||||
// Past 256 bytes OpenDKIM matches its SigningTable against the
|
||||
// address cut short, which here would end in the other domain's
|
||||
// name (opendkim.c, dkimf_apply_signtable(), MAXADDRESS).
|
||||
{"an address longer than OpenDKIM reads", wildcard, `"` + strings.Repeat("a", 256-len("@"+otherDomain)-1) + `@` + otherDomain + `x"@` + senderDomain, false},
|
||||
} {
|
||||
token := uniqueToken("from")
|
||||
res := attemptSend(sendAttempt{
|
||||
|
||||
Reference in New Issue
Block a user