diff --git a/CHANGELOG.md b/CHANGELOG.md index 71af0b7..59a7a1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version OpenDKIM did: the line must be one address at a domain the application may send as, and the 256-byte limit of OpenDKIM's buffer goes. The key is the login's, so no `From:` line can choose another tenant's. +- **A `Sender:` line is held to the same rule as `From:`** when a message + has one - the address a mail program shows as "on behalf of". A message + with another domain's address there, or with two `Sender:` lines, is + refused with `550 5.7.1 Sender: …`, tagged `wrong Sender` in the + Outbound log. A system writing for people outside your domains puts its + own address in `From:` and theirs in `Reply-To:`. ### Fixed diff --git a/docs/architecture.md b/docs/architecture.md index d87a3d2..0ecd15b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -418,7 +418,11 @@ the hole it closed was the same. `` - is refused. That address, its local part unquoted as Postfix holds the envelope's, is what the bindings are asked about, folded to lower case in ASCII only: a Unicode fold would make the - Kelvin sign a `k` and let a look-alike pass. The check asks Postfix for + Kelvin sign a `k` and let a look-alike pass. Since `2.8.0` a `Sender:` + line - the sending agent's, shown beside `From:` as "on behalf of" - is + held to the same rule when the message has one (at most one; the owner's + decision, 2026-10-16): the reply says `Sender:` instead of `From:` and the + row's reason is `sender`. `From:` is judged first. The check asks Postfix for header values with their leading space (`SMFIP_HDR_LEADSPC`), as the signer does, so both read the same bytes. Until `2.8.0` the line was also read with a port of OpenDKIM's own parser, so that the address judged was diff --git a/docs/guide.md b/docs/guide.md index edba29a..891b476 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1345,6 +1345,17 @@ is permanent, so the application does not send the message again; it is a row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's page shows what the line said. +Since `2.8.0` a **`Sender:` line** - the one a mail program shows beside +`From:` as "on behalf of" - is held to the same rule when the message has +one: one address, one the application may send as, and no more than one +such line. Otherwise the message is refused the same way, `550 5.7.1 +Sender: …`, tagged `wrong Sender`. Most mail libraries write no `Sender:`; +one that writes the application's own address passes. A system that writes +for people outside your domains - a ticket system, a CRM - puts its own +address in `From:` with the person's name (`"Jane via Support" +`) and the person's address in `Reply-To:`: their domain in +`From:` would fail DMARC at the receiver anyway. + What this stops working when you upgrade from an earlier version: - an application that sends with the `From:` of another of your domains - @@ -1353,7 +1364,10 @@ What this stops working when you upgrade from an earlier version: differ - add the second address to the list; - a contact form that puts its visitor's address into `From:` - put it into `Reply-To:` and send from an address of your own, which is also what - DMARC requires. + DMARC requires; +- (`2.8.0`) a message with a `Sender:` of another domain, or of an address + the application is not listed for - drop the line or write the + application's own address there. The check is strict: while the panel does not answer, Postfix answers with a temporary error (`451`, and `454` to the login): an application that queues diff --git a/docs/plans/panel-dkim-signing.md b/docs/plans/panel-dkim-signing.md index bbf3000..044a2b5 100644 --- a/docs/plans/panel-dkim-signing.md +++ b/docs/plans/panel-dkim-signing.md @@ -209,8 +209,9 @@ libmilter1.0.1. *Done (2026-10-16):* help and Health went with stage 2 (design `005922b`); the reviews ran on stages 1-2 and their findings are fixed in `c2f63d2`, with the first rollout's incident (the queue's owner numbers); the -documents in the commit after it. Open: holding `Sender:` to the login -(security review F1, the owner's decision), and go-milter v0.4.1 keeping +documents in the commit after it. `Sender:` is held to the login like +`From:` (security review F1; the owner's decision, 2026-10-16). Open: +go-milter v0.4.1 keeping every header in a map of its own per session (security.md, accepted risks) - a header section the signer refuses past 1 MiB is still held by the library, on 465/587 and on port 25 alike, as before 2.8.0. diff --git a/docs/security.md b/docs/security.md index 1f472ca..0aac35f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -61,9 +61,10 @@ bounded only by `message_size_limit` (now 1 MiB, refused above it); the old key tree stayed readable by group `selfpost` and went into every backup (now the panel's alone and left out); a hostname with capitals deferred every notification (the server's name is kept in lower case for DKIM); only `From` -was over-signed (now the shown headers too) - all fixed. `Sender:` is now -signed but not held to the login, which waits for the owner; that and what the -header cap leaves are under Accepted risks. +was over-signed (now the shown headers too) - all fixed. `Sender:`, which +the review found neither held nor signed (F1), is now both: signed, and held +to the login like `From:` (the owner's decision, 2026-10-16). What the header +cap leaves is under Accepted risks. (2) **Accepted risks** — deliberate departures beyond the mandatory, recorded so the decision is not lost. @@ -318,7 +319,10 @@ operator can act on it. What this does not promise is what a display name looks like to a person: a name of another sender in it passes, signed with the application's own key - a forged display name is as old as mail, and no key of another's is - involved. Nor does it look at `Sender:` (Accepted risks). What the client + involved. Since `2.8.0` a `Sender:` line - shown beside `From:` as "on + behalf of" - is held to the same rule when there is one, and refused as + `550 5.7.1 Sender: ...`; `Resent-From:` is signed but not judged + (Accepted risks). What the client wrote reaches the reply, the log and the send log only as printable ASCII, cut, and a refused message is one row of the log whatever its recipient count. Until `2.4.0` nothing held the header: OpenDKIM chose the key by it @@ -715,25 +719,16 @@ deferred item from the roadmap. signed with that domain's key. Whoever may export the domain has the key, and its applications may send as `postmaster@` by right. The guide says so; a hostname that is nobody's sending domain avoids it. -- **`Sender:` is signed but not held to the login** (open: the owner's - decision). Since `2.8.0` the signer signs `Sender:` and `Resent-From:` - and over-signs `Sender:`, so neither can be added or changed after the - message leaves without breaking the signature; the sender check judges - `From:` alone (`senderSession.Header` keeps `From` and `Subject`). An - application of `mine.com` can write `From: billing@mine.com` and - `Sender: ceo@bank.com`: the check passes it, the signature is - `d=mine.com`, DMARC aligns on `From:` and passes, and a mail program that - shows `Sender:` beside `From:` - Outlook as "on behalf of", by the - review's account, not tried on this build - shows the other address. No - key of another's is involved, as with a display name; until `2.8.0` the - header was neither checked nor signed. The fix the security review of - 2026-10-16 (F1) gives, and recommends: the check also reads `Sender:` and - `Resent-From:` and refuses one whose single address the application may - not send as, under the rule `From:` is held to - a few lines in - `internal/milter/sender.go` and their tests. It changes what is refused - (an application whose mail program writes a `Sender:` of another domain - today would be refused), hence the owner's decision; this item goes when - it is taken. +- **`Resent-From:` is signed but not held to the login.** Since `2.8.0` + the sender check holds `Sender:` to the rule `From:` is held to (the + owner's decision, 2026-10-16; security review F1), and the signer signs + both `Sender:` and `Resent-From:`. `Resent-From:` - written when a + message is passed on again (RFC 5322 § 3.6.6) - is not judged: an + application of `mine.com` could write `Resent-From: ceo@bank.com` beside + its own `From:`, signed `d=mine.com`. Few mail programs show it, and no + key of another's is involved, as with a display name. Holding it too is a + few lines in `internal/milter/sender.go` (`judge()`); the trigger to do it + is a mail program that shows it in place of `From:`. - **A header section is bounded at 1 MiB by the signer, not by the milter library.** The signer counts a message's headers as Postfix hands them over and refuses a section of more than 1 MiB with `552 5.3.4` diff --git a/internal/logtail/refusals.go b/internal/logtail/refusals.go index 1870311..0a81f09 100644 --- a/internal/logtail/refusals.go +++ b/internal/logtail/refusals.go @@ -59,7 +59,10 @@ type RefusalStore interface { // src/cleanup/cleanup_milter.c, cleanup_milter_apply(): "%s: %s: %s from // %s[%s]: %s;" with the reply from its fifth byte (the "550 " dropped), then // " from=<..>", " to=<..>" - the last recipient only (assumptions A-15). -var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d+\]: ([0-9A-Za-z]+): milter-reject: END-OF-MESSAGE from \S+: 5\.7\.1 From: ([^;]*) - ([^;]*); from=<([^>]*)> to=<([^>]*)>`) +// +// Since 2.8.0 the check refuses for a Sender: line too, "5.7.1 Sender: ..." +// in the same form; the second group says which. +var fromRefusalRe = regexp.MustCompile(`^\S+ postfix/(?:[a-z0-9-]+/)?cleanup\[\d+\]: ([0-9A-Za-z]+): milter-reject: END-OF-MESSAGE from \S+: 5\.7\.1 (From|Sender): ([^;]*) - ([^;]*); from=<([^>]*)> to=<([^>]*)>`) // clientLoginRe is smtpd's line naming the login a queue id was submitted // under: ": client=host[addr], sasl_method=PLAIN, sasl_username=". @@ -137,10 +140,14 @@ func (r *refusalLines) handle(line string, now time.Time, start Place) { if ok && login.start.FP == start.FP && login.start.End < start.End { from = login.start } + reason := store.ReasonFrom + if m[2] == "Sender" { + reason = store.ReasonSender + } r.recover = append(r.recover, pendingRecovery{ e: store.SendLogEntry{ - QueueID: m[1], AppLogin: login.login, From: m[4], To: m[5], Domain: domainOf(m[4]), - Reason: store.ReasonFrom, HeaderFrom: m[2], + QueueID: m[1], AppLogin: login.login, From: m[5], To: m[6], Domain: domainOf(m[5]), + Reason: reason, HeaderFrom: m[3], }, at: at, until: now.Add(refusalLineWait), start: from, }) diff --git a/internal/logtail/refusals_test.go b/internal/logtail/refusals_test.go index f8e3769..da424f4 100644 --- a/internal/logtail/refusals_test.go +++ b/internal/logtail/refusals_test.go @@ -98,6 +98,18 @@ func TestFromRefusalIsRecoveredAfterTheWait(t *testing.T) { if e != want { t.Fatalf("recovered %+v, want %+v", e, want) } + + // A refusal for the Sender: line (2.8.0) is recovered as one: composed + // after the From: line above, with the check's reply as sender.go writes + // it. + st = &refusalProbe{} + r = &refusalLines{st: st} + r.handle(`Oct 14 12:00:00 mail postfix/smtpd[11]: F1A2B3C4D5E6: client=unknown[10.77.0.1], sasl_method=PLAIN, sasl_username=app1`, now, Place{}) + r.handle(`Oct 14 12:00:01 mail postfix/cleanup[13]: F1A2B3C4D5E6: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 Sender: ceo@bank.example - not an address this application sends as; from= to= proto=ESMTP helo=`, now, Place{}) + r.retry(now.Add(refusalLineWait + time.Second)) + if len(st.recovered) != 1 || st.recovered[0].Reason != store.ReasonSender || st.recovered[0].HeaderFrom != "ceo@bank.example" || st.recovered[0].From != "alerts@sender.e2e.test" { + t.Fatalf("recovered %+v, want one Sender: refusal", st.recovered) + } } // While a line or a recovery waits, the offset saved for its file stays at the diff --git a/internal/milter/sender.go b/internal/milter/sender.go index 311283b..498d89d 100644 --- a/internal/milter/sender.go +++ b/internal/milter/sender.go @@ -284,6 +284,7 @@ type senderSession struct { rcpts []string subject string froms []string // the value of every From: field of the message + senders []string // and of every Sender: field } // MailFrom starts a message and forgets the one before it: a connection may @@ -297,7 +298,7 @@ type senderSession struct { func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) { s.from = cleanAddress(from) s.login = macro(m, "auth_authen") - s.rcpts, s.subject, s.froms = nil, "", nil + s.rcpts, s.subject, s.froms, s.senders = nil, "", nil, nil return milter.RespContinue, nil } @@ -311,6 +312,8 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R switch textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)) { case "From": s.froms = append(s.froms, value) + case "Sender": + s.senders = append(s.senders, value) case "Subject": if s.subject == "" { s.subject = mailhdr.DecodeSubject(value) @@ -333,7 +336,7 @@ func (s *senderSession) Header(name, value string, m *milter.Modifier) (milter.R // second message it can still hold the first one's From:. The session keeps // its own, emptied at each MAIL FROM (audit 2026-10-12, L9). func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (milter.Response, error) { - shown, why, err := s.judge() + field, shown, why, err := s.judge() if err != nil { // Strict: what cannot be checked is not let through. log.Printf("sender-check: %v - deferring mail of %q", err, s.login) @@ -342,27 +345,41 @@ func (s *senderSession) Headers(h textproto.MIMEHeader, m *milter.Modifier) (mil if why == "" { return milter.RespContinue, nil } - log.Printf("sender-check: application %q refused: %s (From: %s)", s.login, why, shown) + log.Printf("sender-check: application %q refused: %s (%s: %s)", s.login, why, field, shown) // cleanup has given the message a queue id by the end of its headers // (milter_end_of_header_macros has {i}); Postfix's lines about it - the // refusal among them - are found by it on the message's page. // Postfix 3.10.13, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i", // answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval(). - s.record(shown, macro(m, "i")) + reason := store.ReasonFrom + if field == "Sender" { + reason = store.ReasonSender + } + s.record(reason, shown, macro(m, "i")) // A reply code of its own, so that the application is told what is wrong // and, the refusal being permanent, does not send the message again. - return milter.NewResponseStr('y', "550 5.7.1 From: "+shown+" - "+why), nil + return milter.NewResponseStr('y', "550 5.7.1 "+field+": "+shown+" - "+why), nil } func (s *senderSession) Abort(m *milter.Modifier) error { - s.rcpts, s.subject, s.froms = nil, "", nil + s.rcpts, s.subject, s.froms, s.senders = nil, "", nil, nil return nil } // judge decides about the message at hand. why is empty when it may pass; -// otherwise it says what is wrong, and shown is the From: line in a form that -// is safe to print - both go into the reply to the client. -func (s *senderSession) judge() (shown, why string, err error) { +// otherwise it says what is wrong, field is the header it is about ("From" +// or "Sender"), and shown is that line in a form that is safe to print - all +// go into the reply to the client. +// +// A Sender: line - the agent that sent a message written by the From: +// line's author (RFC 5322 § 3.6.2), which a mail program shows beside From: +// as "on behalf of" - is held to the same rule as From: when the message has +// one, and at most one is allowed (§ 3.6, its table). The panel's signer +// signs it (sign.go), so an address of another sender there would be shown +// under this server's signature (security review of panel-dkim-signing, F1; +// the owner's decision, 2026-10-16). +func (s *senderSession) judge() (field, shown, why string, err error) { + field = "From" if s.login == "" { // Not reachable while the submission ports demand authentication; a // message nobody answers for is not signed either way. @@ -370,15 +387,32 @@ func (s *senderSession) judge() (shown, why string, err error) { // smtpd_delay_reject (default yes) smtpd_client_restrictions // (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT; // src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message. - return "(none)", "no authenticated application", nil + return field, "(none)", "no authenticated application", nil } switch len(s.froms) { case 0: - return "(none)", "the message has no From: line", nil + return field, "(none)", "the message has no From: line", nil case 1: default: - return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil + return field, printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil } + if shown, why, err = s.judgeLine(s.froms[0]); err != nil || why != "" { + return field, shown, why, err + } + field = "Sender" + switch len(s.senders) { + case 0: + return field, "", "", nil + case 1: + default: + return field, printable(strings.Join(s.senders, " | ")), "the message has more than one Sender: line", nil + } + shown, why, err = s.judgeLine(s.senders[0]) + return field, shown, why, err +} + +// judgeLine holds one From: or Sender: line against the application. +func (s *senderSession) judgeLine(raw string) (shown, why string, err error) { // The line must be one address in RFC 5322's grammar (net/mail), the // address a mail program shows; a display name may say anything. A line // a lenient program could show as another address - "ceo@bank.com @@ -389,7 +423,6 @@ func (s *senderSession) judge() (shown, why string, err error) { // 3.10.13, src/global/tok822_parse.c), is the one held against the // bindings: "x"@mine.com is x@mine.com, which may be another // application's (F2). - raw := s.froms[0] shown = printable(raw) list, perr := fromParser.ParseList(unfold(raw)) if perr != nil || len(list) != 1 { @@ -439,7 +472,7 @@ func asciiLower(v string) string { // each recipient as an accepted message has: nothing was sent to anybody, and // a refused message with a thousand recipients would otherwise be a thousand // rows at the price of one. -func (s *senderSession) record(shown, queueID string) { +func (s *senderSession) record(reason, shown, queueID string) { if s.refused == nil { return } @@ -457,7 +490,7 @@ func (s *senderSession) record(shown, queueID string) { From: s.from, To: to, Subject: s.subject, - Reason: store.ReasonFrom, + Reason: reason, HeaderFrom: shown, } select { diff --git a/internal/milter/sender_test.go b/internal/milter/sender_test.go index 9b8d320..465f904 100644 --- a/internal/milter/sender_test.go +++ b/internal/milter/sender_test.go @@ -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" `)}, "", ""}, + {"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) + } + }) + } +} diff --git a/internal/store/sendercheck.go b/internal/store/sendercheck.go index 4655199..ec086f2 100644 --- a/internal/store/sendercheck.go +++ b/internal/store/sendercheck.go @@ -11,6 +11,7 @@ const ( ReasonLimit = "limit" // a level-2 rate limit ReasonClient = "client" // the application's list of client addresses ReasonFrom = "from" // the From: line is not the application's + ReasonSender = "sender" // the Sender: line is not the application's (2.8.0) ) // Who decided a "bounced" row the log did not (send_log.reason). diff --git a/internal/store/sendlog.go b/internal/store/sendlog.go index c937267..7d54243 100644 --- a/internal/store/sendlog.go +++ b/internal/store/sendlog.go @@ -20,8 +20,8 @@ const ( // refusal. The journal-milter refuses at MAIL FROM with a 4xx when a level-2 // rate limit or the application's client-address list says no (Reason is // ReasonLimit or ReasonClient; such a row has no queue-id). The sender check - // refuses a From: line that is not the application's with a 550 (ReasonFrom; - // its row has the queue-id). Every refused row is left out of the level-2 + // refuses a From: or Sender: line that is not the application's with a + // 550 (ReasonFrom, ReasonSender; its row has the queue-id). Every refused row is left out of the level-2 // message count (it was never sent). StatusRejected = "rejected" ) @@ -38,7 +38,8 @@ type SendLogEntry struct { To string Subject string // Reason and HeaderFrom are set on refused rows only: why the message - // was refused (Reason*), and for ReasonFrom what its From: line said. + // was refused (Reason*), and for ReasonFrom and ReasonSender the address + // of the line refused (the column's name is from before Sender: was). Reason string HeaderFrom string // ClientIP is the client a refusal at MAIL FROM came from (InsertRejected diff --git a/internal/web/handlers/handlers_monitor.go b/internal/web/handlers/handlers_monitor.go index a533c72..02f4ded 100644 --- a/internal/web/handlers/handlers_monitor.go +++ b/internal/web/handlers/handlers_monitor.go @@ -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...) } diff --git a/internal/web/handlers/handlers_outbound_log_test.go b/internal/web/handlers/handlers_outbound_log_test.go index cb9b717..4fac078 100644 --- a/internal/web/handlers/handlers_outbound_log_test.go +++ b/internal/web/handlers/handlers_outbound_log_test.go @@ -214,6 +214,25 @@ func TestMessagePageOfARefusedMessage(t *testing.T) { lacks(t, "refused message", body, `Sender: ceo@bank.example is not an address of application`, `wrong Sender`) + has(t, "log", getBody(t, h.HandleDeliveries, "/outbound/log"), `">wrong Sender`) +} + // 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) { diff --git a/internal/web/view/help.go b/internal/web/view/help.go index 396b7a6..fba66df 100644 --- a/internal/web/view/help.go +++ b/internal/web/view/help.go @@ -166,10 +166,10 @@ var outboundTopics = []HelpTopic{ Plain("An application is one SASL login, unique across all domains. It may send as any address of its domain or only as listed addresses, and, with a list of client IPs set, only from those addresses. The password is shown once at creation or regeneration and is not stored in readable form."), }}, {ID: HelpSenderCheck, Title: "Sender check", Body: []Text{ - Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ", + Rich("An application sends only as itself, and that holds for every sender a message names. The envelope sender is held against the login by Postfix. The ", Code("From:"), " line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ", Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ", - Code("550 5.7.1"), ". The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.", + Code("550 5.7.1"), ". The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. A ", Code("Sender:"), " line, which a mail program shows beside ", Code("From:"), " as \"on behalf of\", is held to the same rule when the message has one. A system that writes for people outside your domains writes its own address in ", Code("From:"), " with the person's name, and the person's address in ", Code("Reply-To:"), ": their domain in ", Code("From:"), " would fail DMARC at the receiver anyway. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.", Br(), Br(), "The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."), }}, @@ -177,7 +177,7 @@ var outboundTopics = []HelpTopic{ Rich("Level 1 is a per-IP ceiling set in ", Code(".env"), ". Level 2 is optional, per domain and per application, entered by hand or computed from the last 30 days of sending, and can never exceed level 1. An application's own limit replaces its domain's."), }}, {ID: HelpLog, Title: "Outbound log", Body: []Text{ - Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and ", Code("mail.log"), " never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ", + Rich("One row per message and recipient, with the status Postfix last reported: queued (accepted, nothing reported yet), sent (the receiving server took it; what the mailbox does next is not reported back), deferred (not taken yet, Postfix tries again), bounced (failed for good, or the queue lifetime ran out; or closed by the panel when Postfix no longer held the message and ", Code("mail.log"), " never said what became of it) and rejected (refused before queueing, so it never reached the queue: under a level-2 rate limit, from a client address the application does not allow, or for a ", Code("From:"), " or ", Code("Sender:"), " line that is not the application's - the message's page says which). A message's page lists these steps and the lines Postfix wrote about it in ", Code("mail.log"), ", found by queue id - and for a message refused before it had one, Postfix's one line about the refusal. The lines are kept with the row: they stay on the page after that file has rotated, for as long as the row does. A message accepted before version 2.2.0 shows them only while they are still in the current file. Rows are deleted after the retention set in Server › Settings, 90 days by default, and their lines with them. A domain administrator sees only the rows of their own domains."), }}, {ID: HelpQueue, Title: "Queue and retries", Body: []Text{ diff --git a/internal/web/view/outbound_log.go b/internal/web/view/outbound_log.go index d5d39f1..5ae1e2b 100644 --- a/internal/web/view/outbound_log.go +++ b/internal/web/view/outbound_log.go @@ -237,6 +237,8 @@ func SendLogTag(status, reason string) Tag { switch reason { case "from": return Tag{Status: "reject", Label: "wrong From"} + case "sender": + return Tag{Status: "reject", Label: "wrong Sender"} case "client": return Tag{Status: "reject", Label: "wrong client"} case "limit": diff --git a/internal/web/view/outbound_log_test.go b/internal/web/view/outbound_log_test.go index 7b1dcaf..41572a3 100644 --- a/internal/web/view/outbound_log_test.go +++ b/internal/web/view/outbound_log_test.go @@ -176,6 +176,7 @@ func TestSendLogTag(t *testing.T) { {"rejected", "", Tag{Status: "rejected"}}, {"rejected", "limit", Tag{Status: "rate-limited"}}, {"rejected", "from", Tag{Status: "reject", Label: "wrong From"}}, + {"rejected", "sender", Tag{Status: "reject", Label: "wrong Sender"}}, {"rejected", "client", Tag{Status: "reject", Label: "wrong client"}}, } { if got := SendLogTag(c.status, c.reason); got != c.want { diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index b6daa52..730072d 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -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) }) diff --git a/test/e2e/sender_line_test.go b/test/e2e/sender_line_test.go new file mode 100644 index 0000000..35f3cbf --- /dev/null +++ b/test/e2e/sender_line_test.go @@ -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 ", + "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`), nil + }); err != nil { + t.Error(err) + } +}