consider Return-Path in SPF checks
This commit is contained in:
parent
a6f52fe1ee
commit
952ff99fc7
19 changed files with 567 additions and 308 deletions
2
go.mod
2
go.mod
|
|
@ -27,7 +27,7 @@ require (
|
|||
gitlab.com/etke.cc/go/mxidwc v1.0.0
|
||||
gitlab.com/etke.cc/go/psd v1.1.2
|
||||
gitlab.com/etke.cc/go/secgen v1.2.0
|
||||
gitlab.com/etke.cc/go/validator v1.0.7
|
||||
gitlab.com/etke.cc/go/validator/v2 v2.2.0
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||
maunium.net/go/mautrix v0.19.0
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -116,8 +116,8 @@ gitlab.com/etke.cc/go/secgen v1.2.0 h1:qpV7rUn5Rs6eWxAmbGG/idPCOgsN4HggGmSZ+1R/L
|
|||
gitlab.com/etke.cc/go/secgen v1.2.0/go.mod h1:v5L07AIXtNpC/miYiK0TMIn+ZKbiYrTRiXTw6qTL6pw=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.3 h1:e2EHond77onMaecqCg6mWumffTSEf+ycgj88nbeefDI=
|
||||
gitlab.com/etke.cc/go/trysmtp v1.1.3/go.mod h1:lOO7tTdAE0a3ETV3wN3GJ7I1Tqewu7YTpPWaOmTteV0=
|
||||
gitlab.com/etke.cc/go/validator v1.0.7 h1:4BGDTa9x68vJhbyn7m8W2yX+2Nb5im9+JLRrgoLUlF4=
|
||||
gitlab.com/etke.cc/go/validator v1.0.7/go.mod h1:Id0SxRj0J3IPhiKlj0w1plxVLZfHlkwipn7HfRZsDts=
|
||||
gitlab.com/etke.cc/go/validator/v2 v2.2.0 h1:XgB8XikOc3WzzFyimpZOt3p+kbHny98sGkXtp2tSwzQ=
|
||||
gitlab.com/etke.cc/go/validator/v2 v2.2.0/go.mod h1:NBY0v4ZqR1GQpuojf369EW2oS/4Ll1uIs9YTgijRbaY=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54 h1:+ihVGkVBBWlRyugJ3dvmA1rwqC9jFXrUZSslkYFN0Gw=
|
||||
gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54/go.mod h1:R/uxbOoajQVxm0qCbWYfox6B9uqnM5R0/KMJcLSr6zM=
|
||||
go.mau.fi/util v0.6.0 h1:W6SyB3Bm/GjenQ5iq8Z8WWdN85Gy2xS6L0wmnR7SVjg=
|
||||
|
|
|
|||
|
|
@ -2,23 +2,8 @@ package smtp
|
|||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// validatorLoggerWrapper is a wrapper around zerolog.Logger to implement validator.Logger interface
|
||||
type validatorLoggerWrapper struct {
|
||||
log *zerolog.Logger
|
||||
}
|
||||
|
||||
func (l validatorLoggerWrapper) Info(msg string, args ...any) {
|
||||
l.log.Info().Msgf(msg, args...)
|
||||
}
|
||||
|
||||
func (l validatorLoggerWrapper) Error(msg string, args ...any) {
|
||||
l.log.Error().Msgf(msg, args...)
|
||||
}
|
||||
|
||||
// loggerWrapper is a wrapper around any logger to implement smtp.Logger interface
|
||||
type loggerWrapper struct {
|
||||
log func(string, ...any)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/emersion/go-smtp"
|
||||
"github.com/jhillyerd/enmime"
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/etke.cc/go/validator"
|
||||
"gitlab.com/etke.cc/go/validator/v2"
|
||||
"gitlab.com/etke.cc/postmoogle/email"
|
||||
"gitlab.com/etke.cc/postmoogle/utils"
|
||||
"maunium.net/go/mautrix/id"
|
||||
|
|
@ -171,7 +171,7 @@ func (s *session) incomingData(r io.Reader) error {
|
|||
addr := s.getAddr(envelope)
|
||||
reader.Seek(0, io.SeekStart) //nolint:errcheck // becase we're sure that's ok
|
||||
validations := s.bot.GetIFOptions(s.ctx, s.roomID)
|
||||
if !validateIncoming(s.from, s.tos[0], addr, s.log, validations) {
|
||||
if !validateIncoming(s.from, envelope.GetHeader("Return-Path"), addr, s.log, validations) {
|
||||
s.bot.BanAuth(s.ctx, addr)
|
||||
return ErrBanned
|
||||
}
|
||||
|
|
@ -288,7 +288,7 @@ func (s *session) getAddr(envelope *enmime.Envelope) net.Addr {
|
|||
return realAddr
|
||||
}
|
||||
|
||||
func validateIncoming(from, to string, senderAddr net.Addr, log *zerolog.Logger, options email.IncomingFilteringOptions) bool {
|
||||
func validateIncoming(from, returnPath string, senderAddr net.Addr, log *zerolog.Logger, options email.IncomingFilteringOptions) bool {
|
||||
var sender net.IP
|
||||
switch netaddr := senderAddr.(type) {
|
||||
case *net.TCPAddr:
|
||||
|
|
@ -298,13 +298,18 @@ func validateIncoming(from, to string, senderAddr net.Addr, log *zerolog.Logger,
|
|||
sender = net.ParseIP(host)
|
||||
}
|
||||
|
||||
enforce := validator.Enforce{
|
||||
Email: true,
|
||||
MX: options.SpamcheckMX(),
|
||||
SPF: options.SpamcheckSPF(),
|
||||
SMTP: options.SpamcheckSMTP(),
|
||||
vcfg := &validator.Config{
|
||||
Log: log.Warn().Msgf,
|
||||
Email: validator.Email{
|
||||
Enforce: true,
|
||||
Spamlist: options.Spamlist(),
|
||||
MX: options.SpamcheckMX(),
|
||||
SPF: options.SpamcheckSPF(),
|
||||
SMTP: options.SpamcheckSMTP(),
|
||||
From: from,
|
||||
},
|
||||
}
|
||||
v := validator.New(options.Spamlist(), enforce, to, &validatorLoggerWrapper{log: log})
|
||||
v := validator.New(vcfg)
|
||||
|
||||
return v.Email(from, sender)
|
||||
return v.Email(from, returnPath, sender)
|
||||
}
|
||||
|
|
|
|||
11
vendor/gitlab.com/etke.cc/go/validator/.gitlab-ci.yml
generated
vendored
11
vendor/gitlab.com/etke.cc/go/validator/.gitlab-ci.yml
generated
vendored
|
|
@ -1,11 +0,0 @@
|
|||
lint:
|
||||
image: registry.gitlab.com/etke.cc/base
|
||||
script:
|
||||
- golangci-lint run ./...
|
||||
|
||||
unit:
|
||||
image: registry.gitlab.com/etke.cc/base
|
||||
script:
|
||||
- go test -coverprofile=cover.out ./...
|
||||
- go tool cover -func=cover.out
|
||||
- rm -f cover.out
|
||||
142
vendor/gitlab.com/etke.cc/go/validator/emails.go
generated
vendored
142
vendor/gitlab.com/etke.cc/go/validator/emails.go
generated
vendored
|
|
@ -1,142 +0,0 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"blitiri.com.ar/go/spf"
|
||||
"gitlab.com/etke.cc/go/trysmtp"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
// Email checks if email is valid
|
||||
func (v *V) Email(email string, optionalSenderIP ...net.IP) bool {
|
||||
|
||||
// edge case: email may be optional
|
||||
if email == "" {
|
||||
return !v.enforce.Email
|
||||
}
|
||||
|
||||
address, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
v.log.Info("email %s invalid, reason: %v", email, err)
|
||||
return false
|
||||
}
|
||||
email = address.Address
|
||||
return v.emailChecks(email, optionalSenderIP...)
|
||||
}
|
||||
|
||||
func (v *V) emailChecks(email string, optionalSenderIP ...net.IP) bool {
|
||||
maxChecks := 4
|
||||
var senderIP net.IP
|
||||
if len(optionalSenderIP) > 0 {
|
||||
senderIP = optionalSenderIP[0]
|
||||
}
|
||||
errchan := make(chan error, maxChecks)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go v.emailSpamlist(ctx, email, errchan)
|
||||
go v.emailNoMX(ctx, email, errchan)
|
||||
go v.emailNoSPF(ctx, email, senderIP, errchan)
|
||||
go v.emailNoSMTP(ctx, email, errchan)
|
||||
|
||||
var checks int
|
||||
for {
|
||||
checks++
|
||||
err := <-errchan
|
||||
if err != nil {
|
||||
v.log.Info("email %q is invalid, reason: %v", email, err)
|
||||
return false
|
||||
}
|
||||
if checks >= maxChecks {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailSpamlist(ctx context.Context, email string, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
emailb := []byte(email)
|
||||
for _, spamregex := range v.spamlist {
|
||||
if spamregex.Match(emailb) {
|
||||
errchan <- fmt.Errorf("spamlist")
|
||||
return
|
||||
}
|
||||
}
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailNoMX(ctx context.Context, email string, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if !v.enforce.MX {
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
at := strings.LastIndex(email, "@")
|
||||
domain := email[at+1:]
|
||||
if !v.MX(domain) {
|
||||
v.log.Info("email %s domain %s invalid, reason: no MX", email, domain)
|
||||
errchan <- fmt.Errorf("no MX")
|
||||
return
|
||||
}
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailNoSMTP(ctx context.Context, email string, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if !v.enforce.SMTP {
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
client, err := trysmtp.Connect(v.from, email)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "45") {
|
||||
v.log.Info("email %s may be invalid, reason: SMTP check (%v)", email, err)
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
v.log.Info("email %s invalid, reason: SMTP check (%v)", email, err)
|
||||
errchan <- fmt.Errorf("SMTP")
|
||||
return
|
||||
}
|
||||
client.Close()
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailNoSPF(ctx context.Context, email string, senderIP net.IP, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if !v.enforce.SPF {
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
result, _ := spf.CheckHostWithSender(senderIP, "", email, spf.WithTraceFunc(v.log.Info)) //nolint:errcheck // not a error
|
||||
if result == spf.Fail {
|
||||
errchan <- fmt.Errorf("SPF")
|
||||
return
|
||||
}
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
45
vendor/gitlab.com/etke.cc/go/validator/lookups.go
generated
vendored
45
vendor/gitlab.com/etke.cc/go/validator/lookups.go
generated
vendored
|
|
@ -1,45 +0,0 @@
|
|||
package validator
|
||||
|
||||
import "net"
|
||||
|
||||
// A checks if host has at least one A record
|
||||
func (v *V) A(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
v.log.Error("cannot get A records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return len(ips) > 0
|
||||
}
|
||||
|
||||
// CNAME checks if host has at least one CNAME record
|
||||
func (v *V) CNAME(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
cname, err := net.LookupCNAME(host)
|
||||
if err != nil {
|
||||
v.log.Error("cannot get CNAME records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return cname != ""
|
||||
}
|
||||
|
||||
// MX checks if host has at least one MX record
|
||||
func (v *V) MX(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
mxs, err := net.LookupMX(host)
|
||||
if err != nil {
|
||||
v.log.Error("cannot get MX records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return len(mxs) > 0
|
||||
}
|
||||
11
vendor/gitlab.com/etke.cc/go/validator/v2/.gitlab-ci.yml
generated
vendored
Normal file
11
vendor/gitlab.com/etke.cc/go/validator/v2/.gitlab-ci.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
lint:
|
||||
stage: test
|
||||
image: registry.gitlab.com/etke.cc/base/build
|
||||
script:
|
||||
- just lint
|
||||
|
||||
unit:
|
||||
stage: test
|
||||
image: registry.gitlab.com/etke.cc/base/build
|
||||
script:
|
||||
- just test
|
||||
142
vendor/gitlab.com/etke.cc/go/validator/v2/.golangci.yml
generated
vendored
Normal file
142
vendor/gitlab.com/etke.cc/go/validator/v2/.golangci.yml
generated
vendored
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
run:
|
||||
concurrency: 4
|
||||
timeout: 30m
|
||||
issues-exit-code: 1
|
||||
tests: true
|
||||
build-tags: []
|
||||
skip-dirs-use-default: true
|
||||
skip-files: []
|
||||
modules-download-mode: readonly
|
||||
|
||||
output:
|
||||
formats:
|
||||
- format: colored-line-number
|
||||
print-issued-lines: true
|
||||
print-linter-name: true
|
||||
sort-results: true
|
||||
|
||||
linters-settings:
|
||||
decorder:
|
||||
dec-order:
|
||||
- const
|
||||
- var
|
||||
- type
|
||||
- func
|
||||
dogsled:
|
||||
max-blank-identifiers: 3
|
||||
errcheck:
|
||||
check-type-assertions: true
|
||||
check-blank: true
|
||||
errchkjson:
|
||||
report-no-exported: true
|
||||
exhaustive:
|
||||
check:
|
||||
- switch
|
||||
- map
|
||||
default-signifies-exhaustive: true
|
||||
gocognit:
|
||||
min-complexity: 15
|
||||
nestif:
|
||||
min-complexity: 5
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- style
|
||||
- performance
|
||||
gofmt:
|
||||
simplify: true
|
||||
rewrite-rules:
|
||||
- pattern: 'interface{}'
|
||||
replacement: 'any'
|
||||
- pattern: 'a[b:len(a)]'
|
||||
replacement: 'a[b:]'
|
||||
gofumpt:
|
||||
extra-rules: true
|
||||
grouper:
|
||||
const-require-single-const: true
|
||||
import-require-single-import: true
|
||||
var-require-single-var: true
|
||||
misspell:
|
||||
locale: US
|
||||
usestdlibvars:
|
||||
time-month: true
|
||||
time-layout: true
|
||||
crypto-hash: true
|
||||
default-rpc-path: true
|
||||
sql-isolation-level: true
|
||||
tls-signature-scheme: true
|
||||
constant-kind: true
|
||||
unparam:
|
||||
check-exported: true
|
||||
linters:
|
||||
disable-all: false
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- decorder
|
||||
- dogsled
|
||||
- dupl
|
||||
- dupword
|
||||
- durationcheck
|
||||
- errcheck
|
||||
- errchkjson
|
||||
- errname
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- exportloopref
|
||||
- forcetypeassert
|
||||
- gocognit
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gofmt
|
||||
- gofumpt
|
||||
- goimports
|
||||
- gosec
|
||||
- gosimple
|
||||
- gosmopolitan
|
||||
- govet
|
||||
- ineffassign
|
||||
- makezero
|
||||
- mirror
|
||||
- misspell
|
||||
- nestif
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- predeclared
|
||||
- revive
|
||||
- sqlclosecheck
|
||||
- staticcheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- usestdlibvars
|
||||
- wastedassign
|
||||
fast: false
|
||||
|
||||
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- mocks
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- gocyclo
|
||||
- gocognit
|
||||
- errcheck
|
||||
- dupl
|
||||
- gosec
|
||||
- linters:
|
||||
- staticcheck
|
||||
text: "SA9003:"
|
||||
- linters:
|
||||
- lll
|
||||
source: "^//go:generate "
|
||||
- linters:
|
||||
- revive
|
||||
text: "returns unexported type"
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
new: false
|
||||
35
vendor/gitlab.com/etke.cc/go/validator/v2/config.go
generated
vendored
Normal file
35
vendor/gitlab.com/etke.cc/go/validator/v2/config.go
generated
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package validator
|
||||
|
||||
import "regexp"
|
||||
|
||||
// Config of the validator
|
||||
type Config struct {
|
||||
Email Email // Email config
|
||||
Domain Domain // Domain config
|
||||
Log func(msg string, args ...any) // Log func
|
||||
}
|
||||
|
||||
// Email checks configuration
|
||||
type Email struct {
|
||||
// Enforce enforces email check and rejects empty emails
|
||||
Enforce 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
|
||||
// From is a valid email address that will be used for SMTP checks
|
||||
From string
|
||||
// Spamlist is a list of spam emails with wildcards
|
||||
Spamlist []string
|
||||
spamlist []*regexp.Regexp
|
||||
}
|
||||
|
||||
// Domain checks configuration
|
||||
type Domain struct {
|
||||
// Enforce enforces domain check and rejects empty domains
|
||||
Enforce bool
|
||||
// PrivateSuffixes considers subdomains with the following suffixes as domains
|
||||
PrivateSuffixes []string
|
||||
}
|
||||
|
|
@ -9,12 +9,11 @@ import (
|
|||
|
||||
// based on W3C email regex, ref: https://www.w3.org/TR/2016/REC-html51-20161101/sec-forms.html#email-state-typeemail
|
||||
var domainRegex = regexp.MustCompile(`^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
|
||||
var privateSuffix = []string{".etke.host", ".onmatrix.chat"}
|
||||
|
||||
// Domain checks if domain is valid
|
||||
func (v *V) Domain(domain string) bool {
|
||||
if domain == "" {
|
||||
return !v.enforce.Domain
|
||||
return !v.cfg.Domain.Enforce
|
||||
}
|
||||
|
||||
if !v.DomainString(domain) {
|
||||
|
|
@ -27,12 +26,12 @@ func (v *V) Domain(domain string) bool {
|
|||
// DomainString checks if domain string / value is valid using string checks like length and regexp
|
||||
func (v *V) DomainString(domain string) bool {
|
||||
if len(domain) < 4 || len(domain) > 77 {
|
||||
v.log.Info("domain %s invalid, reason: length", domain)
|
||||
v.cfg.Log("domain %s invalid, reason: length", domain)
|
||||
return false
|
||||
}
|
||||
|
||||
if !domainRegex.MatchString(domain) {
|
||||
v.log.Info("domain %s invalid, reason: regexp", domain)
|
||||
v.cfg.Log("domain %s invalid, reason: regexp", domain)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +60,7 @@ func (v *V) GetBase(domain string) string {
|
|||
|
||||
// hasSuffix checks if domain has a suffix from public suffix list or from predefined suffix list
|
||||
func (v *V) hasSuffix(domain string) bool {
|
||||
for _, suffix := range privateSuffix {
|
||||
for _, suffix := range v.cfg.Domain.PrivateSuffixes {
|
||||
if strings.HasSuffix(domain, suffix) {
|
||||
return true
|
||||
}
|
||||
197
vendor/gitlab.com/etke.cc/go/validator/v2/emails.go
generated
vendored
Normal file
197
vendor/gitlab.com/etke.cc/go/validator/v2/emails.go
generated
vendored
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"blitiri.com.ar/go/spf"
|
||||
"gitlab.com/etke.cc/go/trysmtp"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrSpamlist = fmt.Errorf("spamlist")
|
||||
ErrNoMX = fmt.Errorf("no MX")
|
||||
ErrSMTP = fmt.Errorf("SMTP")
|
||||
ErrSPF = fmt.Errorf("SPF")
|
||||
)
|
||||
|
||||
// Email checks if email is valid
|
||||
// returnPath and optionalSenderIP are optional fields
|
||||
func (v *V) Email(email, returnPath string, optionalSenderIP ...net.IP) bool {
|
||||
// edge case: email may be optional
|
||||
if email == "" {
|
||||
return !v.cfg.Email.Enforce
|
||||
}
|
||||
|
||||
address, err := mail.ParseAddress(email)
|
||||
if err != nil {
|
||||
v.cfg.Log("email %s invalid, reason: %v", email, err)
|
||||
return false
|
||||
}
|
||||
if returnPath != "" {
|
||||
rpAddress, err := mail.ParseAddress(returnPath)
|
||||
if err != nil {
|
||||
v.cfg.Log("return path %s invalid, reason: %v", returnPath, err)
|
||||
}
|
||||
if rpAddress != nil {
|
||||
returnPath = rpAddress.Address
|
||||
}
|
||||
}
|
||||
|
||||
email = address.Address
|
||||
return v.emailChecks(email, returnPath, optionalSenderIP...)
|
||||
}
|
||||
|
||||
func (v *V) emailChecks(email, returnPath string, optionalSenderIP ...net.IP) bool {
|
||||
maxChecks := 4
|
||||
var senderIP net.IP
|
||||
if len(optionalSenderIP) > 0 {
|
||||
senderIP = optionalSenderIP[0]
|
||||
}
|
||||
errchan := make(chan error, maxChecks)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
go v.emailSpamlist(ctx, email, errchan)
|
||||
go v.emailNoMX(ctx, email, errchan)
|
||||
go v.emailNoSPF(ctx, email, returnPath, senderIP, errchan)
|
||||
go v.emailNoSMTP(ctx, email, errchan)
|
||||
|
||||
var checks int
|
||||
for {
|
||||
checks++
|
||||
err := <-errchan
|
||||
if err != nil {
|
||||
v.cfg.Log("email %q is invalid, reason: %v", email, err)
|
||||
return false
|
||||
}
|
||||
if checks >= maxChecks {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailSpamlist(ctx context.Context, email string, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
emailb := []byte(email)
|
||||
for _, spamregex := range v.cfg.Email.spamlist {
|
||||
if spamregex.Match(emailb) {
|
||||
errchan <- ErrSpamlist
|
||||
return
|
||||
}
|
||||
}
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailNoMX(ctx context.Context, email string, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if !v.cfg.Email.MX {
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
at := strings.LastIndex(email, "@")
|
||||
domain := email[at+1:]
|
||||
if !v.MX(domain) {
|
||||
v.cfg.Log("email %s domain %s invalid, reason: no MX", email, domain)
|
||||
errchan <- ErrNoMX
|
||||
return
|
||||
}
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
|
||||
func (v *V) emailNoSMTP(ctx context.Context, email string, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if !v.cfg.Email.SMTP {
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
client, err := trysmtp.Connect(v.cfg.Email.From, email)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "45") {
|
||||
v.cfg.Log("email %s may be invalid, reason: SMTP check (%v)", email, err)
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
v.cfg.Log("email %s invalid, reason: SMTP check (%v)", email, err)
|
||||
errchan <- ErrSMTP
|
||||
return
|
||||
}
|
||||
client.Close()
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
|
||||
// shouldValidateReturnPath checks if returnPath should be validated in SPF check
|
||||
func (v *V) shouldValidateReturnPath(email, returnPath string) bool {
|
||||
if returnPath == "" {
|
||||
return false
|
||||
}
|
||||
if email == returnPath {
|
||||
return false
|
||||
}
|
||||
|
||||
var emailDomain, returnPathDomain string
|
||||
emailParts := strings.Split(email, "@")
|
||||
if len(emailParts) < 2 {
|
||||
return false
|
||||
}
|
||||
emailDomain = v.GetBase(emailParts[1])
|
||||
|
||||
if returnPath != "" {
|
||||
returnPathParts := strings.Split(returnPath, "@")
|
||||
if len(returnPathParts) < 2 {
|
||||
return false
|
||||
}
|
||||
returnPathDomain = v.GetBase(returnPathParts[1])
|
||||
}
|
||||
return emailDomain == returnPathDomain
|
||||
}
|
||||
|
||||
func (v *V) emailNoSPF(ctx context.Context, email, returnPath string, senderIP net.IP, errchan chan error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
if !v.cfg.Email.SPF {
|
||||
errchan <- nil
|
||||
return
|
||||
}
|
||||
|
||||
opts := []spf.Option{
|
||||
spf.WithContext(ctx),
|
||||
spf.WithTraceFunc(v.cfg.Log),
|
||||
}
|
||||
|
||||
resultEmail, _ := spf.CheckHostWithSender(senderIP, "", email, opts...) //nolint:errcheck // not a error
|
||||
if resultEmail == spf.Fail {
|
||||
if v.shouldValidateReturnPath(email, returnPath) {
|
||||
resultReturnPath, _ := spf.CheckHostWithSender(senderIP, "", returnPath, opts...) //nolint:errcheck // not a error
|
||||
if resultReturnPath == spf.Fail {
|
||||
errchan <- ErrSPF
|
||||
return
|
||||
}
|
||||
}
|
||||
errchan <- ErrSPF
|
||||
return
|
||||
}
|
||||
errchan <- nil
|
||||
}
|
||||
}
|
||||
22
vendor/gitlab.com/etke.cc/go/validator/v2/justfile
generated
vendored
Normal file
22
vendor/gitlab.com/etke.cc/go/validator/v2/justfile
generated
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# show help by default
|
||||
default:
|
||||
@just --list --justfile {{ justfile() }}
|
||||
|
||||
# update go deps
|
||||
update *flags:
|
||||
go get {{flags}} .
|
||||
go mod tidy
|
||||
|
||||
# run linter
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
# automatically fix liter issues
|
||||
lintfix:
|
||||
golangci-lint run --fix ./...
|
||||
|
||||
# run unit tests
|
||||
test:
|
||||
@go test -cover -coverprofile=cover.out -coverpkg=./... -covermode=set ./...
|
||||
@go tool cover -func=cover.out
|
||||
-@rm -f cover.out
|
||||
80
vendor/gitlab.com/etke.cc/go/validator/v2/lookups.go
generated
vendored
Normal file
80
vendor/gitlab.com/etke.cc/go/validator/v2/lookups.go
generated
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A checks if host has at least one A record
|
||||
func (v *V) A(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
v.cfg.Log("cannot get A records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return len(ips) > 0
|
||||
}
|
||||
|
||||
// CNAME checks if host has at least one CNAME record
|
||||
func (v *V) CNAME(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
cname, err := net.LookupCNAME(host)
|
||||
if err != nil {
|
||||
v.cfg.Log("cannot get CNAME records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return cname != ""
|
||||
}
|
||||
|
||||
// MX checks if host has at least one MX record
|
||||
func (v *V) MX(host string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
mxs, err := net.LookupMX(host)
|
||||
if err != nil {
|
||||
v.cfg.Log("cannot get MX records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
|
||||
return len(mxs) > 0
|
||||
}
|
||||
|
||||
// NS checks if host has at least one NS record,
|
||||
// and optionally checks if the NS record contains the given string
|
||||
func (v *V) NS(host string, contains ...string) bool {
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
nss, err := net.LookupNS(host)
|
||||
if err != nil {
|
||||
v.cfg.Log("cannot get NS records of %s: %v", host, err)
|
||||
return false
|
||||
}
|
||||
if len(nss) == 0 {
|
||||
v.cfg.Log("%s doesn't have NS records", host)
|
||||
return false
|
||||
}
|
||||
|
||||
if len(contains) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(contains) > 0 {
|
||||
for _, ns := range nss {
|
||||
for _, c := range contains {
|
||||
if strings.Contains(ns.Host, c) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
56
vendor/gitlab.com/etke.cc/go/validator/v2/validator.go
generated
vendored
Normal file
56
vendor/gitlab.com/etke.cc/go/validator/v2/validator.go
generated
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// V is a validator implementation
|
||||
type V struct {
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
var defaultLogfunc = func(msg string, args ...any) {
|
||||
fmt.Printf(msg+"\n", args...)
|
||||
}
|
||||
|
||||
// New validator
|
||||
func New(cfg *Config) *V {
|
||||
if cfg.Log == nil {
|
||||
cfg.Log = defaultLogfunc
|
||||
}
|
||||
spamregexes, err := parseSpamlist(cfg.Email.Spamlist)
|
||||
if err != nil {
|
||||
cfg.Log("cannot parse spamlist: %v", err)
|
||||
}
|
||||
cfg.Email.spamlist = spamregexes
|
||||
return &V{cfg}
|
||||
}
|
||||
|
||||
func parseSpamlist(patterns []string) ([]*regexp.Regexp, error) {
|
||||
regexes := []*regexp.Regexp{}
|
||||
for _, pattern := range patterns {
|
||||
rule, err := regexp.Compile("^" + parsePattern(pattern) + "$")
|
||||
if err != nil {
|
||||
return regexes, err
|
||||
}
|
||||
|
||||
regexes = append(regexes, rule)
|
||||
}
|
||||
|
||||
return regexes, nil
|
||||
}
|
||||
|
||||
func parsePattern(pattern string) string {
|
||||
var regexpattern strings.Builder
|
||||
for _, runeItem := range pattern {
|
||||
if runeItem == '*' {
|
||||
regexpattern.WriteString("(.*)")
|
||||
continue
|
||||
}
|
||||
regexpattern.WriteString(regexp.QuoteMeta(string(runeItem)))
|
||||
}
|
||||
|
||||
return regexpattern.String()
|
||||
}
|
||||
75
vendor/gitlab.com/etke.cc/go/validator/validator.go
generated
vendored
75
vendor/gitlab.com/etke.cc/go/validator/validator.go
generated
vendored
|
|
@ -1,75 +0,0 @@
|
|||
package validator
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// V is a validator implementation
|
||||
type V struct {
|
||||
spamlist []*regexp.Regexp
|
||||
enforce Enforce
|
||||
from string
|
||||
log Logger
|
||||
}
|
||||
|
||||
// Enforce checks
|
||||
type Enforce struct {
|
||||
// Email enforces email check and rejects empty emails
|
||||
Email bool
|
||||
// Domain enforces domain check and rejects empty domains
|
||||
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
|
||||
}
|
||||
|
||||
type Logger interface {
|
||||
Info(string, ...interface{})
|
||||
Error(string, ...interface{})
|
||||
}
|
||||
|
||||
// New Validator, accepts spamlist with wildcards
|
||||
func New(spamlist []string, enforce Enforce, smtpFrom string, log Logger) *V {
|
||||
spamregexes, err := parseSpamlist(spamlist)
|
||||
if err != nil {
|
||||
log.Error("cannot parse spamlist: %v", err)
|
||||
}
|
||||
|
||||
return &V{
|
||||
spamlist: spamregexes,
|
||||
enforce: enforce,
|
||||
from: smtpFrom,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSpamlist(patterns []string) ([]*regexp.Regexp, error) {
|
||||
regexes := []*regexp.Regexp{}
|
||||
for _, pattern := range patterns {
|
||||
rule, err := regexp.Compile("^" + parsePattern(pattern) + "$")
|
||||
if err != nil {
|
||||
return regexes, err
|
||||
}
|
||||
|
||||
regexes = append(regexes, rule)
|
||||
}
|
||||
|
||||
return regexes, nil
|
||||
}
|
||||
|
||||
func parsePattern(pattern string) string {
|
||||
var regexpattern strings.Builder
|
||||
for _, runeItem := range pattern {
|
||||
if runeItem == '*' {
|
||||
regexpattern.WriteString("(.*)")
|
||||
continue
|
||||
}
|
||||
regexpattern.WriteString(regexp.QuoteMeta(string(runeItem)))
|
||||
}
|
||||
|
||||
return regexpattern.String()
|
||||
}
|
||||
4
vendor/modules.txt
vendored
4
vendor/modules.txt
vendored
|
|
@ -159,9 +159,9 @@ gitlab.com/etke.cc/go/secgen
|
|||
# gitlab.com/etke.cc/go/trysmtp v1.1.3
|
||||
## explicit; go 1.17
|
||||
gitlab.com/etke.cc/go/trysmtp
|
||||
# gitlab.com/etke.cc/go/validator v1.0.7
|
||||
# gitlab.com/etke.cc/go/validator/v2 v2.2.0
|
||||
## explicit; go 1.18
|
||||
gitlab.com/etke.cc/go/validator
|
||||
gitlab.com/etke.cc/go/validator/v2
|
||||
# gitlab.com/etke.cc/linkpearl v0.0.0-20240716084747-f2a547f02d54
|
||||
## explicit; go 1.21
|
||||
gitlab.com/etke.cc/linkpearl
|
||||
|
|
|
|||
Loading…
Reference in a new issue