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:
Mechiel Lukkien
2023-11-01 17:55:40 +01:00
parent d1e93020d8
commit e7699708ef
40 changed files with 2689 additions and 245 deletions

View File

@ -75,7 +75,9 @@ type Result struct {
Reject bool
// Result of DMARC validation. A message can fail validation, but still
// not be rejected, e.g. if the policy is "none".
Status Status
Status Status
AlignedSPFPass bool
AlignedDKIMPass bool
// Domain with the DMARC DNS record. May be the organizational domain instead of
// the domain in the From-header.
Domain dns.Domain
@ -142,7 +144,7 @@ func lookupRecord(ctx context.Context, resolver dns.Resolver, domain dns.Domain)
return StatusPermerror, nil, text, result.Authentic, fmt.Errorf("%w: %s", ErrSyntax, err)
}
if record != nil {
// ../ ../rfc/7489:1388
// ../rfc/7489:1388
return StatusNone, nil, "", result.Authentic, ErrMultipleRecords
}
text = txt
@ -152,14 +154,15 @@ func lookupRecord(ctx context.Context, resolver dns.Resolver, domain dns.Domain)
return StatusNone, record, text, result.Authentic, rerr
}
func lookupReportsRecord(ctx context.Context, resolver dns.Resolver, dmarcDomain, extDestDomain dns.Domain) (Status, *Record, string, bool, error) {
func lookupReportsRecord(ctx context.Context, resolver dns.Resolver, dmarcDomain, extDestDomain dns.Domain) (Status, []*Record, []string, bool, error) {
// ../rfc/7489:1566
name := dmarcDomain.ASCII + "._report._dmarc." + extDestDomain.ASCII + "."
txts, result, err := dns.WithPackage(resolver, "dmarc").LookupTXT(ctx, name)
if err != nil && !dns.IsNotFound(err) {
return StatusTemperror, nil, "", result.Authentic, fmt.Errorf("%w: %s", ErrDNS, err)
return StatusTemperror, nil, nil, result.Authentic, fmt.Errorf("%w: %s", ErrDNS, err)
}
var record *Record
var text string
var records []*Record
var texts []string
var rerr error = ErrNoRecord
for _, txt := range txts {
r, isdmarc, err := ParseRecordNoRequired(txt)
@ -171,44 +174,44 @@ func lookupReportsRecord(ctx context.Context, resolver dns.Resolver, dmarcDomain
r, isdmarc, err = &xr, true, nil
}
if !isdmarc {
// ../rfc/7489:1374
// ../rfc/7489:1586
continue
} else if err != nil {
return StatusPermerror, nil, text, result.Authentic, fmt.Errorf("%w: %s", ErrSyntax, err)
}
if record != nil {
// ../ ../rfc/7489:1388
return StatusNone, nil, "", result.Authentic, ErrMultipleRecords
texts = append(texts, txt)
records = append(records, r)
if err != nil {
return StatusPermerror, records, texts, result.Authentic, fmt.Errorf("%w: %s", ErrSyntax, err)
}
text = txt
record = r
// Multiple records are allowed for the _report record, unlike for policies. ../rfc/7489:1593
rerr = nil
}
return StatusNone, record, text, result.Authentic, rerr
return StatusNone, records, texts, result.Authentic, rerr
}
// LookupExternalReportsAccepted returns whether the extDestDomain has opted in
// to receiving dmarc reports for dmarcDomain (where the dmarc record was found),
// through a "._report._dmarc." DNS TXT DMARC record.
//
// Callers should look at status for interpretation, not err, because err will
// be set to ErrNoRecord when the DNS TXT record isn't present, which means the
// extDestDomain does not opt in (not a failure condition).
// accepts is true if the external domain has opted in.
// If a temporary error occurred, the returned status is StatusTemperror, and a
// later retry may give an authoritative result.
// The returned error is ErrNoRecord if no opt-in DNS record exists, which is
// not a failure condition.
//
// The normally invalid "v=DMARC1" record is accepted since it is used as
// example in RFC 7489.
//
// authentic indicates if the DNS results were DNSSEC-verified.
func LookupExternalReportsAccepted(ctx context.Context, resolver dns.Resolver, dmarcDomain dns.Domain, extDestDomain dns.Domain) (accepts bool, status Status, record *Record, txt string, authentic bool, rerr error) {
func LookupExternalReportsAccepted(ctx context.Context, resolver dns.Resolver, dmarcDomain dns.Domain, extDestDomain dns.Domain) (accepts bool, status Status, records []*Record, txts []string, authentic bool, rerr error) {
log := xlog.WithContext(ctx)
start := time.Now()
defer func() {
log.Debugx("dmarc externalreports result", rerr, mlog.Field("accepts", accepts), mlog.Field("dmarcdomain", dmarcDomain), mlog.Field("extdestdomain", extDestDomain), mlog.Field("record", record), mlog.Field("duration", time.Since(start)))
log.Debugx("dmarc externalreports result", rerr, mlog.Field("accepts", accepts), mlog.Field("dmarcdomain", dmarcDomain), mlog.Field("extdestdomain", extDestDomain), mlog.Field("records", records), mlog.Field("duration", time.Since(start)))
}()
status, record, txt, authentic, rerr = lookupReportsRecord(ctx, resolver, dmarcDomain, extDestDomain)
status, records, txts, authentic, rerr = lookupReportsRecord(ctx, resolver, dmarcDomain, extDestDomain)
accepts = rerr == nil
return accepts, status, record, txt, authentic, rerr
return accepts, status, records, txts, authentic, rerr
}
// Verify evaluates the DMARC policy for the domain in the From-header of a
@ -241,7 +244,7 @@ func Verify(ctx context.Context, resolver dns.Resolver, from dns.Domain, dkimRes
status, recordDomain, record, _, authentic, err := Lookup(ctx, resolver, from)
if record == nil {
return false, Result{false, status, recordDomain, record, authentic, err}
return false, Result{false, status, false, false, recordDomain, record, authentic, err}
}
result.Domain = recordDomain
result.Record = record
@ -251,8 +254,8 @@ func Verify(ctx context.Context, resolver dns.Resolver, from dns.Domain, dkimRes
// See ../rfc/7489:1432
useResult = !applyRandomPercentage || record.Percentage == 100 || mathrand.Intn(100) < record.Percentage
// We reject treat "quarantine" and "reject" the same. Thus, we also don't
// "downgrade" from reject to quarantine if this message was sampled out.
// We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
// from reject to quarantine if this message was sampled out.
// ../rfc/7489:1446 ../rfc/7489:1024
if recordDomain != from && record.SubdomainPolicy != PolicyEmpty {
result.Reject = record.SubdomainPolicy != PolicyNone
@ -282,9 +285,7 @@ func Verify(ctx context.Context, resolver dns.Resolver, from dns.Domain, dkimRes
// ../rfc/7489:1319
// ../rfc/7489:544
if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == from || result.Record.ASPF == "r" && pubsuffix(from) == pubsuffix(*spfIdentity)) {
result.Reject = false
result.Status = StatusPass
return
result.AlignedSPFPass = true
}
for _, dkimResult := range dkimResults {
@ -296,10 +297,14 @@ func Verify(ctx context.Context, resolver dns.Resolver, from dns.Domain, dkimRes
// ../rfc/7489:511
if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == from || result.Record.ADKIM == "r" && pubsuffix(from) == pubsuffix(dkimResult.Sig.Domain)) {
// ../rfc/7489:535
result.Reject = false
result.Status = StatusPass
return
result.AlignedDKIMPass = true
break
}
}
if result.AlignedSPFPass || result.AlignedDKIMPass {
result.Reject = false
result.Status = StatusPass
}
return
}