1/*2Maddy Mail Server - Composable all-in-one email server.3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors45This program is free software: you can redistribute it and/or modify6it under the terms of the GNU General Public License as published by7the Free Software Foundation, either version 3 of the License, or8(at your option) any later version.910This program is distributed in the hope that it will be useful,11but WITHOUT ANY WARRANTY; without even the implied warranty of12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the13GNU General Public License for more details.1415You should have received a copy of the GNU General Public License16along with this program. If not, see <https://www.gnu.org/licenses/>.17*/1819package spf2021import (22 "context"23 "errors"24 "fmt"25 "net"26 "runtime/debug"27 "runtime/trace"2829 "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)4546const modName = "check.spf"4748type Check struct {49 instName string50 enforceEarly bool5152 noneAction modconfig.FailAction53 neutralAction modconfig.FailAction54 failAction modconfig.FailAction55 softfailAction modconfig.FailAction56 permerrAction modconfig.FailAction57 temperrAction modconfig.FailAction5859 log log.Logger60 resolver dns.Resolver61}6263func New(_, instName string, _, _ []string) (module.Module, error) {64 return &Check{65 instName: instName,66 log: log.Logger{Name: modName},67 resolver: dns.DefaultResolver(),68 }, nil69}7071func (c *Check) Name() string {72 return modName73}7475func (c *Check) InstanceName() string {76 return c.instName77}7879func (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{}, nil85 }, modconfig.FailActionDirective, &c.noneAction)86 cfg.Custom("neutral_action", false, false,87 func() (interface{}, error) {88 return modconfig.FailAction{}, nil89 }, modconfig.FailActionDirective, &c.neutralAction)90 cfg.Custom("fail_action", false, false,91 func() (interface{}, error) {92 return modconfig.FailAction{Quarantine: true}, nil93 }, modconfig.FailActionDirective, &c.failAction)94 cfg.Custom("softfail_action", false, false,95 func() (interface{}, error) {96 return modconfig.FailAction{}, nil97 }, modconfig.FailActionDirective, &c.softfailAction)98 cfg.Custom("permerr_action", false, false,99 func() (interface{}, error) {100 return modconfig.FailAction{}, nil101 }, modconfig.FailActionDirective, &c.permerrAction)102 cfg.Custom("temperr_action", false, false,103 func() (interface{}, error) {104 return modconfig.FailAction{}, nil105 }, modconfig.FailActionDirective, &c.temperrAction)106 _, err := cfg.Process()107 if err != nil {108 return err109 }110111 return nil112}113114type spfRes struct {115 res spf.Result116 err error117}118119type state struct {120 c *Check121 msgMeta *module.MsgMetadata122 spfFetch chan spfRes123 log log.Logger124125 skip bool126}127128func (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 }, nil135}136137func (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 }144145 if err != nil {146 spfAuth.Reason = err.Error()147 } else if res == spf.None {148 spfAuth.Reason = "no policy"149 }150151 switch res {152 case spf.None:153 spfAuth.Value = authres.ResultNone154 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.ResultNeutral166 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.ResultPass178 return module.CheckResult{AuthResult: []authres.Result{spfAuth}}179 case spf.Fail:180 spfAuth.Value = authres.ResultFail181 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.ResultSoftFail193 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.ResultTempError205 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.ResultPermError217 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 }228229 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}240241func (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 false246 }247248 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 false252 }253 if record == nil {254 return false255 }256257 policy := record.Policy258 // We check for subdomain using non-equality since fromDomain is either the259 // subdomain of policyDomain or policyDomain itself (due to the way260 // FetchRecord handles it).261 if !dns.Equal(policyDomain, fromDomain) && record.SubdomainPolicy != "" {262 policy = record.SubdomainPolicy263 }264265 return policy != dmarc.PolicyNone266}267268func prepareMailFrom(from string) (string, error) {269 // INTERNATIONALIZATION: RFC 8616, Section 4270 // Hostname is already in A-labels per SMTPUTF8 requirement.271 // MAIL FROM domain should be converted to A-labels before doing272 // 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 }291292 // %{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 }297298 return fromMbox + "@" + dns.FQDN(fromDomain), nil299}300301func (s *state) CheckConnection(ctx context.Context) module.CheckResult {302 defer trace.StartRegion(ctx, "check.spf/CheckConnection").End()303304 if s.msgMeta.Conn == nil {305 s.skip = true306 s.log.Println("locally generated message, skipping")307 return module.CheckResult{}308 }309310 ip, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)311 if !ok {312 s.skip = true313 s.log.Println("non-IP SrcAddr")314 return module.CheckResult{}315 }316317 mailFromOriginal := s.msgMeta.OriginalFrom318 if mailFromOriginal == "" {319 // RFC 7208 Section 2.4.320 // >When the reverse-path is null, this document321 // >defines the "MAIL FROM" identity to be the mailbox composed of the322 // >local-part "postmaster" and the "HELO" identity (which might or might323 // >not have been checked separately before).324 mailFromOriginal = "postmaster@" + s.msgMeta.Conn.Hostname325 }326327 mailFrom, err := prepareMailFrom(mailFromOriginal)328 if err != nil {329 s.skip = true330 return module.CheckResult{331 Reason: err,332 Reject: true,333 }334 }335336 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 }343344 // We start evaluation in parallel to other message processing,345 // once we get the body, we fetch DMARC policy and see if it exists346 // and not p=none. In that case, we rely on DMARC alignment to define result.347 // Otherwise, we take action based on SPF only.348349 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 }()357358 defer trace.StartRegion(ctx, "check.spf/CheckConnection (Async)").End()359360 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 }()365366 return module.CheckResult{}367}368369func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {370 return module.CheckResult{}371}372373func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {374 return module.CheckResult{}375}376377func (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 }382383 defer trace.StartRegion(ctx, "check.spf/CheckBody").End()384385 res, ok := <-s.spfFetch386 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 }404405 checkRes := s.spfResult(res.res, res.err)406 checkRes.Quarantine = false407 checkRes.Reject = false408 return checkRes409 }410411 return s.spfResult(res.res, res.err)412}413414func (s *state) Close() error {415 return nil416}417418func init() {419 module.Register(modName, New)420}