mirror of
https://github.com/mjl-/mox.git
synced 2025-07-13 06:54:38 +03:00
implement outgoing dmarc aggregate reporting
in smtpserver, we store dmarc evaluations (under the right conditions). in dmarcdb, we periodically (hourly) send dmarc reports if there are evaluations. for failed deliveries, we deliver the dsn quietly to a submailbox of the postmaster mailbox. this is on by default, but can be disabled in mox.conf.
This commit is contained in:
@ -37,16 +37,17 @@ type delivery struct {
|
||||
}
|
||||
|
||||
type analysis struct {
|
||||
accept bool
|
||||
mailbox string
|
||||
code int
|
||||
secode string
|
||||
userError bool
|
||||
errmsg string
|
||||
err error // For our own logging, not sent to remote.
|
||||
dmarcReport *dmarcrpt.Feedback // Validated dmarc aggregate report, not yet stored.
|
||||
tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
|
||||
reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
|
||||
accept bool
|
||||
mailbox string
|
||||
code int
|
||||
secode string
|
||||
userError bool
|
||||
errmsg string
|
||||
err error // For our own logging, not sent to remote.
|
||||
dmarcReport *dmarcrpt.Feedback // Validated DMARC aggregate report, not yet stored.
|
||||
tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
|
||||
reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
|
||||
dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
|
||||
}
|
||||
|
||||
const (
|
||||
@ -64,7 +65,7 @@ const (
|
||||
reasonDNSBlocklisted = "dns-blocklisted"
|
||||
reasonSubjectpass = "subjectpass"
|
||||
reasonSubjectpassError = "subjectpass-error"
|
||||
reasonIPrev = "iprev" // No or mil junk reputation signals, and bad iprev.
|
||||
reasonIPrev = "iprev" // No or mild junk reputation signals, and bad iprev.
|
||||
)
|
||||
|
||||
func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delivery) analysis {
|
||||
@ -83,15 +84,17 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
ld := rs.ListAllowDNSDomain
|
||||
// todo: on temporary failures, reject temporarily?
|
||||
if d.m.MailFromValidated && ld.Name() == d.m.MailFromDomain {
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow}
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList)}
|
||||
}
|
||||
for _, r := range d.dkimResults {
|
||||
if r.Status == dkim.StatusPass && r.Sig.Domain == ld {
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow}
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var dmarcOverrideReason string
|
||||
|
||||
// For forwarded messages, we have different junk analysis. We don't reject for
|
||||
// failing DMARC, and we clear fields that could implicate the forwarding mail
|
||||
// server during future classifications on incoming messages (the forwarding mail
|
||||
@ -113,6 +116,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
}
|
||||
}
|
||||
d.m.DKIMDomains = dkimdoms
|
||||
dmarcOverrideReason = string(dmarcrpt.PolicyOverrideForwarded)
|
||||
log.Info("forwarded message, clearing identifying signals of forwarding mail server")
|
||||
}
|
||||
|
||||
@ -154,7 +158,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
})
|
||||
})
|
||||
if mberr != nil {
|
||||
return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError}
|
||||
return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason}
|
||||
}
|
||||
d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
|
||||
}
|
||||
@ -168,7 +172,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
d.m.Seen = true
|
||||
log.Info("accepting reject to configured mailbox due to ruleset")
|
||||
}
|
||||
return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason}
|
||||
return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason}
|
||||
}
|
||||
|
||||
if d.dmarcUse && d.dmarcResult.Reject {
|
||||
@ -180,17 +184,17 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
// track of the report. We'll check reputation, defaulting to accept.
|
||||
var dmarcReport *dmarcrpt.Feedback
|
||||
if d.rcptAcc.destination.DMARCReports {
|
||||
// Messages with DMARC aggregate reports must have a dmarc pass. ../rfc/7489:1866
|
||||
// Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
|
||||
if d.dmarcResult.Status != dmarc.StatusPass {
|
||||
log.Info("received dmarc report without dmarc pass, not processing as dmarc report")
|
||||
log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
|
||||
} else if report, err := dmarcrpt.ParseMessageReport(log, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
||||
log.Infox("parsing dmarc report", err)
|
||||
log.Infox("parsing dmarc aggregate report", err)
|
||||
} else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
|
||||
log.Infox("parsing domain in dmarc report", err)
|
||||
log.Infox("parsing domain in dmarc aggregate report", err)
|
||||
} else if _, ok := mox.Conf.Domain(d); !ok {
|
||||
log.Info("dmarc report for domain not configured, ignoring", mlog.Field("domain", d))
|
||||
log.Info("dmarc aggregate report for domain not configured, ignoring", mlog.Field("domain", d))
|
||||
} else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
|
||||
log.Info("dmarc report with end date in the future, ignoring", mlog.Field("domain", d), mlog.Field("end", time.Unix(report.ReportMetadata.DateRange.End, 0)))
|
||||
log.Info("dmarc aggregate report with end date in the future, ignoring", mlog.Field("domain", d), mlog.Field("end", time.Unix(report.ReportMetadata.DateRange.End, 0)))
|
||||
} else {
|
||||
dmarcReport = report
|
||||
}
|
||||
@ -261,12 +265,12 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
log.Info("reputation analyzed", mlog.Field("conclusive", conclusive), mlog.Field("isjunk", isjunk), mlog.Field("method", string(method)))
|
||||
if conclusive {
|
||||
if !*isjunk {
|
||||
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason}
|
||||
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason}
|
||||
}
|
||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
|
||||
} else if dmarcReport != nil || tlsReport != nil {
|
||||
log.Info("accepting dmarc reporting or tlsrpt message without reputation")
|
||||
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting}
|
||||
log.Info("accepting message with dmarc aggregate report or tls report without reputation")
|
||||
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason}
|
||||
}
|
||||
// If there was no previous message from sender or its domain, and we have an SPF
|
||||
// (soft)fail, reject the message.
|
||||
@ -302,7 +306,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
pass := err == nil
|
||||
log.Infox("pass by subject token", err, mlog.Field("pass", pass))
|
||||
if pass {
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass}
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason}
|
||||
}
|
||||
}
|
||||
|
||||
@ -382,7 +386,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||
}
|
||||
|
||||
if accept {
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonNoBadSignals}
|
||||
return analysis{accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason}
|
||||
}
|
||||
|
||||
if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
|
||||
|
@ -50,7 +50,9 @@ func queueDSN(ctx context.Context, c *conn, rcptTo smtp.Path, m dsn.Message, req
|
||||
if requireTLS {
|
||||
reqTLS = &requireTLS
|
||||
}
|
||||
if _, err := queue.Add(ctx, c.log, "", smtp.Path{}, rcptTo, has8bit, smtputf8, int64(len(buf)), m.MessageID, nil, f, bufUTF8, reqTLS); err != nil {
|
||||
qm := queue.MakeMsg("", smtp.Path{}, rcptTo, has8bit, smtputf8, int64(len(buf)), m.MessageID, nil, reqTLS)
|
||||
qm.DSNUTF8 = bufUTF8
|
||||
if err := queue.Add(ctx, c.log, &qm, f); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
@ -37,6 +37,7 @@ import (
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dmarc"
|
||||
"github.com/mjl-/mox/dmarcdb"
|
||||
"github.com/mjl-/mox/dmarcrpt"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/dsn"
|
||||
"github.com/mjl-/mox/iprev"
|
||||
@ -1835,7 +1836,8 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
|
||||
xmsgPrefix := append([]byte(recvHdrFor(rcptAcc.rcptTo.String())), msgPrefix...)
|
||||
|
||||
msgSize := int64(len(xmsgPrefix)) + msgWriter.Size
|
||||
if _, err := queue.Add(ctx, c.log, c.account.Name, *c.mailFrom, rcptAcc.rcptTo, msgWriter.Has8bit, c.smtputf8, msgSize, messageID, xmsgPrefix, dataFile, nil, c.requireTLS); err != nil {
|
||||
qm := queue.MakeMsg(c.account.Name, *c.mailFrom, rcptAcc.rcptTo, msgWriter.Has8bit, c.smtputf8, msgSize, messageID, xmsgPrefix, c.requireTLS)
|
||||
if err := queue.Add(ctx, c.log, &qm, dataFile); err != nil {
|
||||
// Aborting the transaction is not great. But continuing and generating DSNs will
|
||||
// probably result in errors as well...
|
||||
metricSubmission.WithLabelValues("queueerror").Inc()
|
||||
@ -2065,7 +2067,6 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
var comment string
|
||||
var props []message.AuthProp
|
||||
if r.Sig != nil {
|
||||
// todo future: also specify whether dns record was dnssec-signed.
|
||||
if r.Record != nil && r.Record.PublicKey != nil {
|
||||
if pubkey, ok := r.Record.PublicKey.(*rsa.PublicKey); ok {
|
||||
comment = fmt.Sprintf("%d bit rsa, ", pubkey.N.BitLen())
|
||||
@ -2167,6 +2168,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
var dmarcUse bool
|
||||
var dmarcResult dmarc.Result
|
||||
const applyRandomPercentage = true
|
||||
// dmarcMethod is added to authResults when delivering to recipients: accounts can
|
||||
// have different policy override rules.
|
||||
var dmarcMethod message.AuthMethod
|
||||
var msgFromValidation = store.ValidationNone
|
||||
if msgFrom.IsZero() {
|
||||
@ -2178,6 +2181,15 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
} else {
|
||||
msgFromValidation = alignment(ctx, msgFrom.Domain, dkimResults, receivedSPF.Result, spfIdentity)
|
||||
|
||||
// We are doing the DMARC evaluation now. But we only store it for inclusion in an
|
||||
// aggregate report when we actually use it. We use an evaluation for each
|
||||
// recipient, with each a potentially different result due to mailing
|
||||
// list/forwarding configuration. If we reject a message due to being spam, we
|
||||
// don't want to spend any resources for the sender domain, and we don't want to
|
||||
// give the sender any more information about us, so we won't record the
|
||||
// evaluation.
|
||||
// todo future: also not send for first-time senders? they could be spammers getting through our filter, don't want to give them insights either. though we currently would have no reasonable way to decide if they are still reputationless at the time we are composing/sending aggregate reports.
|
||||
|
||||
dmarcctx, dmarccancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer dmarccancel()
|
||||
dmarcUse, dmarcResult = dmarc.Verify(dmarcctx, c.resolver, msgFrom.Domain, dkimResults, receivedSPF.Result, spfIdentity, applyRandomPercentage)
|
||||
@ -2202,9 +2214,8 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
msgFromValidation = store.ValidationDMARC
|
||||
}
|
||||
|
||||
// todo future: consider enforcing an spf fail if there is no dmarc policy or the dmarc policy is none. ../rfc/7489:1507
|
||||
// todo future: consider enforcing an spf (soft)fail if there is no dmarc policy or the dmarc policy is none. ../rfc/7489:1507
|
||||
}
|
||||
authResults.Methods = append(authResults.Methods, dmarcMethod)
|
||||
c.log.Debug("dmarc verification", mlog.Field("result", dmarcResult.Status), mlog.Field("domain", msgFrom.Domain))
|
||||
|
||||
// Prepare for analyzing content, calculating reputation.
|
||||
@ -2366,16 +2377,6 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
continue
|
||||
}
|
||||
|
||||
// ../rfc/5321:3204
|
||||
// Received-SPF header goes before Received. ../rfc/7208:2038
|
||||
msgPrefix := []byte(
|
||||
"Delivered-To: " + rcptAcc.rcptTo.XString(c.smtputf8) + "\r\n" + // ../rfc/9228:274
|
||||
"Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300
|
||||
authResults.Header() +
|
||||
receivedSPF.Header() +
|
||||
recvHdrFor(rcptAcc.rcptTo.String()),
|
||||
)
|
||||
|
||||
m := &store.Message{
|
||||
Received: time.Now(),
|
||||
RemoteIP: c.remoteIP.String(),
|
||||
@ -2398,16 +2399,187 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
MailFromValidation: mailFromValidation,
|
||||
MsgFromValidation: msgFromValidation,
|
||||
DKIMDomains: verifiedDKIMDomains,
|
||||
Size: int64(len(msgPrefix)) + msgWriter.Size,
|
||||
MsgPrefix: msgPrefix,
|
||||
Size: msgWriter.Size,
|
||||
}
|
||||
d := delivery{m, dataFile, rcptAcc, acc, msgFrom, c.dnsBLs, dmarcUse, dmarcResult, dkimResults, iprevStatus}
|
||||
a := analyze(ctx, log, c.resolver, d)
|
||||
if a.reason != "" {
|
||||
xmoxreason := "X-Mox-Reason: " + a.reason + "\r\n"
|
||||
m.MsgPrefix = append([]byte(xmoxreason), m.MsgPrefix...)
|
||||
m.Size += int64(len(xmoxreason))
|
||||
|
||||
// Any DMARC result override is stored in the evaluation for outgoing DMARC
|
||||
// aggregate reports, and added to the Authentication-Results message header.
|
||||
var dmarcOverride string
|
||||
if dmarcResult.Record != nil {
|
||||
if !dmarcUse {
|
||||
dmarcOverride = string(dmarcrpt.PolicyOverrideSampledOut)
|
||||
} else if a.dmarcOverrideReason != "" && (a.accept && !m.IsReject) == dmarcResult.Reject {
|
||||
dmarcOverride = a.dmarcOverrideReason
|
||||
}
|
||||
}
|
||||
|
||||
// Add per-recipient DMARC method to Authentication-Results. Each account can have
|
||||
// their own override rules, e.g. based on configured mailing lists/forwards.
|
||||
// ../rfc/7489:1486
|
||||
rcptDMARCMethod := dmarcMethod
|
||||
if dmarcOverride != "" {
|
||||
if rcptDMARCMethod.Comment != "" {
|
||||
rcptDMARCMethod.Comment += ", "
|
||||
}
|
||||
rcptDMARCMethod.Comment += "override " + dmarcOverride
|
||||
}
|
||||
rcptAuthResults := authResults
|
||||
rcptAuthResults.Methods = append([]message.AuthMethod{}, authResults.Methods...)
|
||||
rcptAuthResults.Methods = append(rcptAuthResults.Methods, rcptDMARCMethod)
|
||||
|
||||
// Prepend reason as message header, for easy display in mail clients.
|
||||
var xmoxreason string
|
||||
if a.reason != "" {
|
||||
xmoxreason = "X-Mox-Reason: " + a.reason + "\r\n"
|
||||
}
|
||||
|
||||
// ../rfc/5321:3204
|
||||
// Received-SPF header goes before Received. ../rfc/7208:2038
|
||||
m.MsgPrefix = []byte(
|
||||
xmoxreason +
|
||||
"Delivered-To: " + rcptAcc.rcptTo.XString(c.smtputf8) + "\r\n" + // ../rfc/9228:274
|
||||
"Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300
|
||||
rcptAuthResults.Header() +
|
||||
receivedSPF.Header() +
|
||||
recvHdrFor(rcptAcc.rcptTo.String()),
|
||||
)
|
||||
m.Size += int64(len(m.MsgPrefix))
|
||||
|
||||
// Store DMARC evaluation for inclusion in an aggregate report. Only if there is at
|
||||
// least one reporting address: We don't want to needlessly store a row in a
|
||||
// database for each delivery attempt. If we reject a message for being junk, we
|
||||
// are also not going to send it a DMARC report. The DMARC check is done early in
|
||||
// the analysis, we will report on rejects because of DMARC, because it could be
|
||||
// valuable feedback about forwarded or mailing list messages.
|
||||
// ../rfc/7489:1492
|
||||
if !mox.Conf.Static.NoOutgoingDMARCReports && dmarcResult.Record != nil && len(dmarcResult.Record.AggregateReportAddresses) > 0 && (a.accept && !m.IsReject || a.reason == reasonDMARCPolicy) {
|
||||
// Disposition holds our decision on whether to accept the message. Not what the
|
||||
// DMARC evaluation resulted in. We can override, e.g. because of mailing lists,
|
||||
// forwarding, or local policy.
|
||||
// We treat quarantine as reject, so never claim to quarantine.
|
||||
// ../rfc/7489:1691
|
||||
disposition := dmarcrpt.DispositionNone
|
||||
if !a.accept {
|
||||
disposition = dmarcrpt.DispositionReject
|
||||
}
|
||||
|
||||
// unknownDomain returns whether the sender is domain with which this account has
|
||||
// not had positive interaction.
|
||||
unknownDomain := func() (unknown bool) {
|
||||
err := acc.DB.Read(ctx, func(tx *bstore.Tx) (err error) {
|
||||
// See if we received a non-junk message from this organizational domain.
|
||||
q := bstore.QueryTx[store.Message](tx)
|
||||
q.FilterNonzero(store.Message{MsgFromOrgDomain: m.MsgFromOrgDomain})
|
||||
q.FilterEqual("Notjunk", false)
|
||||
exists, err := q.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying for non-junk message from organizational domain: %v", err)
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// See if we sent a message to this organizational domain.
|
||||
qr := bstore.QueryTx[store.Recipient](tx)
|
||||
qr.FilterNonzero(store.Recipient{OrgDomain: m.MsgFromOrgDomain})
|
||||
exists, err = qr.Exists()
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying for message sent to organizational domain: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
unknown = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorx("checking if sender is unknown domain, for dmarc aggregate report evaluation", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
r := dmarcResult.Record
|
||||
addresses := make([]string, len(r.AggregateReportAddresses))
|
||||
for i, a := range r.AggregateReportAddresses {
|
||||
addresses[i] = a.String()
|
||||
}
|
||||
sp := dmarcrpt.Disposition(r.SubdomainPolicy)
|
||||
if r.SubdomainPolicy == dmarc.PolicyEmpty {
|
||||
sp = dmarcrpt.Disposition(r.Policy)
|
||||
}
|
||||
eval := dmarcdb.Evaluation{
|
||||
// Evaluated and IntervalHours set by AddEvaluation.
|
||||
PolicyDomain: dmarcResult.Domain.Name(),
|
||||
|
||||
// Optional evaluations don't cause a report to be sent, but will be included.
|
||||
// Useful for automated inter-mailer messages, we don't want to get in a reporting
|
||||
// loop. We also don't want to be used for sending reports to unsuspecting domains
|
||||
// we have no relation with.
|
||||
// todo: would it make sense to also mark some percentage of mailing-list-policy-overrides optional? to lower the load on mail servers of folks sending to large mailing lists.
|
||||
Optional: rcptAcc.destination.DMARCReports || rcptAcc.destination.TLSReports || a.reason == reasonDMARCPolicy && unknownDomain(),
|
||||
|
||||
Addresses: addresses,
|
||||
|
||||
PolicyPublished: dmarcrpt.PolicyPublished{
|
||||
Domain: dmarcResult.Domain.Name(),
|
||||
ADKIM: dmarcrpt.Alignment(r.ADKIM),
|
||||
ASPF: dmarcrpt.Alignment(r.ASPF),
|
||||
Policy: dmarcrpt.Disposition(r.Policy),
|
||||
SubdomainPolicy: sp,
|
||||
Percentage: r.Percentage,
|
||||
// We don't save ReportingOptions, we don't do per-message failure reporting.
|
||||
},
|
||||
SourceIP: c.remoteIP.String(),
|
||||
Disposition: disposition,
|
||||
AlignedDKIMPass: dmarcResult.AlignedDKIMPass,
|
||||
AlignedSPFPass: dmarcResult.AlignedSPFPass,
|
||||
EnvelopeTo: rcptAcc.rcptTo.IPDomain.String(),
|
||||
EnvelopeFrom: c.mailFrom.IPDomain.String(),
|
||||
HeaderFrom: msgFrom.Domain.Name(),
|
||||
}
|
||||
|
||||
if dmarcOverride != "" {
|
||||
eval.OverrideReasons = []dmarcrpt.PolicyOverrideReason{
|
||||
{Type: dmarcrpt.PolicyOverride(dmarcOverride)},
|
||||
}
|
||||
}
|
||||
|
||||
// We'll include all signatures for the organizational domain, even if they weren't
|
||||
// relevant due to strict alignment requirement.
|
||||
for _, dkimResult := range dkimResults {
|
||||
if dkimResult.Sig == nil || publicsuffix.Lookup(ctx, msgFrom.Domain) != publicsuffix.Lookup(ctx, dkimResult.Sig.Domain) {
|
||||
continue
|
||||
}
|
||||
r := dmarcrpt.DKIMAuthResult{
|
||||
Domain: dkimResult.Sig.Domain.Name(),
|
||||
Selector: dkimResult.Sig.Selector.ASCII,
|
||||
Result: dmarcrpt.DKIMResult(dkimResult.Status),
|
||||
}
|
||||
eval.DKIMResults = append(eval.DKIMResults, r)
|
||||
}
|
||||
|
||||
switch receivedSPF.Identity {
|
||||
case spf.ReceivedHELO:
|
||||
spfAuthResult := dmarcrpt.SPFAuthResult{
|
||||
Domain: spfArgs.HelloDomain.String(), // Can be unicode and also IP.
|
||||
Scope: dmarcrpt.SPFDomainScopeHelo,
|
||||
Result: dmarcrpt.SPFResult(receivedSPF.Result),
|
||||
}
|
||||
eval.SPFResults = []dmarcrpt.SPFAuthResult{spfAuthResult}
|
||||
case spf.ReceivedMailFrom:
|
||||
spfAuthResult := dmarcrpt.SPFAuthResult{
|
||||
Domain: spfArgs.MailFromDomain.Name(), // Can be unicode.
|
||||
Scope: dmarcrpt.SPFDomainScopeMailFrom,
|
||||
Result: dmarcrpt.SPFResult(receivedSPF.Result),
|
||||
}
|
||||
eval.SPFResults = []dmarcrpt.SPFAuthResult{spfAuthResult}
|
||||
}
|
||||
|
||||
err := dmarcdb.AddEvaluation(ctx, dmarcResult.Record.AggregateReportingInterval, &eval)
|
||||
log.Check(err, "adding dmarc evaluation to database for aggregate report")
|
||||
}
|
||||
|
||||
if !a.accept {
|
||||
conf, _ := acc.Conf()
|
||||
if conf.RejectsMailbox != "" {
|
||||
@ -2455,9 +2627,9 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||
if a.dmarcReport != nil {
|
||||
// todo future: add rate limiting to prevent DoS attacks. ../rfc/7489:2570
|
||||
if err := dmarcdb.AddReport(ctx, a.dmarcReport, msgFrom.Domain); err != nil {
|
||||
log.Errorx("saving dmarc report in database", err)
|
||||
log.Errorx("saving dmarc aggregate report in database", err)
|
||||
} else {
|
||||
log.Info("dmarc report processed")
|
||||
log.Info("dmarc aggregate report processed")
|
||||
m.Flags.Seen = true
|
||||
delayFirstTime = false
|
||||
}
|
||||
|
@ -100,6 +100,11 @@ func newTestServer(t *testing.T, configPath string, resolver dns.Resolver) *test
|
||||
|
||||
ts := testserver{t: t, cid: 1, resolver: resolver, tlsmode: smtpclient.TLSOpportunistic}
|
||||
|
||||
if dmarcdb.EvalDB != nil {
|
||||
dmarcdb.EvalDB.Close()
|
||||
dmarcdb.EvalDB = nil
|
||||
}
|
||||
|
||||
mox.Context = ctxbg
|
||||
mox.ConfigStaticPath = configPath
|
||||
mox.MustLoadConfig(true, false)
|
||||
@ -192,6 +197,15 @@ func fakeCert(t *testing.T) tls.Certificate {
|
||||
return c
|
||||
}
|
||||
|
||||
// check expected dmarc evaluations for outgoing aggregate reports.
|
||||
func checkEvaluationCount(t *testing.T, n int) []dmarcdb.Evaluation {
|
||||
t.Helper()
|
||||
l, err := dmarcdb.Evaluations(ctxbg)
|
||||
tcheck(t, err, "get dmarc evaluations")
|
||||
tcompare(t, len(l), n)
|
||||
return l
|
||||
}
|
||||
|
||||
// Test submission from authenticated user.
|
||||
func TestSubmission(t *testing.T) {
|
||||
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), dns.MockResolver{})
|
||||
@ -229,6 +243,7 @@ func TestSubmission(t *testing.T) {
|
||||
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) {
|
||||
t.Fatalf("got err %#v (%q), expected %#v", err, err, expErr)
|
||||
}
|
||||
checkEvaluationCount(t, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@ -329,6 +344,8 @@ func TestDelivery(t *testing.T) {
|
||||
t.Fatalf("no delivery in 1s")
|
||||
}
|
||||
})
|
||||
|
||||
checkEvaluationCount(t, 0)
|
||||
}
|
||||
|
||||
func tinsertmsg(t *testing.T, acc *store.Account, mailbox string, m *store.Message, msg string) {
|
||||
@ -392,7 +409,7 @@ func TestSpam(t *testing.T) {
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject; rua=mailto:dmarcrpt@example.org"},
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/junk/mox.conf"), resolver)
|
||||
@ -451,6 +468,7 @@ func TestSpam(t *testing.T) {
|
||||
}
|
||||
|
||||
checkCount("Rejects", 1)
|
||||
checkEvaluationCount(t, 0) // No positive interactions yet.
|
||||
})
|
||||
|
||||
// Delivery from sender with bad reputation matching AcceptRejectsToMailbox should
|
||||
@ -463,8 +481,9 @@ func TestSpam(t *testing.T) {
|
||||
}
|
||||
tcheck(t, err, "deliver")
|
||||
|
||||
checkCount("mjl2junk", 1) // In ruleset rejects mailbox.
|
||||
checkCount("Rejects", 1) // Same as before.
|
||||
checkCount("mjl2junk", 1) // In ruleset rejects mailbox.
|
||||
checkCount("Rejects", 1) // Same as before.
|
||||
checkEvaluationCount(t, 0) // This is not an actual accept.
|
||||
})
|
||||
|
||||
// Mark the messages as having good reputation.
|
||||
@ -485,6 +504,7 @@ func TestSpam(t *testing.T) {
|
||||
// Message should now be removed from Rejects mailboxes.
|
||||
checkCount("Rejects", 0)
|
||||
checkCount("mjl2junk", 1)
|
||||
checkEvaluationCount(t, 1)
|
||||
})
|
||||
|
||||
// Undo dmarc pass, mark messages as junk, and train the filter.
|
||||
@ -506,6 +526,7 @@ func TestSpam(t *testing.T) {
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("attempt to deliver spamy message, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
checkEvaluationCount(t, 1) // No new evaluation, this isn't a DMARC reject.
|
||||
})
|
||||
}
|
||||
|
||||
@ -525,9 +546,9 @@ func TestForward(t *testing.T) {
|
||||
"bad.example.": {"v=spf1 ip4:127.0.0.1 -all"},
|
||||
"good.example.": {"v=spf1 ip4:127.0.0.1 -all"},
|
||||
"forward.example.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.bad.example.": {"v=DMARC1;p=reject"},
|
||||
"_dmarc.good.example.": {"v=DMARC1;p=reject"},
|
||||
"_dmarc.forward.example.": {"v=DMARC1;p=reject"},
|
||||
"_dmarc.bad.example.": {"v=DMARC1;p=reject; rua=mailto:dmarc@bad.example"},
|
||||
"_dmarc.good.example.": {"v=DMARC1;p=reject; rua=mailto:dmarc@good.example"},
|
||||
"_dmarc.forward.example.": {"v=DMARC1;p=reject; rua=mailto:dmarc@forward.example"},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"127.0.0.10": {"forward.example."}, // For iprev check.
|
||||
@ -544,6 +565,8 @@ func TestForward(t *testing.T) {
|
||||
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/junk/mox.conf"), resolver)
|
||||
defer ts.close()
|
||||
|
||||
totalEvaluations := 0
|
||||
|
||||
var msgBad = strings.ReplaceAll(`From: <remote@bad.example>
|
||||
To: <mjl3@mox.example>
|
||||
Subject: test
|
||||
@ -580,6 +603,7 @@ happens to come from forwarding mail server.
|
||||
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgBad)), strings.NewReader(msgBad), false, false, false)
|
||||
tcheck(t, err, "deliver message")
|
||||
}
|
||||
totalEvaluations += 10
|
||||
|
||||
n, err := bstore.QueryDB[store.Message](ctxbg, ts.acc.DB).UpdateFields(map[string]any{"Junk": true, "MsgFromValidated": true})
|
||||
tcheck(t, err, "marking messages as junk")
|
||||
@ -591,6 +615,8 @@ happens to come from forwarding mail server.
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("delivery by bad sender, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
|
||||
checkEvaluationCount(t, totalEvaluations)
|
||||
})
|
||||
|
||||
// Delivery from different "message From" without reputation, but from same
|
||||
@ -607,12 +633,14 @@ happens to come from forwarding mail server.
|
||||
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK)), strings.NewReader(msgOK), false, false, false)
|
||||
if forward {
|
||||
tcheck(t, err, "deliver")
|
||||
totalEvaluations += 1
|
||||
} else {
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("delivery by bad ip, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
}
|
||||
checkEvaluationCount(t, totalEvaluations)
|
||||
})
|
||||
|
||||
// Delivery from forwarding server that isn't a forward should get same treatment.
|
||||
@ -624,12 +652,14 @@ happens to come from forwarding mail server.
|
||||
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK2)), strings.NewReader(msgOK2), false, false, false)
|
||||
if forward {
|
||||
tcheck(t, err, "deliver")
|
||||
totalEvaluations += 1
|
||||
} else {
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("delivery by bad ip, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
}
|
||||
checkEvaluationCount(t, totalEvaluations)
|
||||
})
|
||||
}
|
||||
|
||||
@ -644,13 +674,31 @@ func TestDMARCSent(t *testing.T) {
|
||||
"example.org.": {"127.0.0.1"}, // For mx check.
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.1 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject;rua=mailto:dmarcrpt@example.org"},
|
||||
},
|
||||
}
|
||||
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/junk/mox.conf"), resolver)
|
||||
defer ts.close()
|
||||
|
||||
// First check that DMARC policy rejects message and results in optional evaluation.
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
|
||||
t.Fatalf("attempt to deliver spamy message, got err %v, expected smtpclient.Error with code %d", err, smtp.C550MailboxUnavail)
|
||||
}
|
||||
l := checkEvaluationCount(t, 1)
|
||||
tcompare(t, l[0].Optional, true)
|
||||
})
|
||||
|
||||
// Update DNS for an SPF pass, and DMARC pass.
|
||||
resolver.TXT["example.org."] = []string{"v=spf1 ip4:127.0.0.10 -all"}
|
||||
|
||||
// Insert spammy messages not related to the test message.
|
||||
m := store.Message{
|
||||
MailFrom: "remote@test.example",
|
||||
@ -676,6 +724,7 @@ func TestDMARCSent(t *testing.T) {
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
|
||||
t.Fatalf("attempt to deliver spamy message, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
|
||||
}
|
||||
checkEvaluationCount(t, 1) // No new evaluation.
|
||||
})
|
||||
|
||||
// Insert a message that we sent to the address that is about to send to us.
|
||||
@ -684,7 +733,26 @@ func TestDMARCSent(t *testing.T) {
|
||||
err := ts.acc.DB.Insert(ctxbg, &store.Recipient{MessageID: sentMsg.ID, Localpart: "remote", Domain: "example.org", OrgDomain: "example.org", Sent: time.Now()})
|
||||
tcheck(t, err, "inserting message recipient")
|
||||
|
||||
// Reject a message due to DMARC again. Since we sent a message to the domain, it
|
||||
// is no longer unknown and we should see a non-optional evaluation that will
|
||||
// result in a DMARC report.
|
||||
resolver.TXT["example.org."] = []string{"v=spf1 ip4:127.0.0.1 -all"}
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
if err == nil {
|
||||
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
|
||||
}
|
||||
var cerr smtpclient.Error
|
||||
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
|
||||
t.Fatalf("attempt to deliver spamy message, got err %v, expected smtpclient.Error with code %d", err, smtp.C550MailboxUnavail)
|
||||
}
|
||||
l := checkEvaluationCount(t, 2) // New evaluation.
|
||||
tcompare(t, l[1].Optional, false)
|
||||
})
|
||||
|
||||
// We should now be accepting the message because we recently sent a message.
|
||||
resolver.TXT["example.org."] = []string{"v=spf1 ip4:127.0.0.10 -all"}
|
||||
ts.run(func(err error, client *smtpclient.Client) {
|
||||
mailFrom := "remote@example.org"
|
||||
rcptTo := "mjl@mox.example"
|
||||
@ -692,6 +760,8 @@ func TestDMARCSent(t *testing.T) {
|
||||
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
|
||||
}
|
||||
tcheck(t, err, "deliver")
|
||||
l := checkEvaluationCount(t, 3) // New evaluation.
|
||||
tcompare(t, l[2].Optional, false)
|
||||
})
|
||||
}
|
||||
|
||||
@ -773,7 +843,7 @@ func TestDMARCReport(t *testing.T) {
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject; rua=mailto:dmarcrpt@example.org"},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"127.0.0.10": {"example.org."}, // For iprev check.
|
||||
@ -815,6 +885,11 @@ func TestDMARCReport(t *testing.T) {
|
||||
|
||||
run(dmarcReport, 0)
|
||||
run(strings.ReplaceAll(dmarcReport, "xmox.nl", "mox.example"), 1)
|
||||
|
||||
// We always store as an evaluation, but as optional for reports.
|
||||
evals := checkEvaluationCount(t, 2)
|
||||
tcompare(t, evals[0].Optional, true)
|
||||
tcompare(t, evals[1].Optional, true)
|
||||
}
|
||||
|
||||
const dmarcReport = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
@ -896,7 +971,7 @@ func TestTLSReport(t *testing.T) {
|
||||
TXT: map[string][]string{
|
||||
"testsel._domainkey.example.org.": {dkimTxt},
|
||||
"example.org.": {"v=spf1 ip4:127.0.0.10 -all"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject"},
|
||||
"_dmarc.example.org.": {"v=DMARC1;p=reject;rua=mailto:dmarcrpt@example.org"},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"127.0.0.10": {"example.org."}, // For iprev check.
|
||||
@ -939,6 +1014,11 @@ func TestTLSReport(t *testing.T) {
|
||||
|
||||
run(tlsrpt, 0)
|
||||
run(strings.ReplaceAll(tlsrpt, "xmox.nl", "mox.example"), 1)
|
||||
|
||||
// We always store as an evaluation, but as optional for reports.
|
||||
evals := checkEvaluationCount(t, 2)
|
||||
tcompare(t, evals[0].Optional, true)
|
||||
tcompare(t, evals[1].Optional, true)
|
||||
}
|
||||
|
||||
func TestRatelimitConnectionrate(t *testing.T) {
|
||||
|
Reference in New Issue
Block a user