Files
selfpost/test/e2e/sender_line_test.go
mixandClaude Opus 5.5 0cceb549cc
test / test (push) Waiting to run
sender check: a Sender: line is held to the rule From: is held to
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>
2026-10-16 07:41:42 +03:00

68 lines
2.2 KiB
Go

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)
}
}