diff --git a/README.md b/README.md index d09d1ef..1f5bc68 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,8 @@ If you want to change them - check available options in the help message (`!pm h --- * **!pm spamcheck:mx** - only accept email from servers which seem prepared to receive it (those having valid MX records) (`true` - enable, `false` - disable) +* **!pm spamcheck:spf** - only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable) +* **!pm spamcheck:dkim** - only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable) * **!pm spamcheck:smtp** - only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable) * **!pm spamlist** - Get or set `spamlist` of the room (comma-separated list), eg: `spammer@example.com,*@spammer.org,noreply@*` diff --git a/bot/command.go b/bot/command.go index 766c859..b010c43 100644 --- a/bot/command.go +++ b/bot/command.go @@ -173,6 +173,18 @@ func (b *Bot) initCommands() commandList { sanitizer: utils.SanitizeBoolString, allowed: b.allowOwner, }, + { + key: roomOptionSpamcheckSPF, + description: "only accept email from senders which authorized to send it (those matching SPF records) (`true` - enable, `false` - disable)", + sanitizer: utils.SanitizeBoolString, + allowed: b.allowOwner, + }, + { + key: roomOptionSpamcheckDKIM, + description: "only accept correctly authorized emails (without DKIM signature at all or with valid DKIM signature) (`true` - enable, `false` - disable)", + sanitizer: utils.SanitizeBoolString, + allowed: b.allowOwner, + }, { key: roomOptionSpamcheckSMTP, description: "only accept email from servers which seem prepared to receive it (those listening on an SMTP port) (`true` - enable, `false` - disable)", diff --git a/bot/settings_room.go b/bot/settings_room.go index ffdae3d..2b451a9 100644 --- a/bot/settings_room.go +++ b/bot/settings_room.go @@ -27,7 +27,9 @@ const ( roomOptionNoThreads = "nothreads" roomOptionNoFiles = "nofiles" roomOptionPassword = "password" + roomOptionSpamcheckDKIM = "spamcheck:dkim" roomOptionSpamcheckSMTP = "spamcheck:smtp" + roomOptionSpamcheckSPF = "spamcheck:spf" roomOptionSpamcheckMX = "spamcheck:mx" roomOptionSpamlist = "spamlist" ) @@ -96,10 +98,18 @@ func (s roomSettings) NoFiles() bool { return utils.Bool(s.Get(roomOptionNoFiles)) } +func (s roomSettings) SpamcheckDKIM() bool { + return utils.Bool(s.Get(roomOptionSpamcheckDKIM)) +} + func (s roomSettings) SpamcheckSMTP() bool { return utils.Bool(s.Get(roomOptionSpamcheckSMTP)) } +func (s roomSettings) SpamcheckSPF() bool { + return utils.Bool(s.Get(roomOptionSpamcheckSPF)) +} + func (s roomSettings) SpamcheckMX() bool { return utils.Bool(s.Get(roomOptionSpamcheckMX)) } diff --git a/docs/tricks.md b/docs/tricks.md new file mode 100644 index 0000000..9ebf8c1 --- /dev/null +++ b/docs/tricks.md @@ -0,0 +1,42 @@ +# tricks + + + +* [Logs](#logs) + * [get most active hosts](#get-most-active-hosts) + + + +## Logs + +### get most active hosts + +Even if you use postmoogle as an internal mail server and contact "outside internet" quite rarely, +you will see lots of connections to your SMTP servers from random hosts over internet that do... nothing? +They don't send any valid emails or do something meaningful, thus you can safely assume they are spammers. + +To get top X (in example: top 10) hosts with biggest count of attempts to connect to your postmoogle instance, follow the steps: + +1. enable debug log: `export POSTMOOGLE_LOGLEVEL=debug` +2. restart postmoogle and wait some time to get stats +3. run the following bash one-liner to show top 10 hosts by connections count: + +```bash +journalctl -o cat -u postmoogle | grep "smtp.DEBUG accepted connection from " | grep -oE "[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}" | sort | uniq -ci | sort -rn | head -n 10 + 253 111.111.111.111 + 183 222.222.222.222 + 39 333.333.333.333 + 38 444.444.444.444 + 18 555.555.555.555 + 16 666.666.666.666 + 8 777.777.777.777 + 5 888.888.888.888 + 5 999.999.999.999 + 4 010.010.010.010 +``` + +of course, IP addresses above are crafted just to visualize their place in that top, according to the number of connections done. +In reality, you will see real IP addresses here. Usually, only hosts with hundreds or thousands of connections for the last 7 days worth checking. + +What's next? +Do **not** ban them right away. Check WHOIS info for each host and only after that decide if you really want to ban that host or not. diff --git a/email/options.go b/email/options.go index 522ece8..c9fe79f 100644 --- a/email/options.go +++ b/email/options.go @@ -2,7 +2,9 @@ package email // IncomingFilteringOptions for incoming mail type IncomingFilteringOptions interface { + SpamcheckDKIM() bool SpamcheckSMTP() bool + SpamcheckSPF() bool SpamcheckMX() bool Spamlist() []string } diff --git a/go.mod b/go.mod index 7d309dc..e931856 100644 --- a/go.mod +++ b/go.mod @@ -20,12 +20,13 @@ require ( gitlab.com/etke.cc/go/mxidwc v1.0.0 gitlab.com/etke.cc/go/secgen v1.1.1 gitlab.com/etke.cc/go/trysmtp v1.0.0 - gitlab.com/etke.cc/go/validator v1.0.4 + gitlab.com/etke.cc/go/validator v1.0.5 gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6 maunium.net/go/mautrix v0.12.3 ) require ( + blitiri.com.ar/go/spf v1.5.1 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/gogs/chardet v0.0.0-20191104214054-4b6791f73a28 // indirect github.com/google/go-cmp v0.5.8 // indirect diff --git a/go.sum b/go.sum index 3270104..5fd9f02 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE= +blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= @@ -99,8 +101,8 @@ gitlab.com/etke.cc/go/secgen v1.1.1 h1:RmKOki725HIhWJHzPtAc9X4YvBneczndchpMgoDkE gitlab.com/etke.cc/go/secgen v1.1.1/go.mod h1:3pJqRGeWApzx7qXjABqz2o2SMCNpKSZao/gXVdasqE8= gitlab.com/etke.cc/go/trysmtp v1.0.0 h1:f/7gSmzohKniVeLSLevI+ZsySYcPUGkT9cRlOTwjOr8= gitlab.com/etke.cc/go/trysmtp v1.0.0/go.mod h1:KqRuIB2IPElEEbAxXmFyKtm7S5YiuEb4lxwWthccqyE= -gitlab.com/etke.cc/go/validator v1.0.4 h1:2HIBP12f/RZr/7KTNH5/PgPTzl1vi7Co3lmhNTWB31A= -gitlab.com/etke.cc/go/validator v1.0.4/go.mod h1:3vdssRG4LwgdTr9IHz9MjGSEO+3/FO9hXPGMuSeweJ8= +gitlab.com/etke.cc/go/validator v1.0.5 h1:YJs50yOHY3tKp/2NVP5U6Pbtvxof/YtGRpNOVjkGf+s= +gitlab.com/etke.cc/go/validator v1.0.5/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts= gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6 h1:+HDT2/bx3Hug++aeDE/PaoRRcnKdYzEm6i2RlOAzPXo= gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6/go.mod h1:Dgtu0qvymNjjky4Bu5WC8+iSohcb5xZ9CtkD3ezDqIA= golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= diff --git a/smtp/session.go b/smtp/session.go index ca44a85..0e4b799 100644 --- a/smtp/session.go +++ b/smtp/session.go @@ -1,11 +1,13 @@ package smtp import ( + "bytes" "context" "errors" "io" "net" + "github.com/emersion/go-msgauth/dkim" "github.com/emersion/go-smtp" "github.com/getsentry/sentry-go" "github.com/jhillyerd/enmime" @@ -26,6 +28,7 @@ type incomingSession struct { greylisted func(net.Addr) bool ban func(net.Addr) domains []string + enforceDKIM bool ctx context.Context addr net.Addr @@ -68,7 +71,8 @@ func (s *incomingSession) Rcpt(to string) error { } validations := s.getFilters(roomID) - if !validateEmail(s.from, to, s.log, validations) { + s.enforceDKIM = validations.SpamcheckDKIM() + if !validateIncoming(s.from, to, s.addr, s.log, validations) { s.ban(s.addr) return ErrBanned } @@ -85,8 +89,28 @@ func (s *incomingSession) Data(r io.Reader) error { Message: "You have been greylisted, try again a bit later.", } } + data, err := io.ReadAll(r) + if err != nil { + s.log.Error("cannot read DATA: %v", err) + return err + } + reader := bytes.NewReader(data) + if s.enforceDKIM { + results, verr := dkim.Verify(reader) + if verr != nil { + s.log.Error("cannot verify DKIM: %v", verr) + return verr + } + for _, result := range results { + if result.Err != nil { + s.log.Info("DKIM verification of %q failed: %v", result.Domain, result.Err) + return result.Err + } + } + reader.Seek(0, io.SeekStart) //nolint:errcheck + } parser := enmime.NewParser() - envelope, err := parser.ReadEnvelope(r) + envelope, err := parser.ReadEnvelope(reader) if err != nil { return err } @@ -176,13 +200,23 @@ func (s *outgoingSession) Data(r io.Reader) error { func (s *outgoingSession) Reset() {} func (s *outgoingSession) Logout() error { return nil } -func validateEmail(from, to string, log *logger.Logger, options email.IncomingFilteringOptions) bool { +func validateIncoming(from, to string, senderAddr net.Addr, log *logger.Logger, options email.IncomingFilteringOptions) bool { + var sender net.IP + switch netaddr := senderAddr.(type) { + case *net.TCPAddr: + sender = netaddr.IP + default: + host, _, _ := net.SplitHostPort(senderAddr.String()) // nolint:errcheck + sender = net.ParseIP(host) + } + enforce := validator.Enforce{ Email: true, MX: options.SpamcheckMX(), + SPF: options.SpamcheckSPF(), SMTP: options.SpamcheckSMTP(), } v := validator.New(options.Spamlist(), enforce, to, log) - return v.Email(from) + return v.Email(from, sender) } diff --git a/vendor/blitiri.com.ar/go/spf/.gitignore b/vendor/blitiri.com.ar/go/spf/.gitignore new file mode 100644 index 0000000..7cf7ba3 --- /dev/null +++ b/vendor/blitiri.com.ar/go/spf/.gitignore @@ -0,0 +1,10 @@ +# Ignore anything beginning with a dot: these are usually temporary or +# unimportant. +.* + +# Exceptions to the rule above: files we care about that would otherwise be +# excluded. +!.gitignore + +# go-fuzz build artifacts. +*-fuzz.zip diff --git a/vendor/blitiri.com.ar/go/spf/LICENSE b/vendor/blitiri.com.ar/go/spf/LICENSE new file mode 100644 index 0000000..da8b30f --- /dev/null +++ b/vendor/blitiri.com.ar/go/spf/LICENSE @@ -0,0 +1,27 @@ + +Licensed under the MIT licence, which is reproduced below (from +https://opensource.org/licenses/MIT). + +----- + +Copyright (c) 2016 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/vendor/blitiri.com.ar/go/spf/README.md b/vendor/blitiri.com.ar/go/spf/README.md new file mode 100644 index 0000000..19a14b7 --- /dev/null +++ b/vendor/blitiri.com.ar/go/spf/README.md @@ -0,0 +1,49 @@ + +# blitiri.com.ar/go/spf + +[![GoDoc](https://godoc.org/blitiri.com.ar/go/spf?status.svg)](https://pkg.go.dev/blitiri.com.ar/go/spf) +[![Build Status](https://gitlab.com/albertito/spf/badges/master/pipeline.svg)](https://gitlab.com/albertito/spf/-/pipelines) +[![Go Report Card](https://goreportcard.com/badge/github.com/albertito/spf)](https://goreportcard.com/report/github.com/albertito/spf) +[![Coverage Status](https://coveralls.io/repos/github/albertito/spf/badge.svg?branch=next)](https://coveralls.io/github/albertito/spf) + +[spf](https://godoc.org/blitiri.com.ar/go/spf) is an open source +implementation of the [Sender Policy Framework +(SPF)](https://en.wikipedia.org/wiki/Sender_Policy_Framework) in Go. + +It is used by the [chasquid](https://blitiri.com.ar/p/chasquid/) and +[maddy](https://maddy.email) SMTP servers. + + +## Example + +```go +// Check if `sender` is authorized to send from the given `ip`. The `domain` +// is used if the sender doesn't have one. +result, err := spf.CheckHostWithSender(ip, domain, sender) +if result == spf.Fail { + // Not authorized to send. +} +``` + +See the [package documentation](https://pkg.go.dev/blitiri.com.ar/go/spf) for +more details. + + +## Status + +All SPF mechanisms, modifiers, and macros are supported. + +The API should be considered stable. Major version changes will be announced +to the mailing list (details below). + + +## Contact + +If you have any questions, comments or patches please send them to the mailing +list, `chasquid@googlegroups.com`. + +To subscribe, send an email to `chasquid+subscribe@googlegroups.com`. + +You can also browse the +[archives](https://groups.google.com/forum/#!forum/chasquid). + diff --git a/vendor/blitiri.com.ar/go/spf/fuzz.go b/vendor/blitiri.com.ar/go/spf/fuzz.go new file mode 100644 index 0000000..23c343c --- /dev/null +++ b/vendor/blitiri.com.ar/go/spf/fuzz.go @@ -0,0 +1,58 @@ +// Fuzz testing for package spf. +// +// Run it with: +// +// go-fuzz-build blitiri.com.ar/go/spf +// go-fuzz -bin=./spf-fuzz.zip -workdir=testdata/fuzz +// + +//go:build gofuzz +// +build gofuzz + +package spf + +import ( + "net" + + "blitiri.com.ar/go/spf/internal/dnstest" +) + +// Parsed IP addresses, for convenience. +var ( + ip1110 = net.ParseIP("1.1.1.0") + ip1111 = net.ParseIP("1.1.1.1") + ip6666 = net.ParseIP("2001:db8::68") + ip6660 = net.ParseIP("2001:db8::0") +) + +// DNS resolver to use. Will be initialized once with the expected fixtures, +// and then reused on each fuzz run. +var dns = dnstest.NewResolver() + +func init() { + dns.Ip["d1111"] = []net.IP{ip1111} + dns.Ip["d1110"] = []net.IP{ip1110} + dns.Mx["d1110"] = []*net.MX{{"d1110", 5}, {"nothing", 10}} + dns.Ip["d6666"] = []net.IP{ip6666} + dns.Ip["d6660"] = []net.IP{ip6660} + dns.Mx["d6660"] = []*net.MX{{"d6660", 5}, {"nothing", 10}} + dns.Addr["2001:db8::68"] = []string{"sonlas6.", "domain.", "d6666."} + dns.Addr["1.1.1.1"] = []string{"lalala.", "domain.", "d1111."} +} + +func Fuzz(data []byte) int { + // The domain's TXT record comes from the fuzzer. + dns.Txt["domain"] = []string{string(data)} + + v4result, _ := CheckHostWithSender( + ip1111, "helo", "domain", WithResolver(dns)) + v6result, _ := CheckHostWithSender( + ip6666, "helo", "domain", WithResolver(dns)) + + // Raise priority if any of the results was something other than + // PermError, as it means the data was better formed. + if v4result != PermError || v6result != PermError { + return 1 + } + return 0 +} diff --git a/vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go b/vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go new file mode 100644 index 0000000..2ec1551 --- /dev/null +++ b/vendor/blitiri.com.ar/go/spf/internal/dnstest/dns.go @@ -0,0 +1,111 @@ +// DNS resolver for testing purposes. +// +// In the future, when go fuzz can make use of _test.go files, we can rename +// this file dns_test.go and remove this extra package entirely. +// Until then, unfortunately this is the most reasonable way to share these +// helpers between go and fuzz tests. +package dnstest + +import ( + "context" + "net" + "strings" +) + +// Testing DNS resolver. +// +// Not exported since this is not part of the public API and only used +// internally on tests. +// +type TestResolver struct { + Txt map[string][]string + Mx map[string][]*net.MX + Ip map[string][]net.IP + Addr map[string][]string + Cname map[string]string + Errors map[string]error +} + +func NewResolver() *TestResolver { + return &TestResolver{ + Txt: map[string][]string{}, + Mx: map[string][]*net.MX{}, + Ip: map[string][]net.IP{}, + Addr: map[string][]string{}, + Cname: map[string]string{}, + Errors: map[string]error{}, + } +} + +var nxDomainErr = &net.DNSError{ + Err: "domain not found (for testing)", + IsNotFound: true, +} + +func (r *TestResolver) LookupTXT(ctx context.Context, domain string) (txts []string, err error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + domain = strings.ToLower(domain) + domain = strings.TrimRight(domain, ".") + if cname, ok := r.Cname[domain]; ok { + return r.LookupTXT(ctx, cname) + } + if _, ok := r.Txt[domain]; !ok && r.Errors[domain] == nil { + return nil, nxDomainErr + } + return r.Txt[domain], r.Errors[domain] +} + +func (r *TestResolver) LookupMX(ctx context.Context, domain string) (mxs []*net.MX, err error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + domain = strings.ToLower(domain) + domain = strings.TrimRight(domain, ".") + if cname, ok := r.Cname[domain]; ok { + return r.LookupMX(ctx, cname) + } + if _, ok := r.Mx[domain]; !ok && r.Errors[domain] == nil { + return nil, nxDomainErr + } + return r.Mx[domain], r.Errors[domain] +} + +func (r *TestResolver) LookupIPAddr(ctx context.Context, host string) (as []net.IPAddr, err error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + host = strings.ToLower(host) + host = strings.TrimRight(host, ".") + if cname, ok := r.Cname[host]; ok { + return r.LookupIPAddr(ctx, cname) + } + if _, ok := r.Ip[host]; !ok && r.Errors[host] == nil { + return nil, nxDomainErr + } + return ipsToAddrs(r.Ip[host]), r.Errors[host] +} + +func ipsToAddrs(ips []net.IP) []net.IPAddr { + as := []net.IPAddr{} + for _, ip := range ips { + as = append(as, net.IPAddr{IP: ip, Zone: ""}) + } + return as +} + +func (r *TestResolver) LookupAddr(ctx context.Context, host string) (addrs []string, err error) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + host = strings.ToLower(host) + host = strings.TrimRight(host, ".") + if cname, ok := r.Cname[host]; ok { + return r.LookupAddr(ctx, cname) + } + if _, ok := r.Addr[host]; !ok && r.Errors[host] == nil { + return nil, nxDomainErr + } + return r.Addr[host], r.Errors[host] +} diff --git a/vendor/blitiri.com.ar/go/spf/spf.go b/vendor/blitiri.com.ar/go/spf/spf.go new file mode 100644 index 0000000..5d5784c --- /dev/null +++ b/vendor/blitiri.com.ar/go/spf/spf.go @@ -0,0 +1,1044 @@ +// Package spf implements SPF (Sender Policy Framework) lookup and validation. +// +// Sender Policy Framework (SPF) is a simple email-validation system designed +// to detect email spoofing by providing a mechanism to allow receiving mail +// exchangers to check that incoming mail from a domain comes from a host +// authorized by that domain's administrators [Wikipedia]. +// +// This package is intended to be used by SMTP servers to implement SPF +// validation. +// +// All mechanisms and modifiers are supported: +// all +// include +// a +// mx +// ptr +// ip4 +// ip6 +// exists +// redirect +// exp (ignored) +// Macros +// +// References: +// https://tools.ietf.org/html/rfc7208 +// https://en.wikipedia.org/wiki/Sender_Policy_Framework +package spf // import "blitiri.com.ar/go/spf" + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strconv" + "strings" +) + +// The Result of an SPF check. Note the values have meaning, we use them in +// headers. https://tools.ietf.org/html/rfc7208#section-8 +type Result string + +// Valid results. +var ( + // https://tools.ietf.org/html/rfc7208#section-8.1 + // Not able to reach any conclusion. + None = Result("none") + + // https://tools.ietf.org/html/rfc7208#section-8.2 + // No definite assertion (positive or negative). + Neutral = Result("neutral") + + // https://tools.ietf.org/html/rfc7208#section-8.3 + // Client is authorized to inject mail. + Pass = Result("pass") + + // https://tools.ietf.org/html/rfc7208#section-8.4 + // Client is *not* authorized to use the domain. + Fail = Result("fail") + + // https://tools.ietf.org/html/rfc7208#section-8.5 + // Not authorized, but unwilling to make a strong policy statement. + SoftFail = Result("softfail") + + // https://tools.ietf.org/html/rfc7208#section-8.6 + // Transient error while performing the check. + TempError = Result("temperror") + + // https://tools.ietf.org/html/rfc7208#section-8.7 + // Records could not be correctly interpreted. + PermError = Result("permerror") +) + +var qualToResult = map[byte]Result{ + '+': Pass, + '-': Fail, + '~': SoftFail, + '?': Neutral, +} + +// Errors returned by the library. Note that the errors returned in different +// situations may change over time, and new ones may be added. Be careful +// about over-relying on these. +var ( + // Errors related to an invalid SPF record. + ErrUnknownField = errors.New("unknown field") + ErrInvalidIP = errors.New("invalid ipX value") + ErrInvalidMask = errors.New("invalid mask") + ErrInvalidMacro = errors.New("invalid macro") + ErrInvalidDomain = errors.New("invalid domain") + + // Errors related to DNS lookups. + // Note that the library functions may also return net.DNSError. + ErrNoResult = errors.New("no DNS record found") + ErrLookupLimitReached = errors.New("lookup limit reached") + ErrVoidLookupLimitReached = errors.New("void lookup limit reached") + ErrTooManyMXRecords = errors.New("too many MX records") + ErrMultipleRecords = errors.New("multiple matching DNS records") + + // Errors returned on a successful match. + ErrMatchedAll = errors.New("matched all") + ErrMatchedA = errors.New("matched a") + ErrMatchedIP = errors.New("matched ip") + ErrMatchedMX = errors.New("matched mx") + ErrMatchedPTR = errors.New("matched ptr") + ErrMatchedExists = errors.New("matched exists") +) + +const ( + // Default value for the maximum number of DNS lookups while resolving SPF. + // RFC is quite clear 10 must be the maximum allowed. + // https://tools.ietf.org/html/rfc7208#section-4.6.4 + defaultMaxLookups = 10 + + // Default value for the maximum number of DNS void lookups while + // resolving SPF. RFC suggests that implementations SHOULD limit these + // with a configurable default of 2. + // https://tools.ietf.org/html/rfc7208#section-4.6.4 + defaultMaxVoidLookups = 2 +) + +// TraceFunc is the type of tracing functions. +type TraceFunc func(f string, a ...interface{}) + +var ( + nullTrace = func(f string, a ...interface{}) {} + defaultTrace = nullTrace +) + +// Option type, for setting options. Users are expected to treat this as an +// opaque type and not rely on the implementation, which is subject to change. +type Option func(*resolution) + +// CheckHost fetches SPF records for `domain`, parses them, and evaluates them +// to determine if `ip` is permitted to send mail for it. +// Because it doesn't receive enough information to handle macros well, its +// usage is not recommended, but remains supported for backwards +// compatibility. +// +// The function returns a Result, which corresponds with the SPF result for +// the check as per RFC, as well as an error for debugging purposes. Note that +// the error may be non-nil even on successful checks. +// +// Reference: https://tools.ietf.org/html/rfc7208#section-4 +// +// Deprecated: use CheckHostWithSender instead. +func CheckHost(ip net.IP, domain string) (Result, error) { + r := &resolution{ + ip: ip, + maxcount: defaultMaxLookups, + maxvoidcount: defaultMaxVoidLookups, + helo: domain, + sender: "@" + domain, + ctx: context.TODO(), + resolver: defaultResolver, + trace: defaultTrace, + } + return r.Check(domain) +} + +// CheckHostWithSender fetches SPF records for `sender`'s domain, parses them, +// and evaluates them to determine if `ip` is permitted to send mail for it. +// The `helo` domain is used if the sender has no domain part. +// +// The `opts` optional parameter can be used to adjust some specific +// behaviours, such as the maximum number of DNS lookups allowed. +// +// The function returns a Result, which corresponds with the SPF result for +// the check as per RFC, as well as an error for debugging purposes. Note that +// the error may be non-nil even on successful checks. +// +// Reference: https://tools.ietf.org/html/rfc7208#section-4 +func CheckHostWithSender(ip net.IP, helo, sender string, opts ...Option) (Result, error) { + _, domain := split(sender) + if domain == "" { + domain = helo + } + + r := &resolution{ + ip: ip, + maxcount: defaultMaxLookups, + maxvoidcount: defaultMaxVoidLookups, + helo: helo, + sender: sender, + ctx: context.TODO(), + resolver: defaultResolver, + trace: defaultTrace, + } + + for _, opt := range opts { + opt(r) + } + + return r.Check(domain) +} + +// OverrideLookupLimit overrides the maximum number of DNS lookups allowed +// during SPF evaluation. Note that using this violates the RFC, which is +// quite explicit that the maximum allowed MUST be 10 (the default). Please +// use with care. +// +// This is EXPERIMENTAL for now, and the API is subject to change. +func OverrideLookupLimit(limit uint) Option { + return func(r *resolution) { + r.maxcount = limit + } +} + +// OverrideVoidLookupLimit overrides the maximum number of void DNS lookups allowed +// during SPF evaluation. A void DNS lookup is one that returns an empty +// answer, or a NXDOMAIN. Note that as per RFC, the default value of 2 SHOULD +// be used. Please use with care. +// +// This is EXPERIMENTAL for now, and the API is subject to change. +func OverrideVoidLookupLimit(limit uint) Option { + return func(r *resolution) { + r.maxvoidcount = limit + } +} + +// WithContext is an option to set the context for this operation, which will +// be passed along to the resolver functions and other external calls if +// needed. +// +// This is EXPERIMENTAL for now, and the API is subject to change. +func WithContext(ctx context.Context) Option { + return func(r *resolution) { + r.ctx = ctx + } +} + +// DNSResolver implements the methods we use to resolve DNS queries. +// It is intentionally compatible with *net.Resolver. +type DNSResolver interface { + LookupTXT(ctx context.Context, name string) ([]string, error) + LookupMX(ctx context.Context, name string) ([]*net.MX, error) + LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) + LookupAddr(ctx context.Context, addr string) (names []string, err error) +} + +var defaultResolver DNSResolver = net.DefaultResolver + +// WithResolver sets the resolver to use for DNS lookups. It can be useful for +// testing, and for customize DNS resolution specifically for this library. +// +// The default is to use net.DefaultResolver, which should be appropriate for +// most users. +// +// This is EXPERIMENTAL for now, and the API is subject to change. +func WithResolver(resolver DNSResolver) Option { + return func(r *resolution) { + r.resolver = resolver + } +} + +// WithTraceFunc sets the resolver's trace function. +// +// This can be used for debugging. The trace messages are NOT machine +// parseable, and are NOT stable. They should also NOT be included in +// user-visible output, as they may include sensitive details. +// +// This is EXPERIMENTAL for now, and the API is subject to change. +func WithTraceFunc(trace TraceFunc) Option { + return func(r *resolution) { + r.trace = trace + } +} + +// split an user@domain address into user and domain. +func split(addr string) (string, string) { + ps := strings.SplitN(addr, "@", 2) + if len(ps) != 2 { + return addr, "" + } + + return ps[0], ps[1] +} + +type resolution struct { + ip net.IP + count uint + maxcount uint + voidcount uint + maxvoidcount uint + + helo string + sender string + + // Result of doing a reverse lookup for ip (so we only do it once). + ipNames []string + + // Context for this resolution. + ctx context.Context + + // DNS resolver to use. + resolver DNSResolver + + // Trace function, used for debugging. + trace TraceFunc +} + +var aField = regexp.MustCompile(`^(a$|a:|a/)`) +var mxField = regexp.MustCompile(`^(mx$|mx:|mx/)`) +var ptrField = regexp.MustCompile(`^(ptr$|ptr:)`) + +func (r *resolution) Check(domain string) (Result, error) { + r.trace("check %q %d %d", domain, r.count, r.voidcount) + txt, err := r.getDNSRecord(domain) + if err != nil { + if isNotFound(err) { + // NXDOMAIN -> None. + // https://datatracker.ietf.org/doc/html/rfc7208#section-4.3 + r.trace("dns domain not found: %v", err) + return None, ErrNoResult + } + if isTemporary(err) { + r.trace("dns temp error: %v", err) + return TempError, err + } + if err == ErrMultipleRecords { + r.trace("multiple dns records") + return PermError, err + } + // Got another, permanent error. + // https://datatracker.ietf.org/doc/html/rfc7208#section-2.6.7 + r.trace("dns perm error: %v", err) + return PermError, err + } + r.trace("dns record %q", txt) + + if txt == "" { + // No record => None. + // https://tools.ietf.org/html/rfc7208#section-4.5 + return None, ErrNoResult + } + + fields := strings.Split(txt, " ") + + // Redirects must be handled after the rest; instead of having two loops, + // we just move them to the end. + var newfields, redirects []string + for _, field := range fields { + if strings.HasPrefix(field, "redirect=") { + redirects = append(redirects, field) + } else { + newfields = append(newfields, field) + } + } + if len(redirects) > 1 { + // At most a single redirect is allowed. + // https://tools.ietf.org/html/rfc7208#section-6 + r.trace("too many redirects") + return PermError, ErrInvalidDomain + } + fields = append(newfields, redirects...) + + for _, field := range fields { + if field == "" { + continue + } + + // The version check should be case-insensitive (it's a + // case-insensitive constant in the standard). + // https://tools.ietf.org/html/rfc7208#section-12 + if strings.HasPrefix(field, "v=") || strings.HasPrefix(field, "V=") { + continue + } + + // Limit the number of resolutions. + // https://tools.ietf.org/html/rfc7208#section-4.6.4 + if r.count > r.maxcount { + r.trace("lookup limit reached") + return PermError, ErrLookupLimitReached + } + + if r.voidcount > r.maxvoidcount { + r.trace("void lookup limit reached") + return PermError, ErrVoidLookupLimitReached + } + + // See if we have a qualifier, defaulting to + (pass). + // https://tools.ietf.org/html/rfc7208#section-4.6.2 + result, ok := qualToResult[field[0]] + if ok { + field = field[1:] + } else { + result = Pass + } + + // Mechanism and modifier names are case-insensitive. + // https://tools.ietf.org/html/rfc7208#section-4.6.1 + lfield := strings.ToLower(field) + + if lfield == "all" { + // https://tools.ietf.org/html/rfc7208#section-5.1 + r.trace("all: %v", result) + return result, ErrMatchedAll + } else if strings.HasPrefix(lfield, "include:") { + if ok, res, err := r.includeField(result, field, domain); ok { + r.trace("%q %v, %v", field, res, err) + return res, err + } + } else if aField.MatchString(lfield) { + if ok, res, err := r.aField(result, field, domain); ok { + r.trace("%q %v, %v", field, res, err) + return res, err + } + } else if mxField.MatchString(lfield) { + if ok, res, err := r.mxField(result, field, domain); ok { + r.trace("%q %v, %v", field, res, err) + return res, err + } + } else if strings.HasPrefix(lfield, "ip4:") || strings.HasPrefix(lfield, "ip6:") { + if ok, res, err := r.ipField(result, field); ok { + r.trace("%q %v, %v", field, res, err) + return res, err + } + } else if ptrField.MatchString(lfield) { + if ok, res, err := r.ptrField(result, field, domain); ok { + r.trace("%q %v, %v", field, res, err) + return res, err + } + } else if strings.HasPrefix(lfield, "exists:") { + if ok, res, err := r.existsField(result, field, domain); ok { + r.trace("%q %v, %v", field, res, err) + return res, err + } + } else if strings.HasPrefix(lfield, "exp=") { + r.trace("exp= ignored") + continue + } else if strings.HasPrefix(lfield, "redirect=") { + res, err := r.redirectField(field, domain) + r.trace("%q: %v, %v", field, res, err) + return res, err + } else { + r.trace("unknown field, permerror") + return PermError, ErrUnknownField + } + } + + // Got to the end of the evaluation without a result => Neutral. + // https://tools.ietf.org/html/rfc7208#section-4.7 + r.trace("fallback to neutral") + return Neutral, nil +} + +// getDNSRecord gets TXT records from the given domain, and returns the SPF +// (if any). Note that at most one SPF is allowed per a given domain: +// https://tools.ietf.org/html/rfc7208#section-3 +// https://tools.ietf.org/html/rfc7208#section-3.2 +// https://tools.ietf.org/html/rfc7208#section-4.5 +func (r *resolution) getDNSRecord(domain string) (string, error) { + txts, err := r.resolver.LookupTXT(r.ctx, domain) + if err != nil { + return "", err + } + + records := []string{} + for _, txt := range txts { + // The version check should be case-insensitive (it's a + // case-insensitive constant in the standard). + // https://tools.ietf.org/html/rfc7208#section-12 + if strings.HasPrefix(strings.ToLower(txt), "v=spf1 ") { + records = append(records, txt) + } + + // An empty record is explicitly allowed: + // https://tools.ietf.org/html/rfc7208#section-4.5 + if strings.ToLower(txt) == "v=spf1" { + records = append(records, txt) + } + } + + // 0 records is ok, handled by the parent. + // 1 record is what we expect, return the record. + // More than that, it's a permanent error: + // https://tools.ietf.org/html/rfc7208#section-4.5 + l := len(records) + if l == 0 { + return "", nil + } else if l == 1 { + return records[0], nil + } + return "", ErrMultipleRecords +} + +func isTemporary(err error) bool { + derr, ok := err.(*net.DNSError) + return ok && derr.Temporary() +} + +func isNotFound(err error) bool { + derr, ok := err.(*net.DNSError) + return ok && derr.IsNotFound +} + +// Check if the given DNS error is a "void lookup" (0 answers, or nxdomain), +// and if so increment the void lookup counter. +func (r *resolution) checkVoidLookup(nanswers int, err error) { + if err == nil && nanswers == 0 { + r.voidcount++ + r.trace("void lookup: no answers") + return + } + + derr, ok := err.(*net.DNSError) + if !ok { + return + } + + if derr.IsNotFound { + r.voidcount++ + r.trace("void lookup: nxdomain") + } +} + +// ipField processes an "ip" field. +func (r *resolution) ipField(res Result, field string) (bool, Result, error) { + fip := field[4:] + if strings.Contains(fip, "/") { + _, ipnet, err := net.ParseCIDR(fip) + if err != nil { + return true, PermError, ErrInvalidMask + } + if ipnet.Contains(r.ip) { + r.trace("ip match: %v contains %v", ipnet, r.ip) + return true, res, ErrMatchedIP + } + } else { + ip := net.ParseIP(fip) + if ip == nil { + return true, PermError, ErrInvalidIP + } + if ip.Equal(r.ip) { + r.trace("ip match: %v", ip) + return true, res, ErrMatchedIP + } + } + + return false, "", nil +} + +// ptrField processes a "ptr" field. +func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, error) { + // Extract the domain if the field is in the form "ptr:domain". + ptrDomain := domain + if len(field) >= 4 { + ptrDomain = field[4:] + + } + ptrDomain, err := r.expandMacros(ptrDomain, domain) + if err != nil { + return true, PermError, ErrInvalidMacro + } + + if ptrDomain == "" { + return true, PermError, ErrInvalidDomain + } + + if r.ipNames == nil { + r.ipNames = []string{} + r.count++ + ns, err := r.resolver.LookupAddr(r.ctx, r.ip.String()) + r.checkVoidLookup(len(ns), err) + if err != nil { + // https://tools.ietf.org/html/rfc7208#section-5 + if isNotFound(err) { + return false, "", err + } + return true, TempError, err + } + + // Only take the first 10 names, ignore the rest. + // Each A/AAAA lookup in this context is NOT included in the overall + // count. The RFC defines this separate logic and limits. + // https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4 + if len(ns) > 10 { + r.trace("ptr names trimmed %d down to 10", len(ns)) + ns = ns[:10] + } + + for _, n := range ns { + // Validate the record by doing a forward resolution: it has to + // have some A/AAAA. + addrs, err := r.resolver.LookupIPAddr(r.ctx, n) + if err != nil { + // RFC explicitly says to skip domains which error here. + continue + } + r.trace("ptr forward resolution %q -> %q", n, addrs) + if len(addrs) > 0 { + // Append the lower-case variants so we do a case-insensitive + // lookup below. + r.ipNames = append(r.ipNames, strings.ToLower(n)) + } + } + } + + r.trace("ptr evaluating %q in %q", ptrDomain, r.ipNames) + ptrDomain = strings.ToLower(ptrDomain) + for _, n := range r.ipNames { + if strings.HasSuffix(n, ptrDomain+".") { + r.trace("ptr match: %q", n) + return true, res, ErrMatchedPTR + } + } + + return false, "", nil +} + +// existsField processes a "exists" field. +// https://tools.ietf.org/html/rfc7208#section-5.7 +func (r *resolution) existsField(res Result, field, domain string) (bool, Result, error) { + // The field is in the form "exists:". + eDomain := field[7:] + eDomain, err := r.expandMacros(eDomain, domain) + if err != nil { + return true, PermError, ErrInvalidMacro + } + + if eDomain == "" { + return true, PermError, ErrInvalidDomain + } + + r.count++ + ips, err := r.resolver.LookupIPAddr(r.ctx, eDomain) + r.checkVoidLookup(len(ips), err) + if err != nil { + // https://tools.ietf.org/html/rfc7208#section-5 + if isNotFound(err) { + return false, "", err + } + return true, TempError, err + } + + // Exists only counts if there are IPv4 matches. + for _, ip := range ips { + if ip.IP.To4() != nil { + r.trace("exists match: %v", ip.IP) + return true, res, ErrMatchedExists + } + } + return false, "", nil +} + +// includeField processes an "include" field. +func (r *resolution) includeField(res Result, field, domain string) (bool, Result, error) { + // https://tools.ietf.org/html/rfc7208#section-5.2 + incdomain := field[len("include:"):] + incdomain, err := r.expandMacros(incdomain, domain) + if err != nil { + return true, PermError, ErrInvalidMacro + } + r.count++ + ir, err := r.Check(incdomain) + switch ir { + case Pass: + return true, res, err + case Fail, SoftFail, Neutral: + return false, ir, err + case TempError: + return true, TempError, err + case PermError: + return true, PermError, err + case None: + return true, PermError, err + } + + return false, "", fmt.Errorf("this should never be reached") +} + +type dualMasks struct { + v4 net.IPMask + v6 net.IPMask +} + +func maskToStr(m net.IPMask) string { + ones, bits := m.Size() + if ones == 0 && bits == 0 { + return m.String() + } + return fmt.Sprintf("/%d", ones) +} + +func (m dualMasks) String() string { + return fmt.Sprintf("[%v, %v]", maskToStr(m.v4), maskToStr(m.v6)) +} + +func ipMatch(ip, tomatch net.IP, masks dualMasks) bool { + mask := net.IPMask(nil) + if tomatch.To4() != nil && masks.v4 != nil { + mask = masks.v4 + } else if tomatch.To4() == nil && masks.v6 != nil { + mask = masks.v6 + } + + if mask != nil { + ipnet := net.IPNet{IP: tomatch, Mask: mask} + return ipnet.Contains(ip) + } + + return ip.Equal(tomatch) +} + +var aRegexp = regexp.MustCompile(`^[aA](:([^/]+))?(/(\w+))?(//(\w+))?$`) +var mxRegexp = regexp.MustCompile(`^[mM][xX](:([^/]+))?(/(\w+))?(//(\w+))?$`) + +func domainAndMask(re *regexp.Regexp, field, domain string) (string, dualMasks, error) { + masks := dualMasks{} + groups := re.FindStringSubmatch(field) + if groups != nil { + if groups[2] != "" { + domain = groups[2] + } + if groups[4] != "" { + i, err := strconv.Atoi(groups[4]) + mask4 := net.CIDRMask(i, 32) + if err != nil || mask4 == nil { + return "", masks, ErrInvalidMask + } + masks.v4 = mask4 + } + if groups[6] != "" { + i, err := strconv.Atoi(groups[6]) + mask6 := net.CIDRMask(i, 128) + if err != nil || mask6 == nil { + return "", masks, ErrInvalidMask + } + masks.v6 = mask6 + } + } + + // Test to catch malformed entries: if there's a /, there must be at least + // one mask. + if strings.Contains(field, "/") && masks.v4 == nil && masks.v6 == nil { + return "", masks, ErrInvalidMask + } + + return domain, masks, nil +} + +// aField processes an "a" field. +func (r *resolution) aField(res Result, field, domain string) (bool, Result, error) { + // https://tools.ietf.org/html/rfc7208#section-5.3 + aDomain, masks, err := domainAndMask(aRegexp, field, domain) + r.trace("masks on %q, %q: %q %v", field, domain, aDomain, masks) + if err != nil { + return true, PermError, err + } + aDomain, err = r.expandMacros(aDomain, domain) + if err != nil { + return true, PermError, ErrInvalidMacro + } + + r.count++ + ips, err := r.resolver.LookupIPAddr(r.ctx, aDomain) + r.checkVoidLookup(len(ips), err) + if err != nil { + // https://tools.ietf.org/html/rfc7208#section-5 + if isNotFound(err) { + return false, "", err + } + return true, TempError, err + } + for _, ip := range ips { + if ipMatch(r.ip, ip.IP, masks) { + r.trace("a match: %v, %v, %v", r.ip, ip.IP, masks) + return true, res, ErrMatchedA + } + } + + return false, "", nil +} + +// mxField processes an "mx" field. +func (r *resolution) mxField(res Result, field, domain string) (bool, Result, error) { + // https://tools.ietf.org/html/rfc7208#section-5.4 + mxDomain, masks, err := domainAndMask(mxRegexp, field, domain) + r.trace("masks on %q, %q: %q %v", field, domain, mxDomain, masks) + if err != nil { + return true, PermError, err + } + mxDomain, err = r.expandMacros(mxDomain, domain) + if err != nil { + return true, PermError, ErrInvalidMacro + } + + r.count++ + mxs, err := r.resolver.LookupMX(r.ctx, mxDomain) + r.checkVoidLookup(len(mxs), err) + + // If we get some results, use them even if we get an error alongisde. + // This happens when one of the records is invalid, because Go library can + // be quite strict about it. The RFC is not clear about this specific + // situation, and other SPF libraries and implementations just skip the + // invalid value, so we match common practice. + if err != nil && len(mxs) == 0 { + // https://tools.ietf.org/html/rfc7208#section-5 + if isNotFound(err) { + return false, "", err + } + return true, TempError, err + } + + // There's an explicit maximum of 10 MX records per match. + // https://tools.ietf.org/html/rfc7208#section-4.6.4 + if len(mxs) > 10 { + return true, PermError, ErrTooManyMXRecords + } + + mxips := []net.IP{} + for _, mx := range mxs { + ips, err := r.resolver.LookupIPAddr(r.ctx, mx.Host) + if err != nil { + // If the address of the MX record was not found, we just skip it. + // https://tools.ietf.org/html/rfc7208#section-5 + if isNotFound(err) { + continue + } + return true, TempError, err + } + for _, ipaddr := range ips { + mxips = append(mxips, ipaddr.IP) + } + } + + r.trace("mx ips: %v", mxips) + for _, ip := range mxips { + if ipMatch(r.ip, ip, masks) { + r.trace("mx match: %v, %v, %v", r.ip, ip, masks) + return true, res, ErrMatchedMX + } + } + + return false, "", nil +} + +// redirectField processes a "redirect=" field. +func (r *resolution) redirectField(field, domain string) (Result, error) { + rDomain := field[len("redirect="):] + rDomain, err := r.expandMacros(rDomain, domain) + if err != nil { + return PermError, ErrInvalidMacro + } + + if rDomain == "" { + return PermError, ErrInvalidDomain + } + + // https://tools.ietf.org/html/rfc7208#section-6.1 + r.count++ + result, err := r.Check(rDomain) + if result == None { + result = PermError + } + return result, err +} + +// Group extraction of macro-string from the formal specification. +// https://tools.ietf.org/html/rfc7208#section-7.1 +var macroRegexp = regexp.MustCompile( + `([slodiphcrtvSLODIPHCRTV])([0-9]+)?([rR])?([-.+,/_=]+)?`) + +// Expand macros, return the expanded string. +// This expects to be passed the domain-spec within a field, not an entire +// field or larger (that has problematic security implications). +// https://tools.ietf.org/html/rfc7208#section-7 +func (r *resolution) expandMacros(s, domain string) (string, error) { + // Macros/domains shouldn't contain CIDR. Our parsing should prevent it + // from happening in case where it matters (a, mx), but for the ones which + // doesn't, prevent them from sneaking through. + if strings.Contains(s, "/") { + r.trace("macro contains /") + return "", ErrInvalidDomain + } + + // Bypass the complex logic if there are no macros present. + if !strings.Contains(s, "%") { + return s, nil + } + + // Are we processing the character right after "%"? + afterPercent := false + + // Are we inside a macro definition (%{...}) ? + inMacroDefinition := false + + // Macro string, where we accumulate the values inside the definition. + macroS := "" + + var err error + n := "" + for _, c := range s { + if afterPercent { + afterPercent = false + switch c { + case '%': + n += "%" + continue + case '_': + n += " " + continue + case '-': + n += "%20" + continue + case '{': + inMacroDefinition = true + continue + } + return "", ErrInvalidMacro + } + if inMacroDefinition { + if c != '}' { + macroS += string(c) + continue + } + inMacroDefinition = false + + // Extract letter, digit transformer, reverse transformer, and + // delimiters. + groups := macroRegexp.FindStringSubmatch(macroS) + r.trace("macro %q: %q", macroS, groups) + macroS = "" + if groups == nil { + return "", ErrInvalidMacro + } + letter := groups[1] + + digits := 0 + if groups[2] != "" { + // Use 0 as "no digits given"; an explicit value of 0 is not + // valid. + digits, err = strconv.Atoi(groups[2]) + if err != nil || digits <= 0 { + return "", ErrInvalidMacro + } + } + reverse := groups[3] == "r" || groups[3] == "R" + delimiters := groups[4] + if delimiters == "" { + // By default, split strings by ".". + delimiters = "." + } + + // Uppercase letters indicate URL escaping of the results. + urlEscape := letter == strings.ToUpper(letter) + letter = strings.ToLower(letter) + + str := "" + switch letter { + case "s": + str = r.sender + case "l": + str, _ = split(r.sender) + case "o": + _, str = split(r.sender) + case "d": + str = domain + case "i": + str = ipToMacroStr(r.ip) + case "p": + // This shouldn't be used, we don't want to support it, it's + // risky. "unknown" is a safe value. + // https://tools.ietf.org/html/rfc7208#section-7.3 + str = "unknown" + case "v": + if r.ip.To4() != nil { + str = "in-addr" + } else { + str = "ip6" + } + case "h": + str = r.helo + default: + // c, r, t are allowed in exp only, and we don't expand macros + // in exp so they are just as invalid as the rest. + return "", ErrInvalidMacro + } + + // Split str using the given separators. + splitFunc := func(r rune) bool { + return strings.ContainsRune(delimiters, r) + } + split := strings.FieldsFunc(str, splitFunc) + + // Reverse if requested. + if reverse { + reverseStrings(split) + } + + // Leave the last $digits fields, if given. + if digits > 0 { + if digits > len(split) { + digits = len(split) + } + split = split[len(split)-digits:] + } + + // Join back, always with "." + str = strings.Join(split, ".") + + // Escape if requested. Note this doesn't strictly escape ALL + // unreserved characters, it's the closest we can get without + // reimplmenting it ourselves. + if urlEscape { + str = url.QueryEscape(str) + } + + n += str + continue + } + if c == '%' { + afterPercent = true + continue + } + n += string(c) + } + + r.trace("macro expanded %q to %q", s, n) + return n, nil +} + +func reverseStrings(a []string) { + for left, right := 0, len(a)-1; left < right; left, right = left+1, right-1 { + a[left], a[right] = a[right], a[left] + } +} + +func ipToMacroStr(ip net.IP) string { + if ip.To4() != nil { + return ip.String() + } + + // For IPv6 addresses, the "i" macro expands to a dot-format address. + // https://datatracker.ietf.org/doc/html/rfc7208#section-7.3 + sb := strings.Builder{} + sb.Grow(64) + for _, b := range ip.To16() { + fmt.Fprintf(&sb, "%x.%x.", b>>4, b&0xf) + } + // Return the string without the trailing ".". + return sb.String()[:sb.Len()-1] +} diff --git a/vendor/gitlab.com/etke.cc/go/validator/emails.go b/vendor/gitlab.com/etke.cc/go/validator/emails.go index 26ce57b..d40bfbb 100644 --- a/vendor/gitlab.com/etke.cc/go/validator/emails.go +++ b/vendor/gitlab.com/etke.cc/go/validator/emails.go @@ -1,14 +1,21 @@ package validator import ( + "net" "net/mail" "strings" + "blitiri.com.ar/go/spf" "gitlab.com/etke.cc/go/trysmtp" ) // Email checks if email is valid -func (v *V) Email(email string) bool { +func (v *V) Email(email string, optionalSenderIP ...net.IP) bool { + var senderIP net.IP + if len(optionalSenderIP) > 0 { + senderIP = optionalSenderIP[0] + } + // edge case: email may be optional if email == "" { return !v.enforce.Email @@ -41,6 +48,12 @@ func (v *V) Email(email string) bool { } } + if v.enforce.SPF { + if v.emailNoSPF(email, senderIP) { + return false + } + } + if v.enforce.SMTP { if v.emailNoSMTP(email) { return false @@ -78,3 +91,8 @@ func (v *V) emailNoSMTP(email string) bool { return false } + +func (v *V) emailNoSPF(email string, senderIP net.IP) bool { + result, _ := spf.CheckHostWithSender(senderIP, "", email, spf.WithTraceFunc(v.log.Info)) //nolint:errcheck // not a error + return result == spf.Fail +} diff --git a/vendor/gitlab.com/etke.cc/go/validator/validator.go b/vendor/gitlab.com/etke.cc/go/validator/validator.go index b4f6ff8..2fc2ecd 100644 --- a/vendor/gitlab.com/etke.cc/go/validator/validator.go +++ b/vendor/gitlab.com/etke.cc/go/validator/validator.go @@ -21,6 +21,8 @@ type Enforce struct { Domain bool // SMTP enforces SMTP check (email actually exists on mail server) and rejects non-existing emails SMTP bool + // SPF enforces SPF record check (sender allowed to use that email and send emails) and rejects unathorized emails + SPF bool // MX enforces MX records check on email's mail server MX bool } diff --git a/vendor/modules.txt b/vendor/modules.txt index 9c1617f..27f815c 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,3 +1,7 @@ +# blitiri.com.ar/go/spf v1.5.1 +## explicit; go 1.15 +blitiri.com.ar/go/spf +blitiri.com.ar/go/spf/internal/dnstest # github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a ## explicit github.com/cention-sany/utf7 @@ -127,7 +131,7 @@ gitlab.com/etke.cc/go/secgen # gitlab.com/etke.cc/go/trysmtp v1.0.0 ## explicit; go 1.18 gitlab.com/etke.cc/go/trysmtp -# gitlab.com/etke.cc/go/validator v1.0.4 +# gitlab.com/etke.cc/go/validator v1.0.5 ## explicit; go 1.18 gitlab.com/etke.cc/go/validator # gitlab.com/etke.cc/linkpearl v0.0.0-20221116205701-65547c5608e6