Skip to content

Commit 2ff87a0

Browse files
committed
more strict junk checks for some first-time senders: when TLS isn't used and when recipient address isn't in To/Cc header
both cases are quite typical for spammers, and not for legitimate senders. this doesn't apply to known senders. and it only requires that the content look more like ham instead of spam. so legitimate mail can still get through with these properties.
1 parent 8e37fad commit 2ff87a0

File tree

8 files changed

+69
-19
lines changed

8 files changed

+69
-19
lines changed

docker-compose-integration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ services:
115115
volumes:
116116
# todo: figure out how to mount files with a uid that the process in the container can read...
117117
- ./testdata/integration/resolv.conf:/etc/resolv.conf
118-
command: ["sh", "-c", "set -e; chmod o+r /etc/resolv.conf; (echo 'maillog_file = /dev/stdout'; echo 'mydestination = $$myhostname, localhost.$$mydomain, localhost, $$mydomain') >>/etc/postfix/main.cf; echo 'root: moxtest1@mox1.example' >>/etc/postfix/aliases; newaliases; postfix start-fg"]
118+
command: ["sh", "-c", "set -e; chmod o+r /etc/resolv.conf; (echo 'maillog_file = /dev/stdout'; echo 'mydestination = $$myhostname, localhost.$$mydomain, localhost, $$mydomain'; echo 'smtp_tls_security_level = may') >>/etc/postfix/main.cf; echo 'root: postfix@mox1.example' >>/etc/postfix/aliases; newaliases; postfix start-fg"]
119119
healthcheck:
120120
test: netstat -nlt | grep ':25 '
121121
interval: 1s

integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ This is the message.
158158

159159
xlog.Print("submitting email to postfix, waiting for imap notification at moxacmepebble")
160160
t0 = time.Now()
161-
deliver(true, true, "moxacmepebble.mox1.example:993", "[email protected]", "accountpass1234", func() {
161+
deliver(false, true, "moxacmepebble.mox1.example:993", "[email protected]", "accountpass1234", func() {
162162
submit(true, "[email protected]", "accountpass1234", "moxacmepebble.mox1.example:465", "[email protected]")
163163
})
164164
xlog.Print("success", mlog.Field("duration", time.Since(t0)))

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2377,7 +2377,7 @@ can be found in message headers.
23772377

23782378
data, err := io.ReadAll(os.Stdin)
23792379
xcheckf(err, "read message")
2380-
dmarcFrom, _, err := message.From(mlog.New("dmarcverify"), false, bytes.NewReader(data))
2380+
dmarcFrom, _, _, err := message.From(mlog.New("dmarcverify"), false, bytes.NewReader(data))
23812381
xcheckf(err, "extract dmarc from message")
23822382

23832383
const ignoreTestMode = false

message/from.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,28 @@ import (
1717
// From headers may be present. From returns an error if there is not exactly
1818
// one address. This address can be used for evaluating a DMARC policy against
1919
// SPF and DKIM results.
20-
func From(log *mlog.Log, strict bool, r io.ReaderAt) (raddr smtp.Address, header textproto.MIMEHeader, rerr error) {
20+
func From(log *mlog.Log, strict bool, r io.ReaderAt) (raddr smtp.Address, envelope *Envelope, header textproto.MIMEHeader, rerr error) {
2121
// ../rfc/7489:1243
2222

2323
// todo: only allow utf8 if enabled in session/message?
2424

2525
p, err := Parse(log, strict, r)
2626
if err != nil {
2727
// todo: should we continue with p, perhaps headers can be parsed?
28-
return raddr, nil, fmt.Errorf("parsing message: %v", err)
28+
return raddr, nil, nil, fmt.Errorf("parsing message: %v", err)
2929
}
3030
header, err = p.Header()
3131
if err != nil {
32-
return raddr, nil, fmt.Errorf("parsing message header: %v", err)
32+
return raddr, nil, nil, fmt.Errorf("parsing message header: %v", err)
3333
}
3434
from := p.Envelope.From
3535
if len(from) != 1 {
36-
return raddr, nil, fmt.Errorf("from header has %d addresses, need exactly 1 address", len(from))
36+
return raddr, nil, nil, fmt.Errorf("from header has %d addresses, need exactly 1 address", len(from))
3737
}
3838
d, err := dns.ParseDomain(from[0].Host)
3939
if err != nil {
40-
return raddr, nil, fmt.Errorf("bad domain in from address: %v", err)
40+
return raddr, nil, nil, fmt.Errorf("bad domain in from address: %v", err)
4141
}
4242
addr := smtp.Address{Localpart: smtp.Localpart(from[0].User), Domain: d}
43-
return addr, textproto.MIMEHeader(header), nil
43+
return addr, p.Envelope, textproto.MIMEHeader(header), nil
4444
}

smtpserver/analyze.go

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/mjl-/mox/dns"
1717
"github.com/mjl-/mox/dnsbl"
1818
"github.com/mjl-/mox/iprev"
19+
"github.com/mjl-/mox/message"
1920
"github.com/mjl-/mox/mlog"
2021
"github.com/mjl-/mox/mox-"
2122
"github.com/mjl-/mox/publicsuffix"
@@ -26,10 +27,13 @@ import (
2627
)
2728

2829
type delivery struct {
30+
tls bool
2931
m *store.Message
3032
dataFile *os.File
3133
rcptAcc rcptAccount
3234
acc *store.Account
35+
msgTo []message.Address
36+
msgCc []message.Address
3337
msgFrom smtp.Address
3438
dnsBLs []dns.Domain
3539
dmarcUse bool
@@ -369,11 +373,41 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
369373
jitter := (jitterRand.Float64() - 0.5) / 10
370374
threshold := jf.Threshold + jitter
371375

372-
// With an iprev fail, we set a higher bar for content.
376+
rcptToMatch := func(l []message.Address) bool {
377+
// todo: we use Go's net/mail to parse message header addresses. it does not allow empty quoted strings (contrary to spec), leaving To empty. so we don't verify To address for that unusual case for now. ../rfc/5322:961 ../rfc/5322:743
378+
if d.rcptAcc.rcptTo.Localpart == "" {
379+
return true
380+
}
381+
for _, a := range l {
382+
dom, err := dns.ParseDomain(a.Host)
383+
if err != nil {
384+
continue
385+
}
386+
if dom == d.rcptAcc.rcptTo.IPDomain.Domain && smtp.Localpart(a.User) == d.rcptAcc.rcptTo.Localpart {
387+
return true
388+
}
389+
}
390+
return false
391+
}
392+
393+
// todo: some of these checks should also apply for reputation-based analysis with a weak signal, e.g. verified dkim/spf signal from new domain.
394+
// With an iprev fail, non-TLS connection or our address not in To/Cc header, we set a higher bar for content.
373395
reason = reasonJunkContent
374396
if suspiciousIPrevFail && threshold > 0.25 {
375397
threshold = 0.25
376-
log.Info("setting junk threshold due to iprev fail", mlog.Field("threshold", 0.25))
398+
log.Info("setting junk threshold due to iprev fail", mlog.Field("threshold", threshold))
399+
reason = reasonJunkContentStrict
400+
} else if !d.tls && threshold > 0.25 {
401+
threshold = 0.25
402+
log.Info("setting junk threshold due to plaintext smtp", mlog.Field("threshold", threshold))
403+
reason = reasonJunkContentStrict
404+
} else if (rs == nil || !rs.IsForward) && threshold > 0.25 && !rcptToMatch(d.msgTo) && !rcptToMatch(d.msgCc) {
405+
// A common theme in junk messages is your recipient address not being in the To/Cc
406+
// headers. We may be in Bcc, but that's unusual for first-time senders. Some
407+
// providers (e.g. gmail) does not DKIM-sign Bcc headers, so junk messages can be
408+
// sent with matching Bcc headers. We don't get here for known senders.
409+
threshold = 0.25
410+
log.Info("setting junk threshold due to smtp rcpt to and message to/cc address mismatch", mlog.Field("threshold", threshold))
377411
reason = reasonJunkContentStrict
378412
}
379413
accept = contentProb <= threshold

smtpserver/server.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1765,7 +1765,7 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
17651765
// for other users.
17661766
// We don't check the Sender field, there is no expectation of verification, ../rfc/7489:2948
17671767
// and with Resent headers it seems valid to have someone else as Sender. ../rfc/5322:1578
1768-
msgFrom, header, err := message.From(c.log, true, dataFile)
1768+
msgFrom, _, header, err := message.From(c.log, true, dataFile)
17691769
if err != nil {
17701770
metricSubmission.WithLabelValues("badmessage").Inc()
17711771
c.log.Infox("parsing message From address", err, mlog.Field("user", c.username))
@@ -1961,7 +1961,7 @@ func (c *conn) xlocalserveError(lp smtp.Localpart) {
19611961
func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgWriter *message.Writer, iprevStatus iprev.Status, iprevAuthentic bool, dataFile *os.File) {
19621962
// todo: in decision making process, if we run into (some) temporary errors, attempt to continue. if we decide to accept, all good. if we decide to reject, we'll make it a temporary reject.
19631963

1964-
msgFrom, headers, err := message.From(c.log, false, dataFile)
1964+
msgFrom, envelope, headers, err := message.From(c.log, false, dataFile)
19651965
if err != nil {
19661966
c.log.Infox("parsing message for From address", err)
19671967
}
@@ -2461,7 +2461,12 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
24612461
m.ReceivedTLSVersion = 1 // Signals plain text delivery.
24622462
}
24632463

2464-
d := delivery{&m, dataFile, rcptAcc, acc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus}
2464+
var msgTo, msgCc []message.Address
2465+
if envelope != nil {
2466+
msgTo = envelope.To
2467+
msgCc = envelope.CC
2468+
}
2469+
d := delivery{c.tls, &m, dataFile, rcptAcc, acc, msgTo, msgCc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus}
24652470
a := analyze(ctx, log, c.resolver, d)
24662471

24672472
// Any DMARC result override is stored in the evaluation for outgoing DMARC

smtpserver/server_test.go

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -574,21 +574,21 @@ func TestForward(t *testing.T) {
574574
totalEvaluations := 0
575575

576576
var msgBad = strings.ReplaceAll(`From: <[email protected]>
577-
To: <mjl3@mox.example>
577+
To: <mjl@mox.example>
578578
Subject: test
579579
Message-Id: <[email protected]>
580580
581581
test email
582582
`, "\n", "\r\n")
583583
var msgOK = strings.ReplaceAll(`From: <[email protected]>
584-
To: <mjl3@mox.example>
584+
To: <mjl@mox.example>
585585
Subject: other
586586
Message-Id: <[email protected]>
587587
588588
unrelated message.
589589
`, "\n", "\r\n")
590590
var msgOK2 = strings.ReplaceAll(`From: <[email protected]>
591-
To: <mjl3@mox.example>
591+
To: <mjl@mox.example>
592592
Subject: non-forward
593593
Message-Id: <[email protected]>
594594
@@ -655,7 +655,13 @@ happens to come from forwarding mail server.
655655

656656
mailFrom := "[email protected]"
657657

658-
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK2)), strings.NewReader(msgOK2), false, false, false)
658+
// Ensure To header matches.
659+
msg := msgOK2
660+
if forward {
661+
msg = strings.ReplaceAll(msg, "<[email protected]>", "<[email protected]>")
662+
}
663+
664+
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
659665
if forward {
660666
tcheck(t, err, "deliver")
661667
totalEvaluations += 1
@@ -1418,9 +1424,11 @@ func TestEmptylocalpart(t *testing.T) {
14181424
t.Helper()
14191425
ts.run(func(err error, client *smtpclient.Client) {
14201426
t.Helper()
1427+
14211428
mailFrom := `""@other.example`
1429+
msg := strings.ReplaceAll(deliverMessage, "To: <[email protected]>", `To: <""@mox.example>`)
14221430
if err == nil {
1423-
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
1431+
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
14241432
}
14251433
var cerr smtpclient.Error
14261434
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) {

testdata/integration/moxacmepebble.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ TLS:
1919
# So certificates from moxmail2 are trusted, and pebble's certificate is trusted.
2020
- /integration/tls/ca.pem
2121
EOF
22+
# Recognize [email protected] as destination, and that it is a forwarding destination.
23+
# Postfix seems to keep the mailfrom when forwarding, so we match on that verifieddomain (but using DKIM).
24+
sed -i -e 's/[email protected]: nil/[email protected]: nil\n\t\t\[email protected]:\n\t\t\t\tRulesets:\n\t\t\t\t\t-\n\t\t\t\t\t\tSMTPMailFromRegexp: .*\n\t\t\t\t\t\tVerifiedDomain: mox1.example\n\t\t\t\t\t\tIsForward: true\n\t\t\t\t\t\tMailbox: Inbox/' config/domains.conf
2225

2326
(
2427
cat /integration/example.zone;

0 commit comments

Comments
 (0)