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:
@@ -177,10 +177,14 @@ func deliveryEvents(row store.SendLogRow, policy postfix.RetryPolicy, sendsAs vi
|
||||
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
||||
if row.Status == store.StatusRejected {
|
||||
switch row.Reason {
|
||||
case store.ReasonFrom:
|
||||
case store.ReasonFrom, store.ReasonSender:
|
||||
// The one step that names things: which line, whose, and what that
|
||||
// application may write instead.
|
||||
text := view.Rich(" — ", view.Code("From: "+row.HeaderFrom), " is not an address of application ", view.Mono(row.AppLogin))
|
||||
field := "From: "
|
||||
if row.Reason == store.ReasonSender {
|
||||
field = "Sender: "
|
||||
}
|
||||
text := view.Rich(" — ", view.Code(field+row.HeaderFrom), " is not an address of application ", view.Mono(row.AppLogin))
|
||||
if len(sendsAs) > 0 {
|
||||
text = append(append(text, view.Inline{Text: ", which sends as "}), sendsAs...)
|
||||
}
|
||||
|
||||
@@ -214,6 +214,25 @@ func TestMessagePageOfARefusedMessage(t *testing.T) {
|
||||
lacks(t, "refused message", body, `<pre`)
|
||||
}
|
||||
|
||||
// A message refused for its Sender: line says so - which line, and whose
|
||||
// address it is not - and the log tags it apart from a From: refusal.
|
||||
func TestMessagePageOfASenderRefusal(t *testing.T) {
|
||||
h, _ := serverWithDelivery(t)
|
||||
if err := h.store.InsertRejected(store.SendLogEntry{
|
||||
Domain: "bs.example.ru", AppLogin: "Queuer3C", From: "noreply@bs.example.ru", To: "late@example.ru", Subject: "On behalf",
|
||||
Reason: store.ReasonSender, HeaderFrom: "ceo@bank.example",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := h.store.QuerySendLog(store.SendLogFilter{AllDomains: true}, 1, 0)
|
||||
if err != nil || len(rows) != 1 || rows[0].Reason != store.ReasonSender {
|
||||
t.Fatalf("query: %v (%+v)", err, rows)
|
||||
}
|
||||
body := getBody(t, h.HandleDelivery, "/outbound/log/"+itoa(rows[0].ID))
|
||||
has(t, "Sender refusal", body, `Refused before queueing`, `<code>Sender: ceo@bank.example</code> is not an address of application`, `wrong Sender`)
|
||||
has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `">wrong Sender</span>`)
|
||||
}
|
||||
|
||||
// Whatever the viewer reaches, another tenant's message is a 404 on its own
|
||||
// page whether the row exists or not.
|
||||
func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user