mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:04:39 +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:
@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user