maddy/internal/dmarc/evaluate.go
2024-12-09 09:03:45 +01:00

256 lines
7.3 KiB
Go

/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dmarc
import (
"context"
"errors"
"fmt"
"net"
"net/mail"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/emersion/go-msgauth/dmarc"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/dns"
"golang.org/x/net/publicsuffix"
)
// FetchRecord looks up the DMARC record relevant for the RFC5322.From domain.
// It returns the record and the domain it was found with (may not be
// equal to the RFC5322.From domain).
func FetchRecord(ctx context.Context, r Resolver, fromDomain string) (policyDomain string, rec *Record, err error) {
policyDomain = fromDomain
// 1. Lookup using From Domain.
txts, err := r.LookupTXT(ctx, dns.FQDN("_dmarc."+fromDomain))
if err != nil {
dnsErr, ok := err.(*net.DNSError)
if !ok || !dnsErr.IsNotFound {
return "", nil, err
}
}
if len(txts) == 0 {
// No records or 'no such host', try orgDomain.
orgDomain, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)
if err != nil {
return "", nil, err
}
policyDomain = orgDomain
txts, err = r.LookupTXT(ctx, dns.FQDN("_dmarc."+orgDomain))
if err != nil {
dnsErr, ok := err.(*net.DNSError)
if !ok || !dnsErr.IsNotFound {
return "", nil, err
}
}
// Still nothing? Bail out.
if len(txts) == 0 {
return "", nil, nil
}
}
// Exclude records that are not DMARC policies.
records := txts[:0]
for _, txt := range txts {
if strings.HasPrefix(txt, "v=DMARC1") {
records = append(records, txt)
}
}
// Multiple records => no record.
if len(records) > 1 || len(records) == 0 {
return "", nil, nil
}
rec, err = dmarc.Parse(records[0])
return policyDomain, rec, err
}
type EvalResult struct {
// The Authentication-Results field generated as a result of the DMARC
// check.
Authres authres.DMARCResult
// The Authentication-Results field for SPF that was considered during
// alignment check. May be empty.
SPFResult authres.SPFResult
// Whether HELO or MAIL FROM match the RFC5322.From domain.
SPFAligned bool
// The Authentication-Results field for the DKIM signature that is aligned,
// if no signatures are aligned - this field contains the result for the
// first signature. May be empty.
DKIMResult authres.DKIMResult
// Whether there is a DKIM signature with the d= field matching the
// RFC5322.From domain.
DKIMAligned bool
}
// EvaluateAlignment checks whether identifiers authenticated by SPF and DKIM are in alignment
// with the RFC5322.Domain.
//
// It returns EvalResult which contains the Authres field with the actual check result and
// a bunch of other trace information that can be useful for troubleshooting
// (and also report generation).
func EvaluateAlignment(fromDomain string, record *Record, results []authres.Result) EvalResult {
var (
spfAligned = false
spfResult = authres.SPFResult{}
dkimAligned = false
dkimResult = authres.DKIMResult{}
dkimPresent = false
dkimTempFail = false
)
for _, res := range results {
if dkimRes, ok := res.(*authres.DKIMResult); ok {
dkimPresent = true
// We want to return DKIM result for a signature provided by the orgDomain,
// in case there is none - return any (possibly misaligned) for reference.
if dkimResult.Value == "" {
dkimResult = *dkimRes
}
if isAligned(fromDomain, dkimRes.Domain, record.DKIMAlignment) {
dkimResult = *dkimRes
switch dkimRes.Value {
case authres.ResultPass:
dkimAligned = true
case authres.ResultTempError:
dkimTempFail = true
}
}
}
if spfRes, ok := res.(*authres.SPFResult); ok {
spfResult = *spfRes
var aligned bool
if spfRes.From == "" {
aligned = isAligned(fromDomain, spfRes.Helo, record.SPFAlignment)
} else {
aligned = isAligned(fromDomain, spfRes.From, record.SPFAlignment)
}
if aligned && spfRes.Value == authres.ResultPass {
spfAligned = true
}
}
}
res := EvalResult{
SPFResult: spfResult,
SPFAligned: spfAligned,
DKIMResult: dkimResult,
DKIMAligned: dkimAligned,
}
if !dkimPresent || spfResult.Value == "" {
res.Authres = authres.DMARCResult{
Value: authres.ResultNone,
Reason: "Not enough information (required checks are disabled)",
From: fromDomain,
}
return res
}
if dkimTempFail && !dkimAligned && !spfAligned {
// We can't be sure whether it is aligned or not. Bail out.
res.Authres = authres.DMARCResult{
Value: authres.ResultTempError,
Reason: "DKIM authentication temp error",
From: fromDomain,
}
return res
}
if !dkimAligned && spfResult.Value == authres.ResultTempError {
// We can't be sure whether it is aligned or not. Bail out.
res.Authres = authres.DMARCResult{
Value: authres.ResultTempError,
Reason: "SPF authentication temp error",
From: fromDomain,
}
return res
}
res.Authres.From = fromDomain
if dkimAligned || spfAligned {
res.Authres.Value = authres.ResultPass
} else {
res.Authres.Value = authres.ResultFail
res.Authres.Reason = "No aligned identifiers"
}
return res
}
func isAligned(fromDomain, authDomain string, mode AlignmentMode) bool {
if mode == dmarc.AlignmentStrict {
return strings.EqualFold(fromDomain, authDomain)
}
tld, _ := publicsuffix.PublicSuffix(fromDomain)
if strings.EqualFold(fromDomain, tld) {
return strings.EqualFold(fromDomain, authDomain)
}
orgDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)
if err != nil {
return false
}
authDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(authDomain)
if err != nil {
return false
}
return strings.EqualFold(orgDomainFrom, authDomainFrom)
}
func ExtractFromDomain(hdr textproto.Header) (string, error) {
// TODO(GH emersion/go-message#75): Add textproto.Header.Count method.
var firstFrom string
for fields := hdr.FieldsByKey("From"); fields.Next(); {
if firstFrom == "" {
firstFrom = fields.Value()
} else {
return "", errors.New("dmarc: multiple From header fields are not allowed")
}
}
if firstFrom == "" {
return "", errors.New("dmarc: missing From header field")
}
hdrFromList, err := mail.ParseAddressList(firstFrom)
if err != nil {
return "", fmt.Errorf("dmarc: malformed From header field: %s", strings.TrimPrefix(err.Error(), "mail: "))
}
if len(hdrFromList) > 1 {
return "", errors.New("dmarc: multiple addresses in From field are not allowed")
}
if len(hdrFromList) == 0 {
return "", errors.New("dmarc: missing address in From field")
}
_, domain, err := address.Split(hdrFromList[0].Address)
if err != nil {
return "", fmt.Errorf("dmarc: malformed From header field: %w", err)
}
return domain, nil
}