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 spf
 20
 21import (
 22	"context"
 23	"errors"
 24	"fmt"
 25	"net"
 26	"runtime/debug"
 27	"runtime/trace"
 28
 29	"blitiri.com.ar/go/spf"
 30	"github.com/emersion/go-message/textproto"
 31	"github.com/emersion/go-msgauth/authres"
 32	"github.com/emersion/go-msgauth/dmarc"
 33	"github.com/foxcpp/maddy/framework/address"
 34	"github.com/foxcpp/maddy/framework/buffer"
 35	"github.com/foxcpp/maddy/framework/config"
 36	modconfig "github.com/foxcpp/maddy/framework/config/module"
 37	"github.com/foxcpp/maddy/framework/dns"
 38	"github.com/foxcpp/maddy/framework/exterrors"
 39	"github.com/foxcpp/maddy/framework/log"
 40	"github.com/foxcpp/maddy/framework/module"
 41	maddydmarc "github.com/foxcpp/maddy/internal/dmarc"
 42	"github.com/foxcpp/maddy/internal/target"
 43	"golang.org/x/net/idna"
 44)
 45
 46const modName = "check.spf"
 47
 48type Check struct {
 49	instName     string
 50	enforceEarly bool
 51
 52	noneAction     modconfig.FailAction
 53	neutralAction  modconfig.FailAction
 54	failAction     modconfig.FailAction
 55	softfailAction modconfig.FailAction
 56	permerrAction  modconfig.FailAction
 57	temperrAction  modconfig.FailAction
 58
 59	log      log.Logger
 60	resolver dns.Resolver
 61}
 62
 63func New(_, instName string, _, _ []string) (module.Module, error) {
 64	return &Check{
 65		instName: instName,
 66		log:      log.Logger{Name: modName},
 67		resolver: dns.DefaultResolver(),
 68	}, nil
 69}
 70
 71func (c *Check) Name() string {
 72	return modName
 73}
 74
 75func (c *Check) InstanceName() string {
 76	return c.instName
 77}
 78
 79func (c *Check) Init(cfg *config.Map) error {
 80	cfg.Bool("debug", true, false, &c.log.Debug)
 81	cfg.Bool("enforce_early", true, false, &c.enforceEarly)
 82	cfg.Custom("none_action", false, false,
 83		func() (interface{}, error) {
 84			return modconfig.FailAction{}, nil
 85		}, modconfig.FailActionDirective, &c.noneAction)
 86	cfg.Custom("neutral_action", false, false,
 87		func() (interface{}, error) {
 88			return modconfig.FailAction{}, nil
 89		}, modconfig.FailActionDirective, &c.neutralAction)
 90	cfg.Custom("fail_action", false, false,
 91		func() (interface{}, error) {
 92			return modconfig.FailAction{Quarantine: true}, nil
 93		}, modconfig.FailActionDirective, &c.failAction)
 94	cfg.Custom("softfail_action", false, false,
 95		func() (interface{}, error) {
 96			return modconfig.FailAction{}, nil
 97		}, modconfig.FailActionDirective, &c.softfailAction)
 98	cfg.Custom("permerr_action", false, false,
 99		func() (interface{}, error) {
100			return modconfig.FailAction{}, nil
101		}, modconfig.FailActionDirective, &c.permerrAction)
102	cfg.Custom("temperr_action", false, false,
103		func() (interface{}, error) {
104			return modconfig.FailAction{}, nil
105		}, modconfig.FailActionDirective, &c.temperrAction)
106	_, err := cfg.Process()
107	if err != nil {
108		return err
109	}
110
111	return nil
112}
113
114type spfRes struct {
115	res spf.Result
116	err error
117}
118
119type state struct {
120	c        *Check
121	msgMeta  *module.MsgMetadata
122	spfFetch chan spfRes
123	log      log.Logger
124
125	skip bool
126}
127
128func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
129	return &state{
130		c:        c,
131		msgMeta:  msgMeta,
132		spfFetch: make(chan spfRes, 1),
133		log:      target.DeliveryLogger(c.log, msgMeta),
134	}, nil
135}
136
137func (s *state) spfResult(res spf.Result, err error) module.CheckResult {
138	_, fromDomain, _ := address.Split(s.msgMeta.OriginalFrom)
139	spfAuth := &authres.SPFResult{
140		Value: authres.ResultNone,
141		Helo:  s.msgMeta.Conn.Hostname,
142		From:  fromDomain,
143	}
144
145	if err != nil {
146		spfAuth.Reason = err.Error()
147	} else if res == spf.None {
148		spfAuth.Reason = "no policy"
149	}
150
151	switch res {
152	case spf.None:
153		spfAuth.Value = authres.ResultNone
154		return s.c.noneAction.Apply(module.CheckResult{
155			Reason: &exterrors.SMTPError{
156				Code:         550,
157				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
158				Message:      "No SPF policy",
159				CheckName:    modName,
160				Err:          err,
161			},
162			AuthResult: []authres.Result{spfAuth},
163		})
164	case spf.Neutral:
165		spfAuth.Value = authres.ResultNeutral
166		return s.c.neutralAction.Apply(module.CheckResult{
167			Reason: &exterrors.SMTPError{
168				Code:         550,
169				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
170				Message:      "Neutral SPF result is not permitted",
171				CheckName:    modName,
172				Err:          err,
173			},
174			AuthResult: []authres.Result{spfAuth},
175		})
176	case spf.Pass:
177		spfAuth.Value = authres.ResultPass
178		return module.CheckResult{AuthResult: []authres.Result{spfAuth}}
179	case spf.Fail:
180		spfAuth.Value = authres.ResultFail
181		return s.c.failAction.Apply(module.CheckResult{
182			Reason: &exterrors.SMTPError{
183				Code:         550,
184				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
185				Message:      "SPF authentication failed",
186				CheckName:    modName,
187				Err:          err,
188			},
189			AuthResult: []authres.Result{spfAuth},
190		})
191	case spf.SoftFail:
192		spfAuth.Value = authres.ResultSoftFail
193		return s.c.softfailAction.Apply(module.CheckResult{
194			Reason: &exterrors.SMTPError{
195				Code:         550,
196				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
197				Message:      "SPF authentication soft-failed",
198				CheckName:    modName,
199				Err:          err,
200			},
201			AuthResult: []authres.Result{spfAuth},
202		})
203	case spf.TempError:
204		spfAuth.Value = authres.ResultTempError
205		return s.c.temperrAction.Apply(module.CheckResult{
206			Reason: &exterrors.SMTPError{
207				Code:         451,
208				EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
209				Message:      "SPF authentication failed with a temporary error",
210				CheckName:    modName,
211				Err:          err,
212			},
213			AuthResult: []authres.Result{spfAuth},
214		})
215	case spf.PermError:
216		spfAuth.Value = authres.ResultPermError
217		return s.c.permerrAction.Apply(module.CheckResult{
218			Reason: &exterrors.SMTPError{
219				Code:         550,
220				EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
221				Message:      "SPF authentication failed with a permanent error",
222				CheckName:    modName,
223				Err:          err,
224			},
225			AuthResult: []authres.Result{spfAuth},
226		})
227	}
228
229	return module.CheckResult{
230		Reason: &exterrors.SMTPError{
231			Code:         550,
232			EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
233			Message:      fmt.Sprintf("Unknown SPF status: %s", res),
234			CheckName:    modName,
235			Err:          err,
236		},
237		AuthResult: []authres.Result{spfAuth},
238	}
239}
240
241func (s *state) relyOnDMARC(ctx context.Context, hdr textproto.Header) bool {
242	fromDomain, err := maddydmarc.ExtractFromDomain(hdr)
243	if err != nil {
244		s.log.Error("DMARC domains extract", err)
245		return false
246	}
247
248	policyDomain, record, err := maddydmarc.FetchRecord(ctx, s.c.resolver, fromDomain)
249	if err != nil {
250		s.log.Error("DMARC fetch", err, "from_domain", fromDomain)
251		return false
252	}
253	if record == nil {
254		return false
255	}
256
257	policy := record.Policy
258	// We check for subdomain using non-equality since fromDomain is either the
259	// subdomain of policyDomain or policyDomain itself (due to the way
260	// FetchRecord handles it).
261	if !dns.Equal(policyDomain, fromDomain) && record.SubdomainPolicy != "" {
262		policy = record.SubdomainPolicy
263	}
264
265	return policy != dmarc.PolicyNone
266}
267
268func prepareMailFrom(from string) (string, error) {
269	// INTERNATIONALIZATION: RFC 8616, Section 4
270	// Hostname is already in A-labels per SMTPUTF8 requirement.
271	// MAIL FROM domain should be converted to A-labels before doing
272	// anything.
273	fromMbox, fromDomain, err := address.Split(from)
274	if err != nil {
275		return "", &exterrors.SMTPError{
276			Code:         550,
277			EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
278			Message:      "Malformed address",
279			CheckName:    "spf",
280		}
281	}
282	fromDomain, err = idna.ToASCII(fromDomain)
283	if err != nil {
284		return "", &exterrors.SMTPError{
285			Code:         550,
286			EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
287			Message:      "Malformed address",
288			CheckName:    "spf",
289		}
290	}
291
292	// %{s} and %{l} do not match anything if it is non-ASCII.
293	// Since spf lib does not seem to care, strip it.
294	if !address.IsASCII(fromMbox) {
295		fromMbox = ""
296	}
297
298	return fromMbox + "@" + dns.FQDN(fromDomain), nil
299}
300
301func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
302	defer trace.StartRegion(ctx, "check.spf/CheckConnection").End()
303
304	if s.msgMeta.Conn == nil {
305		s.skip = true
306		s.log.Println("locally generated message, skipping")
307		return module.CheckResult{}
308	}
309
310	ip, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
311	if !ok {
312		s.skip = true
313		s.log.Println("non-IP SrcAddr")
314		return module.CheckResult{}
315	}
316
317	mailFromOriginal := s.msgMeta.OriginalFrom
318	if mailFromOriginal == "" {
319		// RFC 7208 Section 2.4.
320		// >When the reverse-path is null, this document
321		// >defines the "MAIL FROM" identity to be the mailbox composed of the
322		// >local-part "postmaster" and the "HELO" identity (which might or might
323		// >not have been checked separately before).
324		mailFromOriginal = "postmaster@" + s.msgMeta.Conn.Hostname
325	}
326
327	mailFrom, err := prepareMailFrom(mailFromOriginal)
328	if err != nil {
329		s.skip = true
330		return module.CheckResult{
331			Reason: err,
332			Reject: true,
333		}
334	}
335
336	if s.c.enforceEarly {
337		res, err := spf.CheckHostWithSender(ip.IP,
338			dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
339			spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
340		s.log.Debugf("result: %s (%v)", res, err)
341		return s.spfResult(res, err)
342	}
343
344	// We start evaluation in parallel to other message processing,
345	// once we get the body, we fetch DMARC policy and see if it exists
346	// and not p=none. In that case, we rely on DMARC alignment to define result.
347	// Otherwise, we take action based on SPF only.
348
349	go func() {
350		defer func() {
351			if err := recover(); err != nil {
352				stack := debug.Stack()
353				log.Printf("panic during spf.CheckHostWithSender: %v\n%s", err, stack)
354				close(s.spfFetch)
355			}
356		}()
357
358		defer trace.StartRegion(ctx, "check.spf/CheckConnection (Async)").End()
359
360		res, err := spf.CheckHostWithSender(ip.IP, dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
361			spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
362		s.log.Debugf("result: %s (%v)", res, err)
363		s.spfFetch <- spfRes{res, err}
364	}()
365
366	return module.CheckResult{}
367}
368
369func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
370	return module.CheckResult{}
371}
372
373func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
374	return module.CheckResult{}
375}
376
377func (s *state) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
378	if s.c.enforceEarly || s.skip {
379		// Already applied in CheckConnection.
380		return module.CheckResult{}
381	}
382
383	defer trace.StartRegion(ctx, "check.spf/CheckBody").End()
384
385	res, ok := <-s.spfFetch
386	if !ok {
387		return module.CheckResult{
388			Reject: true,
389			Reason: exterrors.WithTemporary(
390				exterrors.WithFields(errors.New("panic recovered"), map[string]interface{}{
391					"check":    "spf",
392					"smtp_msg": "Internal error during policy check",
393				}),
394				true,
395			),
396		}
397	}
398	if s.relyOnDMARC(ctx, header) {
399		if res.res != spf.Pass {
400			s.log.Msg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
401		} else {
402			s.log.DebugMsg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
403		}
404
405		checkRes := s.spfResult(res.res, res.err)
406		checkRes.Quarantine = false
407		checkRes.Reject = false
408		return checkRes
409	}
410
411	return s.spfResult(res.res, res.err)
412}
413
414func (s *state) Close() error {
415	return nil
416}
417
418func init() {
419	module.Register(modName, New)
420}