mirror of
https://github.com/foxcpp/maddy.git
synced 2025-04-04 21:47:40 +03:00
420 lines
12 KiB
Go
420 lines
12 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 spf
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"runtime/debug"
|
|
"runtime/trace"
|
|
|
|
"blitiri.com.ar/go/spf"
|
|
"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/buffer"
|
|
"github.com/foxcpp/maddy/framework/config"
|
|
modconfig "github.com/foxcpp/maddy/framework/config/module"
|
|
"github.com/foxcpp/maddy/framework/dns"
|
|
"github.com/foxcpp/maddy/framework/exterrors"
|
|
"github.com/foxcpp/maddy/framework/log"
|
|
"github.com/foxcpp/maddy/framework/module"
|
|
maddydmarc "github.com/foxcpp/maddy/internal/dmarc"
|
|
"github.com/foxcpp/maddy/internal/target"
|
|
"golang.org/x/net/idna"
|
|
)
|
|
|
|
const modName = "check.spf"
|
|
|
|
type Check struct {
|
|
instName string
|
|
enforceEarly bool
|
|
|
|
noneAction modconfig.FailAction
|
|
neutralAction modconfig.FailAction
|
|
failAction modconfig.FailAction
|
|
softfailAction modconfig.FailAction
|
|
permerrAction modconfig.FailAction
|
|
temperrAction modconfig.FailAction
|
|
|
|
log log.Logger
|
|
resolver dns.Resolver
|
|
}
|
|
|
|
func New(_, instName string, _, _ []string) (module.Module, error) {
|
|
return &Check{
|
|
instName: instName,
|
|
log: log.Logger{Name: modName},
|
|
resolver: dns.DefaultResolver(),
|
|
}, nil
|
|
}
|
|
|
|
func (c *Check) Name() string {
|
|
return modName
|
|
}
|
|
|
|
func (c *Check) InstanceName() string {
|
|
return c.instName
|
|
}
|
|
|
|
func (c *Check) Init(cfg *config.Map) error {
|
|
cfg.Bool("debug", true, false, &c.log.Debug)
|
|
cfg.Bool("enforce_early", true, false, &c.enforceEarly)
|
|
cfg.Custom("none_action", false, false,
|
|
func() (interface{}, error) {
|
|
return modconfig.FailAction{}, nil
|
|
}, modconfig.FailActionDirective, &c.noneAction)
|
|
cfg.Custom("neutral_action", false, false,
|
|
func() (interface{}, error) {
|
|
return modconfig.FailAction{}, nil
|
|
}, modconfig.FailActionDirective, &c.neutralAction)
|
|
cfg.Custom("fail_action", false, false,
|
|
func() (interface{}, error) {
|
|
return modconfig.FailAction{Quarantine: true}, nil
|
|
}, modconfig.FailActionDirective, &c.failAction)
|
|
cfg.Custom("softfail_action", false, false,
|
|
func() (interface{}, error) {
|
|
return modconfig.FailAction{}, nil
|
|
}, modconfig.FailActionDirective, &c.softfailAction)
|
|
cfg.Custom("permerr_action", false, false,
|
|
func() (interface{}, error) {
|
|
return modconfig.FailAction{}, nil
|
|
}, modconfig.FailActionDirective, &c.permerrAction)
|
|
cfg.Custom("temperr_action", false, false,
|
|
func() (interface{}, error) {
|
|
return modconfig.FailAction{}, nil
|
|
}, modconfig.FailActionDirective, &c.temperrAction)
|
|
_, err := cfg.Process()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type spfRes struct {
|
|
res spf.Result
|
|
err error
|
|
}
|
|
|
|
type state struct {
|
|
c *Check
|
|
msgMeta *module.MsgMetadata
|
|
spfFetch chan spfRes
|
|
log log.Logger
|
|
|
|
skip bool
|
|
}
|
|
|
|
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
|
|
return &state{
|
|
c: c,
|
|
msgMeta: msgMeta,
|
|
spfFetch: make(chan spfRes, 1),
|
|
log: target.DeliveryLogger(c.log, msgMeta),
|
|
}, nil
|
|
}
|
|
|
|
func (s *state) spfResult(res spf.Result, err error) module.CheckResult {
|
|
_, fromDomain, _ := address.Split(s.msgMeta.OriginalFrom)
|
|
spfAuth := &authres.SPFResult{
|
|
Value: authres.ResultNone,
|
|
Helo: s.msgMeta.Conn.Hostname,
|
|
From: fromDomain,
|
|
}
|
|
|
|
if err != nil {
|
|
spfAuth.Reason = err.Error()
|
|
} else if res == spf.None {
|
|
spfAuth.Reason = "no policy"
|
|
}
|
|
|
|
switch res {
|
|
case spf.None:
|
|
spfAuth.Value = authres.ResultNone
|
|
return s.c.noneAction.Apply(module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
|
|
Message: "No SPF policy",
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
})
|
|
case spf.Neutral:
|
|
spfAuth.Value = authres.ResultNeutral
|
|
return s.c.neutralAction.Apply(module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
|
|
Message: "Neutral SPF result is not permitted",
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
})
|
|
case spf.Pass:
|
|
spfAuth.Value = authres.ResultPass
|
|
return module.CheckResult{AuthResult: []authres.Result{spfAuth}}
|
|
case spf.Fail:
|
|
spfAuth.Value = authres.ResultFail
|
|
return s.c.failAction.Apply(module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
|
|
Message: "SPF authentication failed",
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
})
|
|
case spf.SoftFail:
|
|
spfAuth.Value = authres.ResultSoftFail
|
|
return s.c.softfailAction.Apply(module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
|
|
Message: "SPF authentication soft-failed",
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
})
|
|
case spf.TempError:
|
|
spfAuth.Value = authres.ResultTempError
|
|
return s.c.temperrAction.Apply(module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 451,
|
|
EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
|
|
Message: "SPF authentication failed with a temporary error",
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
})
|
|
case spf.PermError:
|
|
spfAuth.Value = authres.ResultPermError
|
|
return s.c.permerrAction.Apply(module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
|
|
Message: "SPF authentication failed with a permanent error",
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
})
|
|
}
|
|
|
|
return module.CheckResult{
|
|
Reason: &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
|
|
Message: fmt.Sprintf("Unknown SPF status: %s", res),
|
|
CheckName: modName,
|
|
Err: err,
|
|
},
|
|
AuthResult: []authres.Result{spfAuth},
|
|
}
|
|
}
|
|
|
|
func (s *state) relyOnDMARC(ctx context.Context, hdr textproto.Header) bool {
|
|
fromDomain, err := maddydmarc.ExtractFromDomain(hdr)
|
|
if err != nil {
|
|
s.log.Error("DMARC domains extract", err)
|
|
return false
|
|
}
|
|
|
|
policyDomain, record, err := maddydmarc.FetchRecord(ctx, s.c.resolver, fromDomain)
|
|
if err != nil {
|
|
s.log.Error("DMARC fetch", err, "from_domain", fromDomain)
|
|
return false
|
|
}
|
|
if record == nil {
|
|
return false
|
|
}
|
|
|
|
policy := record.Policy
|
|
// We check for subdomain using non-equality since fromDomain is either the
|
|
// subdomain of policyDomain or policyDomain itself (due to the way
|
|
// FetchRecord handles it).
|
|
if !dns.Equal(policyDomain, fromDomain) && record.SubdomainPolicy != "" {
|
|
policy = record.SubdomainPolicy
|
|
}
|
|
|
|
return policy != dmarc.PolicyNone
|
|
}
|
|
|
|
func prepareMailFrom(from string) (string, error) {
|
|
// INTERNATIONALIZATION: RFC 8616, Section 4
|
|
// Hostname is already in A-labels per SMTPUTF8 requirement.
|
|
// MAIL FROM domain should be converted to A-labels before doing
|
|
// anything.
|
|
fromMbox, fromDomain, err := address.Split(from)
|
|
if err != nil {
|
|
return "", &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
|
|
Message: "Malformed address",
|
|
CheckName: "spf",
|
|
}
|
|
}
|
|
fromDomain, err = idna.ToASCII(fromDomain)
|
|
if err != nil {
|
|
return "", &exterrors.SMTPError{
|
|
Code: 550,
|
|
EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
|
|
Message: "Malformed address",
|
|
CheckName: "spf",
|
|
}
|
|
}
|
|
|
|
// %{s} and %{l} do not match anything if it is non-ASCII.
|
|
// Since spf lib does not seem to care, strip it.
|
|
if !address.IsASCII(fromMbox) {
|
|
fromMbox = ""
|
|
}
|
|
|
|
return fromMbox + "@" + dns.FQDN(fromDomain), nil
|
|
}
|
|
|
|
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
|
|
defer trace.StartRegion(ctx, "check.spf/CheckConnection").End()
|
|
|
|
if s.msgMeta.Conn == nil {
|
|
s.skip = true
|
|
s.log.Println("locally generated message, skipping")
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
ip, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
|
|
if !ok {
|
|
s.skip = true
|
|
s.log.Println("non-IP SrcAddr")
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
mailFromOriginal := s.msgMeta.OriginalFrom
|
|
if mailFromOriginal == "" {
|
|
// RFC 7208 Section 2.4.
|
|
// >When the reverse-path is null, this document
|
|
// >defines the "MAIL FROM" identity to be the mailbox composed of the
|
|
// >local-part "postmaster" and the "HELO" identity (which might or might
|
|
// >not have been checked separately before).
|
|
mailFromOriginal = "postmaster@" + s.msgMeta.Conn.Hostname
|
|
}
|
|
|
|
mailFrom, err := prepareMailFrom(mailFromOriginal)
|
|
if err != nil {
|
|
s.skip = true
|
|
return module.CheckResult{
|
|
Reason: err,
|
|
Reject: true,
|
|
}
|
|
}
|
|
|
|
if s.c.enforceEarly {
|
|
res, err := spf.CheckHostWithSender(ip.IP,
|
|
dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
|
|
spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
|
|
s.log.Debugf("result: %s (%v)", res, err)
|
|
return s.spfResult(res, err)
|
|
}
|
|
|
|
// We start evaluation in parallel to other message processing,
|
|
// once we get the body, we fetch DMARC policy and see if it exists
|
|
// and not p=none. In that case, we rely on DMARC alignment to define result.
|
|
// Otherwise, we take action based on SPF only.
|
|
|
|
go func() {
|
|
defer func() {
|
|
if err := recover(); err != nil {
|
|
stack := debug.Stack()
|
|
log.Printf("panic during spf.CheckHostWithSender: %v\n%s", err, stack)
|
|
close(s.spfFetch)
|
|
}
|
|
}()
|
|
|
|
defer trace.StartRegion(ctx, "check.spf/CheckConnection (Async)").End()
|
|
|
|
res, err := spf.CheckHostWithSender(ip.IP, dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
|
|
spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
|
|
s.log.Debugf("result: %s (%v)", res, err)
|
|
s.spfFetch <- spfRes{res, err}
|
|
}()
|
|
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
func (s *state) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
|
|
if s.c.enforceEarly || s.skip {
|
|
// Already applied in CheckConnection.
|
|
return module.CheckResult{}
|
|
}
|
|
|
|
defer trace.StartRegion(ctx, "check.spf/CheckBody").End()
|
|
|
|
res, ok := <-s.spfFetch
|
|
if !ok {
|
|
return module.CheckResult{
|
|
Reject: true,
|
|
Reason: exterrors.WithTemporary(
|
|
exterrors.WithFields(errors.New("panic recovered"), map[string]interface{}{
|
|
"check": "spf",
|
|
"smtp_msg": "Internal error during policy check",
|
|
}),
|
|
true,
|
|
),
|
|
}
|
|
}
|
|
if s.relyOnDMARC(ctx, header) {
|
|
if res.res != spf.Pass {
|
|
s.log.Msg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
|
|
} else {
|
|
s.log.DebugMsg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
|
|
}
|
|
|
|
checkRes := s.spfResult(res.res, res.err)
|
|
checkRes.Quarantine = false
|
|
checkRes.Reject = false
|
|
return checkRes
|
|
}
|
|
|
|
return s.spfResult(res.res, res.err)
|
|
}
|
|
|
|
func (s *state) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func init() {
|
|
module.Register(modName, New)
|
|
}
|