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:
@@ -327,6 +327,9 @@ func TestE2E(t *testing.T) {
|
||||
run("sender_check_holds_the_from_line", func(t *testing.T) {
|
||||
testSenderCheck(t, sc)
|
||||
})
|
||||
run("sender_check_holds_the_sender_line", func(t *testing.T) {
|
||||
testSenderLine(t, sc)
|
||||
})
|
||||
run("passed_from_lines_signed_for_their_domain_only", func(t *testing.T) {
|
||||
testSignedForItsDomainOnly(t, sc)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testSenderLine: a Sender: line is held to the rule the From: line is held
|
||||
// to (the owner's decision, 2026-10-16): the application's own address passes
|
||||
// and is signed; another domain's is refused for good at the end of DATA,
|
||||
// "550 5.7.1 Sender: ...", and is a row of the Outbound log tagged "wrong
|
||||
// Sender" (internal/milter/sender.go, judge()).
|
||||
func testSenderLine(t *testing.T, sc *scenario) {
|
||||
message := func(token, sender string) string {
|
||||
return strings.Join([]string{
|
||||
"From: Alerts <alerts@" + senderDomain + ">",
|
||||
"Sender: " + sender,
|
||||
"To: " + recipient,
|
||||
"Subject: e2e " + token,
|
||||
"Date: " + time.Now().UTC().Format(time.RFC1123Z),
|
||||
"",
|
||||
"sender line check",
|
||||
"",
|
||||
}, "\r\n")
|
||||
}
|
||||
|
||||
own := uniqueToken("senderown")
|
||||
res := attemptSend(sendAttempt{
|
||||
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||
from: "alerts@" + senderDomain, to: recipient, raw: message(own, "mailer@"+senderDomain),
|
||||
})
|
||||
if !res.ok() {
|
||||
t.Fatalf("its own address in Sender: was refused: %v", res.firstErr())
|
||||
}
|
||||
file, delivered, err := findSinkFile(h.stageDir, own, 30*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("never reached the sink: %v", err)
|
||||
}
|
||||
if err := checkPanelSignature(h, file, delivered, senderDomain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if signedNames := strings.ToLower(dkimTagsOf(t, delivered)["h"]); !strings.Contains(signedNames, "sender:") {
|
||||
t.Errorf("h=%s does not sign Sender", signedNames)
|
||||
}
|
||||
|
||||
foreign := uniqueToken("senderforeign")
|
||||
res = attemptSend(sendAttempt{
|
||||
authLogin: sc.appLogin, authPassword: sc.appPassword,
|
||||
from: "alerts@" + senderDomain, to: recipient, raw: message(foreign, "ceo@"+otherDomain),
|
||||
})
|
||||
if err := res.refusedAt("DATA", 550, "5.7.1 Sender:"); err != nil {
|
||||
t.Fatalf("another domain in Sender: %v", err)
|
||||
}
|
||||
if _, err := findSinkMessage(h.stageDir, foreign, 2*time.Second); err == nil {
|
||||
t.Error("the refused message reached the sink")
|
||||
}
|
||||
if err := waitFor("the Sender: refusal in the Outbound log", 15*time.Second, 500*time.Millisecond, func() (bool, error) {
|
||||
rows, err := sc.panel.sendLogRows(senderDomain, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.Contains(rows, `">wrong Sender</span>`), nil
|
||||
}); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user