Files
selfpost/test/e2e/dkim_from_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

148 lines
5.1 KiB
Go

package e2e
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/emersion/go-msgauth/dkim"
)
// fromLines reads one of the files of From: lines the sender check's unit
// test reads too, with the placeholders put in: {domain} the application's,
// {other} another sender of the server, each also in capitals, and
// {other_qp} with its dots as quoted pairs.
func fromLines(repoRoot, file, domain, other string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(repoRoot, "internal", "milter", "testdata", file))
if err != nil {
return nil, err
}
var out []string
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
s, err := strconv.Unquote(line)
if err != nil {
return nil, fmt.Errorf("%s: %w", line, err)
}
out = append(out, strings.NewReplacer(
"{domain}", domain, "{DOMAIN}", strings.ToUpper(domain),
"{other_qp}", strings.ReplaceAll(other, ".", `\.`),
"{other}", other, "{OTHER}", strings.ToUpper(other),
).Replace(s))
}
return out, nil
}
// testSignedForItsDomainOnly: the sender check and OpenDKIM read From: with
// two parsers, and that they agree was argued, never run (audit 2026-10-12,
// stage 2). Every From: line the check passes for an application is sent as
// that application, and what arrives must be signed for its domain - with a
// signature that validates - and for no other: a line the check reads as the
// application's own and OpenDKIM as another sender's would be signed with
// that sender's key.
func testSignedForItsDomainOnly(t *testing.T, sc *scenario) {
lines, err := fromLines(h.repoRoot, "from-lines-passed.txt", senderDomain, otherDomain)
if err != nil {
t.Fatal(err)
}
for _, line := range lines {
token := uniqueToken("dkimfrom")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, headerFrom: line, to: recipient,
subject: "e2e " + token, body: "which key signs this",
})
if !res.ok() {
t.Errorf("From: %q was refused: %v", line, res.firstErr())
continue
}
raw, err := findSinkMessage(h.stageDir, token, 30*time.Second)
if err != nil {
t.Errorf("From: %q never reached the sink: %v", line, err)
continue
}
verifications, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{LookupTXT: lookupTXTFunc()})
if err != nil {
t.Errorf("From: %q: dkim verify: %v", line, err)
continue
}
valid := false
for _, v := range verifications {
if v.Domain != senderDomain {
t.Errorf("From: %q was signed for %s", line, v.Domain)
}
if v.Domain == senderDomain && v.Err == nil {
valid = true
}
}
if !valid {
t.Errorf("From: %q has no valid signature for %s (%d signature(s))", line, senderDomain, len(verifications))
}
}
}
// 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 {
t.Fatal(err)
}
for _, line := range lines {
token := uniqueToken("dkimother")
res := attemptSend(sendAttempt{
authLogin: sc.appLogin, authPassword: sc.appPassword,
from: "alerts@" + senderDomain, headerFrom: line, to: recipient,
subject: "e2e " + token, body: "which key signs this",
})
if err := res.refusedAt("DATA", 550, "5.7.1 From:"); err == nil {
continue
}
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)
}
}
}
// testLoginCaseMatters: SASL compares the login byte for byte, so an
// application's login in other capitals does not authenticate. Every lookup on
// the mail path is by the exact login and relies on that (review of audit
// stage 2, F13).
func testLoginCaseMatters(t *testing.T, sc *scenario) {
res := attemptSend(sendAttempt{
authLogin: strings.ToUpper(sc.appLogin), authPassword: sc.appPassword,
from: "alerts@" + senderDomain, to: recipient, subject: "e2e " + uniqueToken("case"), body: "x",
})
if err := res.refusedAt("AUTH", 535, ""); err != nil {
t.Fatalf("AUTH as %s: %v", strings.ToUpper(sc.appLogin), err)
}
}