sender check: a Sender: line is held to the rule From: is held to
test / test (push) Canceled after 0s
test / test (push) Canceled after 0s
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:
@@ -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
|
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
|
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.
|
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
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -418,7 +418,11 @@ the hole it closed was the same.
|
|||||||
`<b@evil.com, a@mine.com>` - is refused. That address, its local part
|
`<b@evil.com, a@mine.com>` - is refused. That address, its local part
|
||||||
unquoted as Postfix holds the envelope's, is what the bindings are asked
|
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
|
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
|
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
|
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
|
read with a port of OpenDKIM's own parser, so that the address judged was
|
||||||
|
|||||||
+15
-1
@@ -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
|
row of the [Outbound log](#outbound-log) tagged `wrong From`, and the row's
|
||||||
page shows what the line said.
|
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"
|
||||||
|
<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:
|
What this stops working when you upgrade from an earlier version:
|
||||||
|
|
||||||
- an application that sends with the `From:` of another of your domains -
|
- 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;
|
differ - add the second address to the list;
|
||||||
- a contact form that puts its visitor's address into `From:` - put it into
|
- 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
|
`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
|
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
|
temporary error (`451`, and `454` to the login): an application that queues
|
||||||
|
|||||||
@@ -209,8 +209,9 @@ libmilter1.0.1.
|
|||||||
*Done (2026-10-16):* help and Health went with stage 2 (design `005922b`);
|
*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`,
|
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
|
with the first rollout's incident (the queue's owner numbers); the
|
||||||
documents in the commit after it. Open: holding `Sender:` to the login
|
documents in the commit after it. `Sender:` is held to the login like
|
||||||
(security review F1, the owner's decision), and go-milter v0.4.1 keeping
|
`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)
|
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
|
- 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.
|
library, on 465/587 and on port 25 alike, as before 2.8.0.
|
||||||
|
|||||||
+18
-23
@@ -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
|
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
|
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`
|
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
|
was over-signed (now the shown headers too) - all fixed. `Sender:`, which
|
||||||
signed but not held to the login, which waits for the owner; that and what the
|
the review found neither held nor signed (F1), is now both: signed, and held
|
||||||
header cap leaves are under Accepted risks.
|
to the login like `From:` (the owner's decision, 2026-10-16). What the header
|
||||||
|
cap leaves is under Accepted risks.
|
||||||
(2) **Accepted risks** —
|
(2) **Accepted risks** —
|
||||||
deliberate departures beyond the mandatory, recorded so the decision is not
|
deliberate departures beyond the mandatory, recorded so the decision is not
|
||||||
lost.
|
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:
|
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
|
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
|
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,
|
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
|
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
|
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,
|
signed with that domain's key. Whoever may export the domain has the key,
|
||||||
and its applications may send as `postmaster@<hostname>` by right. The
|
and its applications may send as `postmaster@<hostname>` by right. The
|
||||||
guide says so; a hostname that is nobody's sending domain avoids it.
|
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
|
- **`Resent-From:` is signed but not held to the login.** Since `2.8.0`
|
||||||
decision). Since `2.8.0` the signer signs `Sender:` and `Resent-From:`
|
the sender check holds `Sender:` to the rule `From:` is held to (the
|
||||||
and over-signs `Sender:`, so neither can be added or changed after the
|
owner's decision, 2026-10-16; security review F1), and the signer signs
|
||||||
message leaves without breaking the signature; the sender check judges
|
both `Sender:` and `Resent-From:`. `Resent-From:` - written when a
|
||||||
`From:` alone (`senderSession.Header` keeps `From` and `Subject`). An
|
message is passed on again (RFC 5322 § 3.6.6) - is not judged: an
|
||||||
application of `mine.com` can write `From: billing@mine.com` and
|
application of `mine.com` could write `Resent-From: ceo@bank.com` beside
|
||||||
`Sender: ceo@bank.com`: the check passes it, the signature is
|
its own `From:`, signed `d=mine.com`. Few mail programs show it, and no
|
||||||
`d=mine.com`, DMARC aligns on `From:` and passes, and a mail program that
|
key of another's is involved, as with a display name. Holding it too is a
|
||||||
shows `Sender:` beside `From:` - Outlook as "on behalf of", by the
|
few lines in `internal/milter/sender.go` (`judge()`); the trigger to do it
|
||||||
review's account, not tried on this build - shows the other address. No
|
is a mail program that shows it in place of `From:`.
|
||||||
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.
|
|
||||||
- **A header section is bounded at 1 MiB by the signer, not by the milter
|
- **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
|
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`
|
over and refuses a section of more than 1 MiB with `552 5.3.4`
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ type RefusalStore interface {
|
|||||||
// src/cleanup/cleanup_milter.c, cleanup_milter_apply(): "%s: %s: %s from
|
// 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
|
// %s[%s]: %s;" with the reply from its fifth byte (the "550 " dropped), then
|
||||||
// " from=<..>", " to=<..>" - the last recipient only (assumptions A-15).
|
// " 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
|
// clientLoginRe is smtpd's line naming the login a queue id was submitted
|
||||||
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
|
// under: "<id>: client=host[addr], sasl_method=PLAIN, sasl_username=<login>".
|
||||||
@@ -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 {
|
if ok && login.start.FP == start.FP && login.start.End < start.End {
|
||||||
from = login.start
|
from = login.start
|
||||||
}
|
}
|
||||||
|
reason := store.ReasonFrom
|
||||||
|
if m[2] == "Sender" {
|
||||||
|
reason = store.ReasonSender
|
||||||
|
}
|
||||||
r.recover = append(r.recover, pendingRecovery{
|
r.recover = append(r.recover, pendingRecovery{
|
||||||
e: store.SendLogEntry{
|
e: store.SendLogEntry{
|
||||||
QueueID: m[1], AppLogin: login.login, From: m[4], To: m[5], Domain: domainOf(m[4]),
|
QueueID: m[1], AppLogin: login.login, From: m[5], To: m[6], Domain: domainOf(m[5]),
|
||||||
Reason: store.ReasonFrom, HeaderFrom: m[2],
|
Reason: reason, HeaderFrom: m[3],
|
||||||
},
|
},
|
||||||
at: at, until: now.Add(refusalLineWait), start: from,
|
at: at, until: now.Add(refusalLineWait), start: from,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -98,6 +98,18 @@ func TestFromRefusalIsRecoveredAfterTheWait(t *testing.T) {
|
|||||||
if e != want {
|
if e != want {
|
||||||
t.Fatalf("recovered %+v, want %+v", 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=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<x>`, 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
|
// While a line or a recovery waits, the offset saved for its file stays at the
|
||||||
|
|||||||
+48
-15
@@ -284,6 +284,7 @@ type senderSession struct {
|
|||||||
rcpts []string
|
rcpts []string
|
||||||
subject string
|
subject string
|
||||||
froms []string // the value of every From: field of the message
|
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
|
// 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) {
|
func (s *senderSession) MailFrom(from string, m *milter.Modifier) (milter.Response, error) {
|
||||||
s.from = cleanAddress(from)
|
s.from = cleanAddress(from)
|
||||||
s.login = macro(m, "auth_authen")
|
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
|
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)) {
|
switch textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)) {
|
||||||
case "From":
|
case "From":
|
||||||
s.froms = append(s.froms, value)
|
s.froms = append(s.froms, value)
|
||||||
|
case "Sender":
|
||||||
|
s.senders = append(s.senders, value)
|
||||||
case "Subject":
|
case "Subject":
|
||||||
if s.subject == "" {
|
if s.subject == "" {
|
||||||
s.subject = mailhdr.DecodeSubject(value)
|
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
|
// 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).
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
// Strict: what cannot be checked is not let through.
|
// Strict: what cannot be checked is not let through.
|
||||||
log.Printf("sender-check: %v - deferring mail of %q", err, s.login)
|
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 == "" {
|
if why == "" {
|
||||||
return milter.RespContinue, nil
|
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
|
// 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
|
// (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.
|
// 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",
|
// Postfix 3.10.13, src/global/mail_params.h: DEF_MILT_EOH_MACROS "i",
|
||||||
// answered by src/cleanup/cleanup_milter.c, cleanup_milter_eval().
|
// 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
|
// 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.
|
// 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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// judge decides about the message at hand. why is empty when it may pass;
|
// 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
|
// otherwise it says what is wrong, field is the header it is about ("From"
|
||||||
// is safe to print - both go into the reply to the client.
|
// or "Sender"), and shown is that line in a form that is safe to print - all
|
||||||
func (s *senderSession) judge() (shown, why string, err error) {
|
// 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 == "" {
|
if s.login == "" {
|
||||||
// Not reachable while the submission ports demand authentication; a
|
// Not reachable while the submission ports demand authentication; a
|
||||||
// message nobody answers for is not signed either way.
|
// 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
|
// smtpd_delay_reject (default yes) smtpd_client_restrictions
|
||||||
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
|
// (permit_sasl_authenticated,reject on 465 and 587) refuse every RCPT;
|
||||||
// src/smtpd/smtpd.c, data_cmd(): no valid recipients, no message.
|
// 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) {
|
switch len(s.froms) {
|
||||||
case 0:
|
case 0:
|
||||||
return "(none)", "the message has no From: line", nil
|
return field, "(none)", "the message has no From: line", nil
|
||||||
case 1:
|
case 1:
|
||||||
default:
|
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
|
// 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
|
// address a mail program shows; a display name may say anything. A line
|
||||||
// a lenient program could show as another address - "ceo@bank.com
|
// 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
|
// 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
|
// bindings: "x"@mine.com is x@mine.com, which may be another
|
||||||
// application's (F2).
|
// application's (F2).
|
||||||
raw := s.froms[0]
|
|
||||||
shown = printable(raw)
|
shown = printable(raw)
|
||||||
list, perr := fromParser.ParseList(unfold(raw))
|
list, perr := fromParser.ParseList(unfold(raw))
|
||||||
if perr != nil || len(list) != 1 {
|
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
|
// 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
|
// a refused message with a thousand recipients would otherwise be a thousand
|
||||||
// rows at the price of one.
|
// 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 {
|
if s.refused == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -457,7 +490,7 @@ func (s *senderSession) record(shown, queueID string) {
|
|||||||
From: s.from,
|
From: s.from,
|
||||||
To: to,
|
To: to,
|
||||||
Subject: s.subject,
|
Subject: s.subject,
|
||||||
Reason: store.ReasonFrom,
|
Reason: reason,
|
||||||
HeaderFrom: shown,
|
HeaderFrom: shown,
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -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" <mailer@shop.example.org>`)}, "", ""},
|
||||||
|
{"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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const (
|
|||||||
ReasonLimit = "limit" // a level-2 rate limit
|
ReasonLimit = "limit" // a level-2 rate limit
|
||||||
ReasonClient = "client" // the application's list of client addresses
|
ReasonClient = "client" // the application's list of client addresses
|
||||||
ReasonFrom = "from" // the From: line is not the application's
|
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).
|
// Who decided a "bounced" row the log did not (send_log.reason).
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ const (
|
|||||||
// refusal. The journal-milter refuses at MAIL FROM with a 4xx when a level-2
|
// 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
|
// 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
|
// 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;
|
// refuses a From: or Sender: line that is not the application's with a
|
||||||
// its row has the queue-id). Every refused row is left out of the level-2
|
// 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).
|
// message count (it was never sent).
|
||||||
StatusRejected = "rejected"
|
StatusRejected = "rejected"
|
||||||
)
|
)
|
||||||
@@ -38,7 +38,8 @@ type SendLogEntry struct {
|
|||||||
To string
|
To string
|
||||||
Subject string
|
Subject string
|
||||||
// Reason and HeaderFrom are set on refused rows only: why the message
|
// 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
|
Reason string
|
||||||
HeaderFrom string
|
HeaderFrom string
|
||||||
// ClientIP is the client a refusal at MAIL FROM came from (InsertRejected
|
// ClientIP is the client a refusal at MAIL FROM came from (InsertRejected
|
||||||
|
|||||||
@@ -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.
|
// acceptance: the journal-milter refused it, so Postfix never queued it.
|
||||||
if row.Status == store.StatusRejected {
|
if row.Status == store.StatusRejected {
|
||||||
switch row.Reason {
|
switch row.Reason {
|
||||||
case store.ReasonFrom:
|
case store.ReasonFrom, store.ReasonSender:
|
||||||
// The one step that names things: which line, whose, and what that
|
// The one step that names things: which line, whose, and what that
|
||||||
// application may write instead.
|
// 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 {
|
if len(sendsAs) > 0 {
|
||||||
text = append(append(text, view.Inline{Text: ", which sends as "}), sendsAs...)
|
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`)
|
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
|
// Whatever the viewer reaches, another tenant's message is a 404 on its own
|
||||||
// page whether the row exists or not.
|
// page whether the row exists or not.
|
||||||
func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
|
func TestMessagePageIsNotFoundForOtherTenants(t *testing.T) {
|
||||||
|
|||||||
@@ -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."),
|
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{
|
{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:"), " 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("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(),
|
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."),
|
"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."),
|
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{
|
{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."),
|
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{
|
{ID: HelpQueue, Title: "Queue and retries", Body: []Text{
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ func SendLogTag(status, reason string) Tag {
|
|||||||
switch reason {
|
switch reason {
|
||||||
case "from":
|
case "from":
|
||||||
return Tag{Status: "reject", Label: "wrong From"}
|
return Tag{Status: "reject", Label: "wrong From"}
|
||||||
|
case "sender":
|
||||||
|
return Tag{Status: "reject", Label: "wrong Sender"}
|
||||||
case "client":
|
case "client":
|
||||||
return Tag{Status: "reject", Label: "wrong client"}
|
return Tag{Status: "reject", Label: "wrong client"}
|
||||||
case "limit":
|
case "limit":
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ func TestSendLogTag(t *testing.T) {
|
|||||||
{"rejected", "", Tag{Status: "rejected"}},
|
{"rejected", "", Tag{Status: "rejected"}},
|
||||||
{"rejected", "limit", Tag{Status: "rate-limited"}},
|
{"rejected", "limit", Tag{Status: "rate-limited"}},
|
||||||
{"rejected", "from", Tag{Status: "reject", Label: "wrong From"}},
|
{"rejected", "from", Tag{Status: "reject", Label: "wrong From"}},
|
||||||
|
{"rejected", "sender", Tag{Status: "reject", Label: "wrong Sender"}},
|
||||||
{"rejected", "client", Tag{Status: "reject", Label: "wrong client"}},
|
{"rejected", "client", Tag{Status: "reject", Label: "wrong client"}},
|
||||||
} {
|
} {
|
||||||
if got := SendLogTag(c.status, c.reason); got != c.want {
|
if got := SendLogTag(c.status, c.reason); got != c.want {
|
||||||
|
|||||||
@@ -327,6 +327,9 @@ func TestE2E(t *testing.T) {
|
|||||||
run("sender_check_holds_the_from_line", func(t *testing.T) {
|
run("sender_check_holds_the_from_line", func(t *testing.T) {
|
||||||
testSenderCheck(t, sc)
|
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) {
|
run("passed_from_lines_signed_for_their_domain_only", func(t *testing.T) {
|
||||||
testSignedForItsDomainOnly(t, sc)
|
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