1045 lines
28 KiB
Go
1045 lines
28 KiB
Go
// 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:<domain>".
|
|
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]
|
|
}
|