maddy

Fork https://github.com/foxcpp/maddy

git clone git://git.lin.moe/go/maddy.git

  1/*
  2Maddy Mail Server - Composable all-in-one email server.
  3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
  4
  5This program is free software: you can redistribute it and/or modify
  6it under the terms of the GNU General Public License as published by
  7the Free Software Foundation, either version 3 of the License, or
  8(at your option) any later version.
  9
 10This program is distributed in the hope that it will be useful,
 11but WITHOUT ANY WARRANTY; without even the implied warranty of
 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 13GNU General Public License for more details.
 14
 15You should have received a copy of the GNU General Public License
 16along with this program.  If not, see <https://www.gnu.org/licenses/>.
 17*/
 18
 19package dmarc
 20
 21import (
 22	"context"
 23	"errors"
 24	"fmt"
 25	"net"
 26	"net/mail"
 27	"strings"
 28
 29	"github.com/emersion/go-message/textproto"
 30	"github.com/emersion/go-msgauth/authres"
 31	"github.com/emersion/go-msgauth/dmarc"
 32	"github.com/foxcpp/maddy/framework/address"
 33	"github.com/foxcpp/maddy/framework/dns"
 34	"golang.org/x/net/publicsuffix"
 35)
 36
 37// FetchRecord looks up the DMARC record relevant for the RFC5322.From domain.
 38// It returns the record and the domain it was found with (may not be
 39// equal to the RFC5322.From domain).
 40func FetchRecord(ctx context.Context, r Resolver, fromDomain string) (policyDomain string, rec *Record, err error) {
 41	policyDomain = fromDomain
 42
 43	// 1. Lookup using From Domain.
 44	txts, err := r.LookupTXT(ctx, dns.FQDN("_dmarc."+fromDomain))
 45	if err != nil {
 46		dnsErr, ok := err.(*net.DNSError)
 47		if !ok || !dnsErr.IsNotFound {
 48			return "", nil, err
 49		}
 50	}
 51	if len(txts) == 0 {
 52		// No records or 'no such host', try orgDomain.
 53		orgDomain, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)
 54		if err != nil {
 55			return "", nil, err
 56		}
 57
 58		policyDomain = orgDomain
 59
 60		txts, err = r.LookupTXT(ctx, dns.FQDN("_dmarc."+orgDomain))
 61		if err != nil {
 62			dnsErr, ok := err.(*net.DNSError)
 63			if !ok || !dnsErr.IsNotFound {
 64				return "", nil, err
 65			}
 66		}
 67		// Still nothing? Bail out.
 68		if len(txts) == 0 {
 69			return "", nil, nil
 70		}
 71	}
 72
 73	// Exclude records that are not DMARC policies.
 74	records := txts[:0]
 75	for _, txt := range txts {
 76		if strings.HasPrefix(txt, "v=DMARC1") {
 77			records = append(records, txt)
 78		}
 79	}
 80	// Multiple records => no record.
 81	if len(records) > 1 || len(records) == 0 {
 82		return "", nil, nil
 83	}
 84
 85	rec, err = dmarc.Parse(records[0])
 86
 87	return policyDomain, rec, err
 88}
 89
 90type EvalResult struct {
 91	// The Authentication-Results field generated as a result of the DMARC
 92	// check.
 93	Authres authres.DMARCResult
 94
 95	// The Authentication-Results field for SPF that was considered during
 96	// alignment check. May be empty.
 97	SPFResult authres.SPFResult
 98
 99	// Whether HELO or MAIL FROM match the RFC5322.From domain.
100	SPFAligned bool
101
102	// The Authentication-Results field for the DKIM signature that is aligned,
103	// if no signatures are aligned - this field contains the result for the
104	// first signature. May be empty.
105	DKIMResult authres.DKIMResult
106
107	// Whether there is a DKIM signature with the d= field matching the
108	// RFC5322.From domain.
109	DKIMAligned bool
110}
111
112// EvaluateAlignment checks whether identifiers authenticated by SPF and DKIM are in alignment
113// with the RFC5322.Domain.
114//
115// It returns EvalResult which contains the Authres field with the actual check result and
116// a bunch of other trace information that can be useful for troubleshooting
117// (and also report generation).
118func EvaluateAlignment(fromDomain string, record *Record, results []authres.Result) EvalResult {
119	var (
120		spfAligned   = false
121		spfResult    = authres.SPFResult{}
122		dkimAligned  = false
123		dkimResult   = authres.DKIMResult{}
124		dkimPresent  = false
125		dkimTempFail = false
126	)
127	for _, res := range results {
128		if dkimRes, ok := res.(*authres.DKIMResult); ok {
129			dkimPresent = true
130
131			// We want to return DKIM result for a signature provided by the orgDomain,
132			// in case there is none - return any (possibly misaligned) for reference.
133			if dkimResult.Value == "" {
134				dkimResult = *dkimRes
135			}
136			if isAligned(fromDomain, dkimRes.Domain, record.DKIMAlignment) {
137				dkimResult = *dkimRes
138				switch dkimRes.Value {
139				case authres.ResultPass:
140					dkimAligned = true
141				case authres.ResultTempError:
142					dkimTempFail = true
143				}
144			}
145		}
146		if spfRes, ok := res.(*authres.SPFResult); ok {
147			spfResult = *spfRes
148			var aligned bool
149			if spfRes.From == "" {
150				aligned = isAligned(fromDomain, spfRes.Helo, record.SPFAlignment)
151			} else {
152				aligned = isAligned(fromDomain, spfRes.From, record.SPFAlignment)
153			}
154			if aligned && spfRes.Value == authres.ResultPass {
155				spfAligned = true
156			}
157		}
158	}
159
160	res := EvalResult{
161		SPFResult:   spfResult,
162		SPFAligned:  spfAligned,
163		DKIMResult:  dkimResult,
164		DKIMAligned: dkimAligned,
165	}
166
167	if !dkimPresent || spfResult.Value == "" {
168		res.Authres = authres.DMARCResult{
169			Value:  authres.ResultNone,
170			Reason: "Not enough information (required checks are disabled)",
171			From:   fromDomain,
172		}
173		return res
174	}
175
176	if dkimTempFail && !dkimAligned && !spfAligned {
177		// We can't be sure whether it is aligned or not. Bail out.
178		res.Authres = authres.DMARCResult{
179			Value:  authres.ResultTempError,
180			Reason: "DKIM authentication temp error",
181			From:   fromDomain,
182		}
183		return res
184	}
185	if !dkimAligned && spfResult.Value == authres.ResultTempError {
186		// We can't be sure whether it is aligned or not. Bail out.
187		res.Authres = authres.DMARCResult{
188			Value:  authres.ResultTempError,
189			Reason: "SPF authentication temp error",
190			From:   fromDomain,
191		}
192		return res
193	}
194
195	res.Authres.From = fromDomain
196	if dkimAligned || spfAligned {
197		res.Authres.Value = authres.ResultPass
198	} else {
199		res.Authres.Value = authres.ResultFail
200		res.Authres.Reason = "No aligned identifiers"
201	}
202	return res
203}
204
205func isAligned(fromDomain, authDomain string, mode AlignmentMode) bool {
206	if mode == dmarc.AlignmentStrict {
207		return strings.EqualFold(fromDomain, authDomain)
208	}
209
210	tld, _ := publicsuffix.PublicSuffix(fromDomain)
211	if strings.EqualFold(fromDomain, tld) {
212		return strings.EqualFold(fromDomain, authDomain)
213	}
214	orgDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)
215	if err != nil {
216		return false
217	}
218	authDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(authDomain)
219	if err != nil {
220		return false
221	}
222
223	return strings.EqualFold(orgDomainFrom, authDomainFrom)
224}
225
226func ExtractFromDomain(hdr textproto.Header) (string, error) {
227	// TODO(GH emersion/go-message#75): Add textproto.Header.Count method.
228	var firstFrom string
229	for fields := hdr.FieldsByKey("From"); fields.Next(); {
230		if firstFrom == "" {
231			firstFrom = fields.Value()
232		} else {
233			return "", errors.New("dmarc: multiple From header fields are not allowed")
234		}
235	}
236	if firstFrom == "" {
237		return "", errors.New("dmarc: missing From header field")
238	}
239
240	hdrFromList, err := mail.ParseAddressList(firstFrom)
241	if err != nil {
242		return "", fmt.Errorf("dmarc: malformed From header field: %s", strings.TrimPrefix(err.Error(), "mail: "))
243	}
244	if len(hdrFromList) > 1 {
245		return "", errors.New("dmarc: multiple addresses in From field are not allowed")
246	}
247	if len(hdrFromList) == 0 {
248		return "", errors.New("dmarc: missing address in From field")
249	}
250	_, domain, err := address.Split(hdrFromList[0].Address)
251	if err != nil {
252		return "", fmt.Errorf("dmarc: malformed From header field: %w", err)
253	}
254
255	return domain, nil
256}