sender check: a Sender: line is held to the rule From: is held to
test / test (push) Canceled after 0s

The owner's decision (2026-10-16) on the security review's F1: a Sender:
line - the sending agent's, shown beside From: as "on behalf of", and
signed since 2.8.0 - was neither held to the login nor, before, signed: an
application could write its own From: and Sender: ceo@bank.com under this
server's signature. Now, when a message has one, it must be one address
the application may send as, and at most one such line (RFC 5322 § 3.6);
From: is judged first.

The refusal is "550 5.7.1 Sender: <address> - <why>", a send-log row of
reason "sender" (store.ReasonSender), tagged "wrong Sender"; the message
page names the line. The log tailer recovers such a row from cleanup's line
as it does a From: refusal (fromRefusalRe reads either). Help text as in
the design commit d9d9af3; guide, architecture, security and CHANGELOG say
it, security.md keeps Resent-From: (signed, not judged) as an accepted
risk.

Tests: TestSenderLineHeldToTheSameRule (own address, a name, another
application's, a stranger's, two addresses, two lines, From: first), the
recovery of a Sender: refusal line, the tag, the message page; e2e
sender_check_holds_the_sender_line (own Sender: passes and is signed,
another domain's refused at DATA and logged).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-16 07:41:42 +03:00
co-authored by Claude Opus 5.5
parent d9d9af3932
commit 0cceb549cc
18 changed files with 268 additions and 53 deletions
+45
View File
@@ -415,3 +415,48 @@ func TestSenderCheckPassesTheSharedLines(t *testing.T) {
}
}
}
// A Sender: line, when the message has one, is held to the rule From: is
// held to; the From: line is judged first (the owner's decision, 2026-10-16).
func TestSenderLineHeldToTheSameRule(t *testing.T) {
bind := NewBindings(writeMaps(t, senderMaps))
from := [2]string{"From", "a@shop.example.org"}
sender := func(v string) [2]string { return [2]string{"Sender", v} }
for _, c := range []struct {
name string
headers [][2]string
field string // the line refused; "" when the message passes
wantText string
}{
{"no Sender: line", [][2]string{from}, "", ""},
{"its own address", [][2]string{from, sender("shop@shop.example.org")}, "", ""},
{"its own address, with a name", [][2]string{from, sender(`"The Shop's mailer" <mailer@shop.example.org>`)}, "", ""},
{"header name in another case", [][2]string{from, {"SENDER", "ceo@bank.example"}}, "Sender", "not an address this application sends as"},
{"a stranger's address", [][2]string{from, sender("ceo@bank.example")}, "Sender", "not an address this application sends as"},
{"another application's address", [][2]string{from, sender("desk@shop.example.org")}, "Sender", "not an address this application sends as"},
{"two addresses", [][2]string{from, sender("a@shop.example.org, ceo@bank.example")}, "Sender", "one address"},
{"two Sender: lines", [][2]string{from, sender("a@shop.example.org"), sender("b@shop.example.org")}, "Sender", "more than one Sender: line"},
{"a wrong From: and a wrong Sender:", [][2]string{{"From", "ceo@bank.example"}, sender("x@bank.example")}, "From", "not an address this application sends as"},
} {
t.Run(c.name, func(t *testing.T) {
resp, rows := offer(t, bind, "shop", "shop@shop.example.org", c.headers...)
if c.field == "" {
if resp != milter.RespContinue || len(rows) != 0 {
t.Fatalf("refused: %s", replyText(resp))
}
return
}
text := replyText(resp)
if !strings.HasPrefix(text, "y 550 5.7.1 "+c.field+": ") || !strings.Contains(text, c.wantText) {
t.Fatalf("reply %q, want a 550 5.7.1 about %s: saying %q", text, c.field, c.wantText)
}
reason := store.ReasonFrom
if c.field == "Sender" {
reason = store.ReasonSender
}
if len(rows) != 1 || rows[0].Reason != reason {
t.Fatalf("log rows: %+v, want one of reason %q", rows, reason)
}
})
}
}