sender check: a Sender: line is held to the rule From: is held to
test / test (push) Waiting to run

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
+6 -2
View File
@@ -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) {