mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 17:04:39 +03:00
mox!
This commit is contained in:
466
spf/parse.go
Normal file
466
spf/parse.go
Normal file
@ -0,0 +1,466 @@
|
||||
package spf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Record is a parsed SPF DNS record.
|
||||
//
|
||||
// An example record for example.com:
|
||||
//
|
||||
// v=spf1 +mx a:colo.example.com/28 -all
|
||||
type Record struct {
|
||||
Version string // Must be "spf1".
|
||||
Directives []Directive // An IP is evaluated against each directive until a match is found.
|
||||
Redirect string // Modifier that redirects SPF checks to other domain after directives did not match. Optional. For "redirect=".
|
||||
Explanation string // Modifier for creating a user-friendly error message when an IP results in status "fail".
|
||||
Other []Modifier // Other modifiers.
|
||||
}
|
||||
|
||||
// Directive consists of a mechanism that describes how to check if an IP matches,
|
||||
// an (optional) qualifier indicating the policy for a match, and optional
|
||||
// parameters specific to the mechanism.
|
||||
type Directive struct {
|
||||
Qualifier string // Sets the result if this directive matches. "" and "+" are "pass", "-" is "fail", "?" is "neutral", "~" is "softfail".
|
||||
Mechanism string // "all", "include", "a", "mx", "ptr", "ip4", "ip6", "exists".
|
||||
DomainSpec string // For include, a, mx, ptr, exists. Always in lower-case when parsed using ParseRecord.
|
||||
IP net.IP `json:"-"` // For ip4, ip6.
|
||||
IPstr string // Original string for IP, always with /subnet.
|
||||
IP4CIDRLen *int // For a, mx, ip4.
|
||||
IP6CIDRLen *int // For a, mx, ip6.
|
||||
}
|
||||
|
||||
// MechanismString returns a directive in string form for use in the Received-SPF header.
|
||||
func (d Directive) MechanismString() string {
|
||||
s := d.Qualifier + d.Mechanism
|
||||
if d.DomainSpec != "" {
|
||||
s += ":" + d.DomainSpec
|
||||
} else if d.IP != nil {
|
||||
s += ":" + d.IP.String()
|
||||
}
|
||||
if d.IP4CIDRLen != nil {
|
||||
s += fmt.Sprintf("/%d", *d.IP4CIDRLen)
|
||||
}
|
||||
if d.IP6CIDRLen != nil {
|
||||
if d.Mechanism != "ip6" {
|
||||
s += "/"
|
||||
}
|
||||
s += fmt.Sprintf("/%d", *d.IP6CIDRLen)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Modifier provides additional information for a policy.
|
||||
// "redirect" and "exp" are not represented as a Modifier but explicitly in a Record.
|
||||
type Modifier struct {
|
||||
Key string // Key is case-insensitive.
|
||||
Value string
|
||||
}
|
||||
|
||||
// Record returns an DNS record, to be configured as a TXT record for a domain,
|
||||
// e.g. a TXT record for example.com.
|
||||
func (r Record) Record() (string, error) {
|
||||
b := &strings.Builder{}
|
||||
b.WriteString("v=")
|
||||
b.WriteString(r.Version)
|
||||
for _, d := range r.Directives {
|
||||
b.WriteString(" " + d.MechanismString())
|
||||
}
|
||||
if r.Redirect != "" {
|
||||
fmt.Fprintf(b, " redirect=%s", r.Redirect)
|
||||
}
|
||||
if r.Explanation != "" {
|
||||
fmt.Fprintf(b, " exp=%s", r.Explanation)
|
||||
}
|
||||
for _, m := range r.Other {
|
||||
fmt.Fprintf(b, " %s=%s", m.Key, m.Value)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
type parser struct {
|
||||
s string
|
||||
lower string
|
||||
o int
|
||||
}
|
||||
|
||||
type parseError string
|
||||
|
||||
func (e parseError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
// toLower lower cases bytes that are A-Z. strings.ToLower does too much. and
|
||||
// would replace invalid bytes with unicode replacement characters, which would
|
||||
// break our requirement that offsets into the original and upper case strings
|
||||
// point to the same character.
|
||||
func toLower(s string) string {
|
||||
r := []byte(s)
|
||||
for i, c := range r {
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
r[i] = c + 0x20
|
||||
}
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// ParseRecord parses an SPF DNS TXT record.
|
||||
func ParseRecord(s string) (r *Record, isspf bool, rerr error) {
|
||||
p := parser{s: s, lower: toLower(s)}
|
||||
|
||||
r = &Record{
|
||||
Version: "spf1",
|
||||
}
|
||||
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x == nil {
|
||||
return
|
||||
}
|
||||
if err, ok := x.(parseError); ok {
|
||||
rerr = err
|
||||
return
|
||||
}
|
||||
panic(x)
|
||||
}()
|
||||
|
||||
p.xtake("v=spf1")
|
||||
for !p.empty() {
|
||||
p.xtake(" ")
|
||||
isspf = true // ../rfc/7208:825
|
||||
for p.take(" ") {
|
||||
}
|
||||
if p.empty() {
|
||||
break
|
||||
}
|
||||
|
||||
qualifier := p.takelist("+", "-", "?", "~")
|
||||
mechanism := p.takelist("all", "include:", "a", "mx", "ptr", "ip4:", "ip6:", "exists:")
|
||||
if qualifier != "" && mechanism == "" {
|
||||
p.xerrorf("expected mechanism after qualifier")
|
||||
}
|
||||
if mechanism == "" {
|
||||
// ../rfc/7208:2597
|
||||
modifier := p.takelist("redirect=", "exp=")
|
||||
if modifier == "" {
|
||||
// ../rfc/7208:2600
|
||||
name := p.xtakefn1(func(c rune, i int) bool {
|
||||
alpha := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z'
|
||||
return alpha || i > 0 && (c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.')
|
||||
})
|
||||
p.xtake("=")
|
||||
v := p.xmacroString(true)
|
||||
r.Other = append(r.Other, Modifier{name, v})
|
||||
continue
|
||||
}
|
||||
v := p.xdomainSpec(true)
|
||||
modifier = strings.TrimSuffix(modifier, "=")
|
||||
if modifier == "redirect" {
|
||||
if r.Redirect != "" {
|
||||
// ../rfc/7208:1419
|
||||
p.xerrorf("duplicate redirect modifier")
|
||||
}
|
||||
r.Redirect = v
|
||||
}
|
||||
if modifier == "exp" {
|
||||
if r.Explanation != "" {
|
||||
// ../rfc/7208:1419
|
||||
p.xerrorf("duplicate exp modifier")
|
||||
}
|
||||
r.Explanation = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
// ../rfc/7208:2585
|
||||
d := Directive{
|
||||
Qualifier: qualifier,
|
||||
Mechanism: strings.TrimSuffix(mechanism, ":"),
|
||||
}
|
||||
switch d.Mechanism {
|
||||
case "all":
|
||||
case "include":
|
||||
d.DomainSpec = p.xdomainSpec(false)
|
||||
case "a", "mx":
|
||||
if p.take(":") {
|
||||
d.DomainSpec = p.xdomainSpec(false)
|
||||
}
|
||||
if p.take("/") {
|
||||
if !p.take("/") {
|
||||
num, _ := p.xnumber()
|
||||
if num > 32 {
|
||||
p.xerrorf("invalid ip4 cidr length %d", num)
|
||||
}
|
||||
d.IP4CIDRLen = &num
|
||||
if !p.take("//") {
|
||||
break
|
||||
}
|
||||
}
|
||||
num, _ := p.xnumber()
|
||||
if num > 128 {
|
||||
p.xerrorf("invalid ip6 cidr length %d", num)
|
||||
}
|
||||
d.IP6CIDRLen = &num
|
||||
}
|
||||
case "ptr":
|
||||
if p.take(":") {
|
||||
d.DomainSpec = p.xdomainSpec(false)
|
||||
}
|
||||
case "ip4":
|
||||
d.IP, d.IPstr = p.xip4address()
|
||||
if p.take("/") {
|
||||
num, _ := p.xnumber()
|
||||
if num > 32 {
|
||||
p.xerrorf("invalid ip4 cidr length %d", num)
|
||||
}
|
||||
d.IP4CIDRLen = &num
|
||||
d.IPstr += fmt.Sprintf("/%d", num)
|
||||
} else {
|
||||
d.IPstr += "/32"
|
||||
}
|
||||
case "ip6":
|
||||
d.IP, d.IPstr = p.xip6address()
|
||||
if p.take("/") {
|
||||
num, _ := p.xnumber()
|
||||
if num > 128 {
|
||||
p.xerrorf("invalid ip6 cidr length %d", num)
|
||||
}
|
||||
d.IP6CIDRLen = &num
|
||||
d.IPstr += fmt.Sprintf("/%d", num)
|
||||
} else {
|
||||
d.IPstr += "/128"
|
||||
}
|
||||
case "exists":
|
||||
d.DomainSpec = p.xdomainSpec(false)
|
||||
default:
|
||||
return nil, true, fmt.Errorf("internal error, missing case for mechanism %q", d.Mechanism)
|
||||
}
|
||||
r.Directives = append(r.Directives, d)
|
||||
}
|
||||
return r, true, nil
|
||||
}
|
||||
|
||||
func (p *parser) xerrorf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if !p.empty() {
|
||||
msg += fmt.Sprintf(" (leftover %q)", p.s[p.o:])
|
||||
}
|
||||
panic(parseError(msg))
|
||||
}
|
||||
|
||||
// operates on original-cased characters.
|
||||
func (p *parser) xtakefn1(fn func(rune, int) bool) string {
|
||||
r := ""
|
||||
for i, c := range p.s[p.o:] {
|
||||
if !fn(c, i) {
|
||||
break
|
||||
}
|
||||
r += string(c)
|
||||
}
|
||||
if r == "" {
|
||||
p.xerrorf("need at least 1 char")
|
||||
}
|
||||
p.o += len(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// caller should set includingSlash to false when parsing "a" or "mx", or the / would be consumed as valid macro literal.
|
||||
func (p *parser) xdomainSpec(includingSlash bool) string {
|
||||
// ../rfc/7208:1579
|
||||
// This also consumes the "domain-end" part, which we check below.
|
||||
s := p.xmacroString(includingSlash)
|
||||
|
||||
// The ABNF says s must either end in macro-expand, or "." toplabel ["."]. The
|
||||
// toplabel rule implies the intention is to force a valid DNS name. We cannot just
|
||||
// check if the name is valid, because "macro-expand" is not a valid label. So we
|
||||
// recognize the macro-expand, and check for valid toplabel otherwise, because we
|
||||
// syntax errors must result in Permerror.
|
||||
for _, suf := range []string{"%%", "%_", "%-", "}"} {
|
||||
// The check for "}" assumes a "%{" precedes it...
|
||||
if strings.HasSuffix(s, suf) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
tl := strings.Split(strings.TrimSuffix(s, "."), ".")
|
||||
t := tl[len(tl)-1]
|
||||
if t == "" {
|
||||
p.xerrorf("invalid empty toplabel")
|
||||
}
|
||||
nums := 0
|
||||
for i, c := range t {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z':
|
||||
case c >= '0' && c <= '9':
|
||||
nums++
|
||||
case c == '-':
|
||||
if i == 0 {
|
||||
p.xerrorf("bad toplabel, invalid leading dash")
|
||||
}
|
||||
if i == len(t)-1 {
|
||||
p.xerrorf("bad toplabel, invalid trailing dash")
|
||||
}
|
||||
default:
|
||||
p.xerrorf("bad toplabel, invalid character")
|
||||
}
|
||||
}
|
||||
if nums == len(t) {
|
||||
p.xerrorf("bad toplabel, cannot be all digits")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *parser) xmacroString(includingSlash bool) string {
|
||||
// ../rfc/7208:1588
|
||||
r := ""
|
||||
for !p.empty() {
|
||||
w := p.takelist("%{", "%%", "%_", "%-") // "macro-expand"
|
||||
if w == "" {
|
||||
// "macro-literal"
|
||||
if !p.empty() {
|
||||
b := p.peekchar()
|
||||
if b > ' ' && b < 0x7f && b != '%' && (includingSlash || b != '/') {
|
||||
r += string(b)
|
||||
p.o++
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
r += w
|
||||
if w != "%{" {
|
||||
continue
|
||||
}
|
||||
r += p.xtakelist("s", "l", "o", "d", "i", "p", "h", "c", "r", "t", "v") // "macro-letter"
|
||||
digits := p.digits()
|
||||
if digits != "" {
|
||||
if v, err := strconv.Atoi(digits); err != nil {
|
||||
p.xerrorf("bad digits: %v", err)
|
||||
} else if v == 0 {
|
||||
p.xerrorf("bad digits 0 for 0 labels")
|
||||
}
|
||||
}
|
||||
r += digits
|
||||
if p.take("r") {
|
||||
r += "r"
|
||||
}
|
||||
for {
|
||||
delimiter := p.takelist(".", "-", "+", ",", "/", "_", "=")
|
||||
if delimiter == "" {
|
||||
break
|
||||
}
|
||||
r += delimiter
|
||||
}
|
||||
r += p.xtake("}")
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) empty() bool {
|
||||
return p.o >= len(p.s)
|
||||
}
|
||||
|
||||
// returns next original-cased character.
|
||||
func (p *parser) peekchar() byte {
|
||||
return p.s[p.o]
|
||||
}
|
||||
|
||||
func (p *parser) xtakelist(l ...string) string {
|
||||
w := p.takelist(l...)
|
||||
if w == "" {
|
||||
p.xerrorf("no match for %v", l)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func (p *parser) takelist(l ...string) string {
|
||||
for _, w := range l {
|
||||
if strings.HasPrefix(p.lower[p.o:], w) {
|
||||
p.o += len(w)
|
||||
return w
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// digits parses zero or more digits.
|
||||
func (p *parser) digits() string {
|
||||
r := ""
|
||||
for !p.empty() {
|
||||
b := p.peekchar()
|
||||
if b >= '0' && b <= '9' {
|
||||
r += string(b)
|
||||
p.o++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (p *parser) take(s string) bool {
|
||||
if strings.HasPrefix(p.lower[p.o:], s) {
|
||||
p.o += len(s)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *parser) xtake(s string) string {
|
||||
ok := p.take(s)
|
||||
if !ok {
|
||||
p.xerrorf("expected %q", s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p *parser) xnumber() (int, string) {
|
||||
s := p.digits()
|
||||
if s == "" {
|
||||
p.xerrorf("expected number")
|
||||
}
|
||||
if s == "0" {
|
||||
return 0, s
|
||||
}
|
||||
if strings.HasPrefix(s, "0") {
|
||||
p.xerrorf("bogus leading 0 in number")
|
||||
}
|
||||
v, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
p.xerrorf("parsing number for %q: %s", s, err)
|
||||
}
|
||||
return v, s
|
||||
}
|
||||
|
||||
func (p *parser) xip4address() (net.IP, string) {
|
||||
// ../rfc/7208:2607
|
||||
ip4num := func() (byte, string) {
|
||||
v, vs := p.xnumber()
|
||||
if v > 255 {
|
||||
p.xerrorf("bad ip4 number %d", v)
|
||||
}
|
||||
return byte(v), vs
|
||||
}
|
||||
a, as := ip4num()
|
||||
p.xtake(".")
|
||||
b, bs := ip4num()
|
||||
p.xtake(".")
|
||||
c, cs := ip4num()
|
||||
p.xtake(".")
|
||||
d, ds := ip4num()
|
||||
return net.IPv4(a, b, c, d), as + "." + bs + "." + cs + "." + ds
|
||||
}
|
||||
|
||||
func (p *parser) xip6address() (net.IP, string) {
|
||||
// ../rfc/7208:2614
|
||||
// We just take in a string that has characters that IPv6 uses, then parse it.
|
||||
s := p.xtakefn1(func(c rune, i int) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' || c == ':' || c == '.'
|
||||
})
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
p.xerrorf("ip6 address %q not valid", s)
|
||||
}
|
||||
return ip, s
|
||||
}
|
138
spf/parse_test.go
Normal file
138
spf/parse_test.go
Normal file
@ -0,0 +1,138 @@
|
||||
package spf
|
||||
|
||||
import (
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
intptr := func(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
mustParseIP := func(s string) net.IP {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad ip %q", s)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
test := func(txt string, expRecord *Record) {
|
||||
t.Helper()
|
||||
valid := expRecord != nil
|
||||
r, _, err := ParseRecord(txt)
|
||||
if valid && err != nil {
|
||||
t.Fatalf("expected success, got err %s, txt %q", err, txt)
|
||||
}
|
||||
if !valid && err == nil {
|
||||
t.Fatalf("expected error, got record %#v, txt %q", r, txt)
|
||||
}
|
||||
if valid && !reflect.DeepEqual(r, expRecord) {
|
||||
t.Fatalf("unexpected record:\ngot: %v\nexpected: %v, txt %q", r, expRecord, txt)
|
||||
}
|
||||
}
|
||||
|
||||
test("", nil)
|
||||
test("v=spf1", &Record{Version: "spf1"})
|
||||
test("v=SPF1", &Record{Version: "spf1"})
|
||||
test("V=spf1 ", &Record{Version: "spf1"})
|
||||
test("V=spf1 all Include:example.org a ?a -a +a ~a a:x a:x/0 a:x/24//64 a:x//64 mx mx:x ptr ptr:x IP4:10.0.0.1 ip4:0.0.0.0/0 ip4:10.0.0.1/24 ip6:2001:db8::1 ip6:2001:db8::1/128 exists:x REDIRECT=x exp=X Other=x",
|
||||
&Record{
|
||||
Version: "spf1",
|
||||
Directives: []Directive{
|
||||
{Mechanism: "all"},
|
||||
{Mechanism: "include", DomainSpec: "example.org"},
|
||||
{Mechanism: "a"},
|
||||
{Qualifier: "?", Mechanism: "a"},
|
||||
{Qualifier: "-", Mechanism: "a"},
|
||||
{Qualifier: "+", Mechanism: "a"},
|
||||
{Qualifier: "~", Mechanism: "a"},
|
||||
{Mechanism: "a", DomainSpec: "x"},
|
||||
{Mechanism: "a", DomainSpec: "x", IP4CIDRLen: intptr(0)},
|
||||
{Mechanism: "a", DomainSpec: "x", IP4CIDRLen: intptr(24), IP6CIDRLen: intptr(64)},
|
||||
{Mechanism: "a", DomainSpec: "x", IP6CIDRLen: intptr(64)},
|
||||
{Mechanism: "mx"},
|
||||
{Mechanism: "mx", DomainSpec: "x"},
|
||||
{Mechanism: "ptr"},
|
||||
{Mechanism: "ptr", DomainSpec: "x"},
|
||||
{Mechanism: "ip4", IP: mustParseIP("10.0.0.1"), IPstr: "10.0.0.1/32"},
|
||||
{Mechanism: "ip4", IP: mustParseIP("0.0.0.0"), IPstr: "0.0.0.0/0", IP4CIDRLen: intptr(0)},
|
||||
{Mechanism: "ip4", IP: mustParseIP("10.0.0.1"), IPstr: "10.0.0.1/24", IP4CIDRLen: intptr(24)},
|
||||
{Mechanism: "ip6", IP: mustParseIP("2001:db8::1"), IPstr: "2001:db8::1/128"},
|
||||
{Mechanism: "ip6", IP: mustParseIP("2001:db8::1"), IPstr: "2001:db8::1/128", IP6CIDRLen: intptr(128)},
|
||||
{Mechanism: "exists", DomainSpec: "x"},
|
||||
},
|
||||
Redirect: "x",
|
||||
Explanation: "X",
|
||||
Other: []Modifier{
|
||||
{"Other", "x"},
|
||||
},
|
||||
},
|
||||
)
|
||||
test("V=spf1 -all", &Record{Version: "spf1", Directives: []Directive{{Qualifier: "-", Mechanism: "all"}}})
|
||||
test("v=spf1 !", nil) // Invalid character.
|
||||
test("v=spf1 ?redirect=bogus", nil)
|
||||
test("v=spf1 redirect=mox.example redirect=mox2.example", nil) // Duplicate redirect.
|
||||
test("v=spf1 exp=mox.example exp=mox2.example", nil) // Duplicate exp.
|
||||
test("v=spf1 ip4:10.0.0.256", nil) // Invalid address.
|
||||
test("v=spf1 ip6:2001:db8:::1", nil) // Invalid address.
|
||||
test("v=spf1 ip4:10.0.0.1/33", nil) // IPv4 prefix >32.
|
||||
test("v=spf1 ip6:2001:db8::1/129", nil) // IPv6 prefix >128.
|
||||
test("v=spf1 a:mox.example/33", nil) // IPv4 prefix >32.
|
||||
test("v=spf1 a:mox.example//129", nil) // IPv6 prefix >128.
|
||||
test("v=spf1 a:mox.example//129", nil) // IPv6 prefix >128.
|
||||
test("v=spf1 exists:%%.%{l1r+}.%{d}",
|
||||
&Record{
|
||||
Version: "spf1",
|
||||
Directives: []Directive{
|
||||
{Mechanism: "exists", DomainSpec: "%%.%{l1r+}.%{d}"},
|
||||
},
|
||||
},
|
||||
)
|
||||
test("v=spf1 exists:%{l1r+}..", nil) // Empty toplabel in domain-end.
|
||||
test("v=spf1 exists:%{l1r+}._.", nil) // Invalid toplabel in domain-end.
|
||||
test("v=spf1 exists:%{l1r+}.123.", nil) // Invalid toplabel in domain-end.
|
||||
test("v=spf1 exists:%{l1r+}.bad-.", nil) // Invalid toplabel in domain-end.
|
||||
test("v=spf1 exists:%{l1r+}.-bad.", nil) // Invalid toplabel in domain-end.
|
||||
test("v=spf1 exists:%{l1r+}./.", nil) // Invalid toplabel in domain-end.
|
||||
test("v=spf1 exists:%{x}", nil) // Unknown macro-letter.
|
||||
test("v=spf1 exists:%{s0}", nil) // Invalid digits.
|
||||
test("v=spf1 exists:%{ir}.%{l1r+}.%{d}",
|
||||
&Record{
|
||||
Version: "spf1",
|
||||
Directives: []Directive{
|
||||
{Mechanism: "exists", DomainSpec: "%{ir}.%{l1r+}.%{d}"},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
orig := `V=SPF1 all Include:example.org a ?a -a +a ~a a:x a:x/0 a:x/24//64 a:x//64 mx mx:x ptr ptr:x IP4:10.0.0.1 ip4:0.0.0.0/0 ip4:10.0.0.1/24 ip6:2001:db8::1 ip6:2001:db8::1/128 exists:x REDIRECT=x exp=X Other=x`
|
||||
exp := `v=spf1 all include:example.org a ?a -a +a ~a a:x a:x/0 a:x/24//64 a:x//64 mx mx:x ptr ptr:x ip4:10.0.0.1 ip4:0.0.0.0/0 ip4:10.0.0.1/24 ip6:2001:db8::1 ip6:2001:db8::1/128 exists:x redirect=x exp=X Other=x`
|
||||
r, _, err := ParseRecord(orig)
|
||||
if err != nil {
|
||||
t.Fatalf("parsing original: %s", err)
|
||||
}
|
||||
record, err := r.Record()
|
||||
if err != nil {
|
||||
t.Fatalf("making dns record: %s", err)
|
||||
}
|
||||
if record != exp {
|
||||
t.Fatalf("packing dns record, got %q, expected %q", record, exp)
|
||||
}
|
||||
}
|
||||
|
||||
func FuzzParseRecord(f *testing.F) {
|
||||
f.Add("")
|
||||
f.Add("v=spf1")
|
||||
f.Add(`V=SPF1 all Include:example.org a ?a -a +a ~a a:x a:x/0 a:x/24//64 a:x//64 mx mx:x ptr ptr:x IP4:10.0.0.1 ip4:0.0.0.0/0 ip4:10.0.0.1/24 ip6:2001:db8::1 ip6:2001:db8::1/128 exists:x REDIRECT=x exp=X Other=x`)
|
||||
f.Fuzz(func(t *testing.T, s string) {
|
||||
r, _, err := ParseRecord(s)
|
||||
if err == nil {
|
||||
if _, err := r.Record(); err != nil {
|
||||
t.Errorf("r.Record for %s, %#v: %s", s, r, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
118
spf/received.go
Normal file
118
spf/received.go
Normal file
@ -0,0 +1,118 @@
|
||||
package spf
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/message"
|
||||
)
|
||||
|
||||
// ../rfc/7208:2083
|
||||
|
||||
// Received represents a Received-SPF header with the SPF verify results, to be
|
||||
// prepended to a message.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// Received-SPF: pass (mybox.example.org: domain of
|
||||
// myname@example.com designates 192.0.2.1 as permitted sender)
|
||||
// receiver=mybox.example.org; client-ip=192.0.2.1;
|
||||
// envelope-from="myname@example.com"; helo=foo.example.com;
|
||||
type Received struct {
|
||||
Result Status
|
||||
Comment string // Additional free-form information about the verification result. Optional. Included in message header comment inside "()".
|
||||
ClientIP net.IP // IP address of remote SMTP client, "client-ip=".
|
||||
EnvelopeFrom string // Sender mailbox, typically SMTP MAIL FROM, but will be set to "postmaster" at SMTP EHLO if MAIL FROM is empty, "envelop-from=".
|
||||
Helo dns.IPDomain // IP or host name from EHLO or HELO command, "helo=".
|
||||
Problem string // Optional. "problem="
|
||||
Receiver string // Hostname of receiving mail server, "receiver=".
|
||||
Identity Identity // The identity that was checked, "mailfrom" or "helo", for "identity=".
|
||||
Mechanism string // Mechanism that caused the result, can be "default". Optional.
|
||||
}
|
||||
|
||||
// Identity that was verified.
|
||||
type Identity string
|
||||
|
||||
const (
|
||||
ReceivedMailFrom Identity = "mailfrom"
|
||||
ReceivedHELO Identity = "helo"
|
||||
)
|
||||
|
||||
func receivedValueEncode(s string) string {
|
||||
if s == "" {
|
||||
return quotedString("")
|
||||
}
|
||||
for i, c := range s {
|
||||
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c > 0x7f {
|
||||
continue
|
||||
}
|
||||
// ../rfc/5322:679
|
||||
const atext = "!#$%&'*+-/=?^_`{|}~"
|
||||
if strings.IndexByte(atext, byte(c)) >= 0 {
|
||||
continue
|
||||
}
|
||||
if c != '.' || (i == 0 || i+1 == len(s)) {
|
||||
return quotedString(s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ../rfc/5322:736
|
||||
func quotedString(s string) string {
|
||||
w := &strings.Builder{}
|
||||
w.WriteByte('"')
|
||||
for _, c := range s {
|
||||
if c > ' ' && c < 0x7f && c != '"' && c != '\\' || c > 0x7f || c == ' ' || c == '\t' {
|
||||
// We allow utf-8. This should only be needed when the destination address has an
|
||||
// utf8 localpart, in which case we are already doing smtputf8.
|
||||
// We also allow unescaped space and tab. This is FWS, and the name of ABNF
|
||||
// production "qcontent" implies the FWS is not part of the string, but escaping
|
||||
// space and tab leads to ugly strings. ../rfc/5322:743
|
||||
w.WriteRune(c)
|
||||
continue
|
||||
}
|
||||
switch c {
|
||||
case ' ', '\t', '"', '\\':
|
||||
w.WriteByte('\\')
|
||||
w.WriteRune(c)
|
||||
}
|
||||
}
|
||||
w.WriteByte('"')
|
||||
return w.String()
|
||||
}
|
||||
|
||||
// Header returns a Received-SPF header line including trailing crlf that can
|
||||
// be prepended to an incoming message.
|
||||
func (r Received) Header() string {
|
||||
// ../rfc/7208:2043
|
||||
w := &message.HeaderWriter{}
|
||||
w.Add("", "Received-SPF: "+string(r.Result))
|
||||
if r.Comment != "" {
|
||||
w.Add(" ", "("+r.Comment+")")
|
||||
}
|
||||
w.Addf(" ", "client-ip=%s;", receivedValueEncode(r.ClientIP.String()))
|
||||
w.Addf(" ", "envelope-from=%s;", receivedValueEncode(r.EnvelopeFrom))
|
||||
var helo string
|
||||
if len(r.Helo.IP) > 0 {
|
||||
helo = r.Helo.IP.String()
|
||||
} else {
|
||||
helo = r.Helo.Domain.ASCII
|
||||
}
|
||||
w.Addf(" ", "helo=%s;", receivedValueEncode(helo))
|
||||
if r.Problem != "" {
|
||||
s := r.Problem
|
||||
max := 77 - len("problem=; ")
|
||||
if len(s) > max {
|
||||
s = s[:max]
|
||||
}
|
||||
w.Addf(" ", "problem=%s;", receivedValueEncode(s))
|
||||
}
|
||||
if r.Mechanism != "" {
|
||||
w.Addf(" ", "mechanism=%s;", receivedValueEncode(r.Mechanism))
|
||||
}
|
||||
w.Addf(" ", "receiver=%s;", receivedValueEncode(r.Receiver))
|
||||
w.Addf(" ", "identity=%s", receivedValueEncode(string(r.Identity)))
|
||||
return w.String()
|
||||
}
|
39
spf/received_test.go
Normal file
39
spf/received_test.go
Normal file
@ -0,0 +1,39 @@
|
||||
package spf
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func TestReceived(t *testing.T) {
|
||||
test := func(r Received, exp string) {
|
||||
t.Helper()
|
||||
s := r.Header()
|
||||
if s != exp {
|
||||
t.Fatalf("got %q, expected %q", s, exp)
|
||||
}
|
||||
}
|
||||
|
||||
test(Received{
|
||||
Result: StatusPass,
|
||||
Comment: "c",
|
||||
ClientIP: net.ParseIP("0.0.0.0"),
|
||||
EnvelopeFrom: "x@x",
|
||||
Helo: dns.IPDomain{Domain: dns.Domain{ASCII: "y"}},
|
||||
Problem: `a b"\`,
|
||||
Receiver: "z",
|
||||
Identity: ReceivedMailFrom,
|
||||
Mechanism: "+ip4:0.0.0.0/0",
|
||||
}, "Received-SPF: pass (c) client-ip=0.0.0.0; envelope-from=\"x@x\"; helo=y;\r\n\tproblem=\"a b\\\"\\\\\"; mechanism=\"+ip4:0.0.0.0/0\"; receiver=z; identity=mailfrom\r\n")
|
||||
|
||||
test(Received{
|
||||
Result: StatusPass,
|
||||
ClientIP: net.ParseIP("0.0.0.0"),
|
||||
EnvelopeFrom: "x@x",
|
||||
Helo: dns.IPDomain{IP: net.ParseIP("2001:db8::1")},
|
||||
Receiver: "z",
|
||||
Identity: ReceivedMailFrom,
|
||||
}, "Received-SPF: pass client-ip=0.0.0.0; envelope-from=\"x@x\"; helo=\"2001:db8::1\";\r\n\treceiver=z; identity=mailfrom\r\n")
|
||||
}
|
958
spf/spf.go
Normal file
958
spf/spf.go
Normal file
@ -0,0 +1,958 @@
|
||||
// Package spf implements Sender Policy Framework (SPF, RFC 7208) for verifying
|
||||
// remote mail server IPs with their published records.
|
||||
//
|
||||
// With SPF a domain can publish a policy as a DNS TXT record describing which IPs
|
||||
// are allowed to send email with SMTP with the domain in the MAIL FROM command,
|
||||
// and how to treat SMTP transactions coming from other IPs.
|
||||
package spf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
)
|
||||
|
||||
// The net package always returns DNS names in absolute, lower-case form. We make
|
||||
// sure we make names absolute when looking up. For verifying, we do not want to
|
||||
// verify names relative to our local search domain.
|
||||
|
||||
var xlog = mlog.New("spf")
|
||||
|
||||
var (
|
||||
metricSPFVerify = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "mox_spf_verify_duration_seconds",
|
||||
Help: "SPF verify, including lookup, duration and result.",
|
||||
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
|
||||
},
|
||||
[]string{
|
||||
"status",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
// cross-link rfc and errata
|
||||
// ../rfc/7208-eid5436 ../rfc/7208:2043
|
||||
// ../rfc/7208-eid6721 ../rfc/7208:1928
|
||||
// ../rfc/7208-eid5227 ../rfc/7208:1297
|
||||
// ../rfc/7208-eid6595 ../rfc/7208:984
|
||||
|
||||
var (
|
||||
// Lookup errors.
|
||||
ErrName = errors.New("spf: bad domain name")
|
||||
ErrNoRecord = errors.New("spf: no txt record")
|
||||
ErrMultipleRecords = errors.New("spf: multiple spf txt records in dns")
|
||||
ErrDNS = errors.New("spf: lookup of dns record")
|
||||
ErrRecordSyntax = errors.New("spf: malformed spf txt record")
|
||||
|
||||
// Evaluation errors.
|
||||
ErrTooManyDNSRequests = errors.New("spf: too many dns requests")
|
||||
ErrTooManyVoidLookups = errors.New("spf: too many void lookups")
|
||||
ErrMacroSyntax = errors.New("spf: bad macro syntax")
|
||||
)
|
||||
|
||||
const (
|
||||
// Maximum number of DNS requests to execute. This excludes some requests, such as
|
||||
// lookups of MX host results.
|
||||
dnsRequestsMax = 10
|
||||
|
||||
// Maximum number of DNS lookups that result in no records before a StatusPermerror
|
||||
// is returned. This limit aims to prevent abuse.
|
||||
voidLookupsMax = 2
|
||||
)
|
||||
|
||||
// Status is the result of an SPF verification.
|
||||
type Status string
|
||||
|
||||
// ../rfc/7208:517
|
||||
// ../rfc/7208:1836
|
||||
|
||||
const (
|
||||
StatusNone Status = "none" // E.g. no DNS domain name in session, or no SPF record in DNS.
|
||||
StatusNeutral Status = "neutral" // Explicit statement that nothing is said about the IP, "?" qualifier. None and Neutral must be treated the same.
|
||||
StatusPass Status = "pass" // IP is authorized.
|
||||
StatusFail Status = "fail" // IP is exlicitly not authorized. "-" qualifier.
|
||||
StatusSoftfail Status = "softfail" // Weak statement that IP is probably not authorized, "~" qualifier.
|
||||
StatusTemperror Status = "temperror" // Trying again later may succeed, e.g. for temporary DNS lookup error.
|
||||
StatusPermerror Status = "permerror" // Error requiring some intervention to correct. E.g. invalid DNS record.
|
||||
)
|
||||
|
||||
// Args are the parameters to the SPF verification algorithm ("check_host" in the RFC).
|
||||
//
|
||||
// All fields should be set as they can be required for macro expansions.
|
||||
type Args struct {
|
||||
// RemoteIP will be checked as sender for email.
|
||||
RemoteIP net.IP
|
||||
|
||||
// Address from SMTP MAIL FROM command. Zero values for a null reverse path (used for DSNs).
|
||||
MailFromLocalpart smtp.Localpart
|
||||
MailFromDomain dns.Domain
|
||||
|
||||
// HelloDomain is from the SMTP EHLO/HELO command.
|
||||
HelloDomain dns.IPDomain
|
||||
|
||||
LocalIP net.IP
|
||||
LocalHostname dns.Domain
|
||||
|
||||
// Explanation string to use for failure. In case of "include", where explanation
|
||||
// from original domain must be used.
|
||||
// May be set for recursive calls.
|
||||
explanation *string
|
||||
|
||||
// Domain to validate.
|
||||
domain dns.Domain
|
||||
|
||||
// Effective sender. Equal to MailFrom if non-zero, otherwise set to "postmaster" at HelloDomain.
|
||||
senderLocalpart smtp.Localpart
|
||||
senderDomain dns.Domain
|
||||
|
||||
// To enforce the limit on lookups. Initialized automatically if nil.
|
||||
dnsRequests *int
|
||||
voidLookups *int
|
||||
}
|
||||
|
||||
// Mocked for testing expanding "t" macro.
|
||||
var timeNow = time.Now
|
||||
|
||||
// Lookup looks up and parses an SPF TXT record for domain.
|
||||
func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debugx("spf lookup result", rerr, mlog.Field("domain", domain), mlog.Field("status", rstatus), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
// ../rfc/7208:586
|
||||
host := domain.ASCII + "."
|
||||
if err := validateDNS(host); err != nil {
|
||||
return StatusNone, "", nil, fmt.Errorf("%w: %s: %s", ErrName, domain, err)
|
||||
}
|
||||
|
||||
// Lookup spf record.
|
||||
txts, err := dns.WithPackage(resolver, "spf").LookupTXT(ctx, host)
|
||||
if dns.IsNotFound(err) {
|
||||
return StatusNone, "", nil, fmt.Errorf("%w for %s", ErrNoRecord, host)
|
||||
} else if err != nil {
|
||||
return StatusTemperror, "", nil, fmt.Errorf("%w: %s: %s", ErrDNS, host, err)
|
||||
}
|
||||
|
||||
// Parse the records. We only handle those that look like spf records.
|
||||
var record *Record
|
||||
var text string
|
||||
for _, txt := range txts {
|
||||
var isspf bool
|
||||
r, isspf, err := ParseRecord(txt)
|
||||
if !isspf {
|
||||
// ../rfc/7208:595
|
||||
continue
|
||||
} else if err != nil {
|
||||
// ../rfc/7208:852
|
||||
return StatusPermerror, txt, nil, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
|
||||
}
|
||||
if record != nil {
|
||||
// ../rfc/7208:576
|
||||
return StatusPermerror, "", nil, ErrMultipleRecords
|
||||
}
|
||||
text = txt
|
||||
record = r
|
||||
}
|
||||
if record == nil {
|
||||
// ../rfc/7208:837
|
||||
return StatusNone, "", nil, ErrNoRecord
|
||||
}
|
||||
return StatusNone, text, record, nil
|
||||
}
|
||||
|
||||
// Verify checks if a remote IP is allowed to send email for a domain.
|
||||
//
|
||||
// If the SMTP "MAIL FROM" is set, it is used as identity (domain) to verify.
|
||||
// Otherwise, the EHLO domain is verified if it is a valid domain.
|
||||
//
|
||||
// The returned Received.Result status will always be set, regardless of whether an
|
||||
// error is returned.
|
||||
// For status Temperror and Permerror, an error is always returned.
|
||||
// For Fail, explanation may be set, and should be returned in the SMTP session if
|
||||
// it is the reason the message is rejected. The caller should ensure the
|
||||
// explanation is valid for use in SMTP, taking line length and ascii-only
|
||||
// requirement into account.
|
||||
//
|
||||
// Verify takes the maximum number of 10 DNS requests into account, and the maximum
|
||||
// of 2 lookups resulting in no records ("void lookups").
|
||||
func Verify(ctx context.Context, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricSPFVerify.WithLabelValues(string(received.Result)).Observe(float64(time.Since(start)) / float64(time.Second))
|
||||
log.Debugx("spf verify result", rerr, mlog.Field("domain", args.domain), mlog.Field("ip", args.RemoteIP), mlog.Field("status", received.Result), mlog.Field("explanation", explanation), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
isHello, ok := prepare(&args)
|
||||
if !ok {
|
||||
received = Received{
|
||||
Result: StatusNone,
|
||||
Comment: "no domain, ehlo is an ip literal and mailfrom is empty",
|
||||
ClientIP: args.RemoteIP,
|
||||
EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.HelloDomain.IP.String()),
|
||||
Helo: args.HelloDomain,
|
||||
Receiver: args.LocalHostname.ASCII,
|
||||
}
|
||||
return received, dns.Domain{}, "", nil
|
||||
}
|
||||
|
||||
status, mechanism, expl, err := checkHost(ctx, resolver, args)
|
||||
comment := fmt.Sprintf("domain %s", args.domain.ASCII)
|
||||
if isHello {
|
||||
comment += ", from ehlo because mailfrom is empty"
|
||||
}
|
||||
received = Received{
|
||||
Result: status,
|
||||
Comment: comment,
|
||||
ClientIP: args.RemoteIP,
|
||||
EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.senderDomain.ASCII), // ../rfc/7208:2090, explicitly "sender", not "mailfrom".
|
||||
Helo: args.HelloDomain,
|
||||
Receiver: args.LocalHostname.ASCII,
|
||||
Mechanism: mechanism,
|
||||
}
|
||||
if err != nil {
|
||||
received.Problem = err.Error()
|
||||
}
|
||||
if isHello {
|
||||
received.Identity = "helo"
|
||||
} else {
|
||||
received.Identity = "mailfrom"
|
||||
}
|
||||
return received, args.domain, expl, err
|
||||
}
|
||||
|
||||
// prepare args, setting fields sender* and domain as required for checkHost.
|
||||
func prepare(args *Args) (isHello bool, ok bool) {
|
||||
// If MAIL FROM is set, that identity is used. Otherwise the EHLO identity is used.
|
||||
// MAIL FROM is preferred, because if we accept the message, and we have to send a
|
||||
// DSN, it helps to know it is a verified sender. If we would check an EHLO
|
||||
// identity, and it is different from the MAIL FROM, we may be sending the DSN to
|
||||
// an address with a domain that would not allow sending from the originating IP.
|
||||
// The RFC seems a bit confused, ../rfc/7208:778 implies MAIL FROM is preferred,
|
||||
// but ../rfc/7208:424 mentions that a MAIL FROM check can be avoided by first
|
||||
// doing HELO.
|
||||
|
||||
args.explanation = nil
|
||||
args.dnsRequests = nil
|
||||
args.voidLookups = nil
|
||||
if args.MailFromDomain.IsZero() {
|
||||
// If there is on EHLO, and it is an IP, there is nothing to SPF-validate.
|
||||
if !args.HelloDomain.IsDomain() {
|
||||
return false, false
|
||||
}
|
||||
// If we have a mailfrom, we also have a localpart. But for EHLO we won't. ../rfc/7208:810
|
||||
args.senderLocalpart = "postmaster"
|
||||
args.senderDomain = args.HelloDomain.Domain
|
||||
isHello = true
|
||||
} else {
|
||||
args.senderLocalpart = args.MailFromLocalpart
|
||||
args.senderDomain = args.MailFromDomain
|
||||
}
|
||||
args.domain = args.senderDomain
|
||||
return isHello, true
|
||||
}
|
||||
|
||||
// lookup spf record, then evaluate args against it.
|
||||
func checkHost(ctx context.Context, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rerr error) {
|
||||
status, _, record, err := Lookup(ctx, resolver, args.domain)
|
||||
if err != nil {
|
||||
return status, "", "", err
|
||||
}
|
||||
|
||||
return evaluate(ctx, record, resolver, args)
|
||||
}
|
||||
|
||||
// Evaluate evaluates the IP and names from args against the SPF DNS record for the domain.
|
||||
func Evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rerr error) {
|
||||
_, ok := prepare(&args)
|
||||
if !ok {
|
||||
return StatusNone, "default", "", fmt.Errorf("no domain name to validate")
|
||||
}
|
||||
return evaluate(ctx, record, resolver, args)
|
||||
}
|
||||
|
||||
// evaluate RemoteIP against domain from args, given record.
|
||||
func evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debugx("spf evaluate result", rerr, mlog.Field("dnsrequests", *args.dnsRequests), mlog.Field("voidLookups", *args.voidLookups), mlog.Field("domain", args.domain), mlog.Field("status", rstatus), mlog.Field("mechanism", mechanism), mlog.Field("explanation", rexplanation), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
resolver = dns.WithPackage(resolver, "spf")
|
||||
|
||||
if args.dnsRequests == nil {
|
||||
args.dnsRequests = new(int)
|
||||
args.voidLookups = new(int)
|
||||
}
|
||||
|
||||
// To4 returns nil for an IPv6 address. To16 will return an IPv4-to-IPv6-mapped address.
|
||||
var remote6 net.IP
|
||||
remote4 := args.RemoteIP.To4()
|
||||
if remote4 == nil {
|
||||
remote6 = args.RemoteIP.To16()
|
||||
}
|
||||
|
||||
// Check if ip matches remote ip, taking cidr mask into account.
|
||||
checkIP := func(ip net.IP, d Directive) bool {
|
||||
// ../rfc/7208:1097
|
||||
if remote4 != nil {
|
||||
ip4 := ip.To4()
|
||||
if ip4 == nil {
|
||||
return false
|
||||
}
|
||||
ones := 32
|
||||
if d.IP4CIDRLen != nil {
|
||||
ones = *d.IP4CIDRLen
|
||||
}
|
||||
mask := net.CIDRMask(ones, 32)
|
||||
return ip4.Mask(mask).Equal(remote4.Mask(mask))
|
||||
}
|
||||
|
||||
ip6 := ip.To16()
|
||||
if ip6 == nil {
|
||||
return false
|
||||
}
|
||||
ones := 128
|
||||
if d.IP6CIDRLen != nil {
|
||||
ones = *d.IP6CIDRLen
|
||||
}
|
||||
mask := net.CIDRMask(ones, 128)
|
||||
return ip6.Mask(mask).Equal(remote6.Mask(mask))
|
||||
}
|
||||
|
||||
// Used for "a" and "mx".
|
||||
checkHostIP := func(domain dns.Domain, d Directive, args *Args) (bool, Status, error) {
|
||||
network := "ip4"
|
||||
if remote6 != nil {
|
||||
network = "ip6"
|
||||
}
|
||||
ips, err := resolver.LookupIP(ctx, network, domain.ASCII+".")
|
||||
trackVoidLookup(err, args)
|
||||
// If "not found", we must ignore the error and treat as zero records in answer. ../rfc/7208:1116
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return false, StatusTemperror, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if checkIP(ip, d) {
|
||||
return true, StatusPass, nil
|
||||
}
|
||||
}
|
||||
return false, StatusNone, nil
|
||||
}
|
||||
|
||||
for _, d := range record.Directives {
|
||||
var match bool
|
||||
|
||||
switch d.Mechanism {
|
||||
case "include", "a", "mx", "ptr", "exists":
|
||||
if err := trackLookupLimits(&args); err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", err
|
||||
}
|
||||
}
|
||||
|
||||
switch d.Mechanism {
|
||||
case "all":
|
||||
// ../rfc/7208:1127
|
||||
match = true
|
||||
|
||||
case "include":
|
||||
// ../rfc/7208:1143
|
||||
name, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
|
||||
if err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", fmt.Errorf("expanding domain-spec for include: %w", err)
|
||||
}
|
||||
nargs := args
|
||||
nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
|
||||
nargs.explanation = &record.Explanation // ../rfc/7208:1548
|
||||
status, _, _, err := checkHost(ctx, resolver, nargs)
|
||||
// ../rfc/7208:1202
|
||||
switch status {
|
||||
case StatusPass:
|
||||
match = true
|
||||
case StatusTemperror:
|
||||
return StatusTemperror, d.MechanismString(), "", fmt.Errorf("include %q: %w", name, err)
|
||||
case StatusPermerror, StatusNone:
|
||||
return StatusPermerror, d.MechanismString(), "", fmt.Errorf("include %q resulted in status %q: %w", name, status, err)
|
||||
}
|
||||
|
||||
case "a":
|
||||
// ../rfc/7208:1249
|
||||
// note: the syntax for DomainSpec hints that macros should be expanded. But
|
||||
// expansion is explicitly documented, and only for "include", "exists" and
|
||||
// "redirect". This reason for this could be low-effort reuse of the domain-spec
|
||||
// ABNF rule. It could be an oversight. We are not implementing expansion for the
|
||||
// mechanism for which it isn't specified.
|
||||
host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
|
||||
if err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", err
|
||||
}
|
||||
hmatch, status, err := checkHostIP(host, d, &args)
|
||||
if err != nil {
|
||||
return status, d.MechanismString(), "", err
|
||||
}
|
||||
match = hmatch
|
||||
|
||||
case "mx":
|
||||
// ../rfc/7208:1262
|
||||
host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
|
||||
if err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", err
|
||||
}
|
||||
// Note: LookupMX can return an error and still return MX records.
|
||||
mxs, err := resolver.LookupMX(ctx, host.ASCII+".")
|
||||
trackVoidLookup(err, &args)
|
||||
// note: we handle "not found" simply as a result of zero mx records.
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return StatusTemperror, d.MechanismString(), "", err
|
||||
}
|
||||
if err == nil && len(mxs) == 1 && mxs[0].Host == "." {
|
||||
// Explicitly no MX.
|
||||
break
|
||||
}
|
||||
for i, mx := range mxs {
|
||||
// ../rfc/7208:947 says that each mx record cannot result in more than 10 DNS
|
||||
// requests. This seems independent of the overall limit of 10 DNS requests. So an
|
||||
// MX request resulting in 11 names is valid, but we must return a permerror if we
|
||||
// found no match before the 11th name.
|
||||
// ../rfc/7208:945
|
||||
if i >= 10 {
|
||||
return StatusPermerror, d.MechanismString(), "", ErrTooManyDNSRequests
|
||||
}
|
||||
mxd, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
|
||||
if err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", err
|
||||
}
|
||||
hmatch, status, err := checkHostIP(mxd, d, &args)
|
||||
if err != nil {
|
||||
return status, d.MechanismString(), "", err
|
||||
}
|
||||
if hmatch {
|
||||
match = hmatch
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
case "ptr":
|
||||
// ../rfc/7208:1281
|
||||
host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
|
||||
if err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", err
|
||||
}
|
||||
|
||||
rnames, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
|
||||
trackVoidLookup(err, &args)
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return StatusTemperror, d.MechanismString(), "", err
|
||||
}
|
||||
lookups := 0
|
||||
ptrnames:
|
||||
for _, rname := range rnames {
|
||||
rd, err := dns.ParseDomain(strings.TrimSuffix(rname, "."))
|
||||
if err != nil {
|
||||
log.Errorx("bad address in ptr record", err, mlog.Field("address", rname))
|
||||
continue
|
||||
}
|
||||
// ../rfc/7208-eid4751 ../rfc/7208:1323
|
||||
if rd.ASCII != host.ASCII && !strings.HasSuffix(rd.ASCII, "."+host.ASCII) {
|
||||
continue
|
||||
}
|
||||
|
||||
// ../rfc/7208:963, we must ignore entries after the first 10.
|
||||
if lookups >= 10 {
|
||||
break
|
||||
}
|
||||
lookups++
|
||||
network := "ip4"
|
||||
if remote6 != nil {
|
||||
network = "ip6"
|
||||
}
|
||||
ips, err := resolver.LookupIP(ctx, network, rd.ASCII+".")
|
||||
trackVoidLookup(err, &args)
|
||||
for _, ip := range ips {
|
||||
if checkIP(ip, d) {
|
||||
match = true
|
||||
break ptrnames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ../rfc/7208:1351
|
||||
case "ip4":
|
||||
if remote4 != nil {
|
||||
match = checkIP(d.IP, d)
|
||||
}
|
||||
case "ip6":
|
||||
if remote6 != nil {
|
||||
match = checkIP(d.IP, d)
|
||||
}
|
||||
|
||||
case "exists":
|
||||
// ../rfc/7208:1382
|
||||
name, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
|
||||
if err != nil {
|
||||
return StatusPermerror, d.MechanismString(), "", fmt.Errorf("expanding domain-spec for exists: %w", err)
|
||||
}
|
||||
|
||||
ips, err := resolver.LookupIP(ctx, "ip4", ensureAbsDNS(name))
|
||||
// Note: we do count this for void lookups, as that is an anti-abuse mechanism.
|
||||
// ../rfc/7208:1382 does not say anything special, so ../rfc/7208:984 applies.
|
||||
trackVoidLookup(err, &args)
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return StatusTemperror, d.MechanismString(), "", err
|
||||
}
|
||||
match = len(ips) > 0
|
||||
|
||||
default:
|
||||
return StatusNone, d.MechanismString(), "", fmt.Errorf("internal error, unexpected mechanism %q", d.Mechanism)
|
||||
}
|
||||
|
||||
if !match {
|
||||
continue
|
||||
}
|
||||
switch d.Qualifier {
|
||||
case "", "+":
|
||||
return StatusPass, d.MechanismString(), "", nil
|
||||
case "?":
|
||||
return StatusNeutral, d.MechanismString(), "", nil
|
||||
case "-":
|
||||
nargs := args
|
||||
// ../rfc/7208:1489
|
||||
expl := explanation(ctx, resolver, record, nargs)
|
||||
return StatusFail, d.MechanismString(), expl, nil
|
||||
case "~":
|
||||
return StatusSoftfail, d.MechanismString(), "", nil
|
||||
}
|
||||
return StatusNone, d.MechanismString(), "", fmt.Errorf("internal error, unexpected qualifier %q", d.Qualifier)
|
||||
}
|
||||
|
||||
if record.Redirect != "" {
|
||||
// We only know "redirect" for evaluating purposes, ignoring any others. ../rfc/7208:1423
|
||||
|
||||
// ../rfc/7208:1440
|
||||
name, err := expandDomainSpecDNS(ctx, resolver, record.Redirect, args)
|
||||
if err != nil {
|
||||
return StatusPermerror, "", "", fmt.Errorf("expanding domain-spec: %w", err)
|
||||
}
|
||||
nargs := args
|
||||
nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
|
||||
nargs.explanation = nil // ../rfc/7208:1548
|
||||
status, mechanism, expl, err := checkHost(ctx, resolver, nargs)
|
||||
if status == StatusNone {
|
||||
return StatusPermerror, mechanism, "", err
|
||||
}
|
||||
return status, mechanism, expl, err
|
||||
}
|
||||
|
||||
// ../rfc/7208:996 ../rfc/7208:2095
|
||||
return StatusNeutral, "default", "", nil
|
||||
}
|
||||
|
||||
// evaluateDomainSpec returns the parsed dns domain for spec if non-empty, and
|
||||
// otherwise returns d, which must be the Domain in checkHost Args.
|
||||
func evaluateDomainSpec(spec string, d dns.Domain) (dns.Domain, error) {
|
||||
// ../rfc/7208:1037
|
||||
if spec == "" {
|
||||
return d, nil
|
||||
}
|
||||
d, err := dns.ParseDomain(spec)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("%w: %s", ErrName, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func expandDomainSpecDNS(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, error) {
|
||||
return expandDomainSpec(ctx, resolver, domainSpec, args, true)
|
||||
}
|
||||
|
||||
func expandDomainSpecExp(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, error) {
|
||||
return expandDomainSpec(ctx, resolver, domainSpec, args, false)
|
||||
}
|
||||
|
||||
// expandDomainSpec interprets macros in domainSpec.
|
||||
// The expansion can fail due to macro syntax errors or DNS errors.
|
||||
// Caller should typically treat failures as StatusPermerror. ../rfc/7208:1641
|
||||
// ../rfc/7208:1639
|
||||
// ../rfc/7208:1047
|
||||
func expandDomainSpec(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args, dns bool) (string, error) {
|
||||
exp := !dns
|
||||
|
||||
s := domainSpec
|
||||
|
||||
b := &strings.Builder{}
|
||||
i := 0
|
||||
n := len(s)
|
||||
for i < n {
|
||||
c := s[i]
|
||||
i++
|
||||
if c != '%' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
|
||||
if i >= n {
|
||||
return "", fmt.Errorf("%w: trailing bare %%", ErrMacroSyntax)
|
||||
}
|
||||
c = s[i]
|
||||
i++
|
||||
if c == '%' {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
} else if c == '_' {
|
||||
b.WriteByte(' ')
|
||||
continue
|
||||
} else if c == '-' {
|
||||
b.WriteString("%20")
|
||||
continue
|
||||
} else if c != '{' {
|
||||
return "", fmt.Errorf("%w: invalid macro opening %%%c", ErrMacroSyntax, c)
|
||||
}
|
||||
|
||||
if i >= n {
|
||||
return "", fmt.Errorf("%w: missing macro ending }", ErrMacroSyntax)
|
||||
}
|
||||
c = s[i]
|
||||
i++
|
||||
|
||||
upper := false
|
||||
if c >= 'A' && c <= 'Z' {
|
||||
upper = true
|
||||
c += 'a' - 'A'
|
||||
}
|
||||
|
||||
var v string
|
||||
switch c {
|
||||
case 's':
|
||||
// todo: should we check for utf8 in localpart, and fail? we may now generate utf8 strings to places that may not be able to parse them. it will probably lead to relatively harmless error somewhere else. perhaps we can just transform the localpart to IDN? because it may be used in a dns lookup. ../rfc/7208:1507
|
||||
v = smtp.NewAddress(args.senderLocalpart, args.senderDomain).String()
|
||||
case 'l':
|
||||
// todo: same about utf8 as for 's'.
|
||||
v = string(args.senderLocalpart)
|
||||
case 'o':
|
||||
v = args.senderDomain.ASCII
|
||||
case 'd':
|
||||
v = args.domain.ASCII
|
||||
case 'i':
|
||||
v = expandIP(args.RemoteIP)
|
||||
case 'p':
|
||||
// ../rfc/7208:937
|
||||
if err := trackLookupLimits(&args); err != nil {
|
||||
return "", err
|
||||
}
|
||||
names, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
|
||||
trackVoidLookup(err, &args)
|
||||
if len(names) == 0 || err != nil {
|
||||
// ../rfc/7208:1709
|
||||
v = "unknown"
|
||||
break
|
||||
}
|
||||
|
||||
// Verify finds the first dns name that resolves to the remote ip.
|
||||
verify := func(matchfn func(string) bool) (string, error) {
|
||||
for _, name := range names {
|
||||
if !matchfn(name) {
|
||||
continue
|
||||
}
|
||||
network := "ip4"
|
||||
if args.RemoteIP.To4() == nil {
|
||||
network = "ip6"
|
||||
}
|
||||
ips, err := resolver.LookupIP(ctx, network, name)
|
||||
trackVoidLookup(err, &args)
|
||||
// ../rfc/7208:1714, we don't have to check other errors.
|
||||
for _, ip := range ips {
|
||||
if ip.Equal(args.RemoteIP) {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// First exact domain name matches, then subdomains, finally other names.
|
||||
domain := args.domain.ASCII + "."
|
||||
dotdomain := "." + domain
|
||||
v, err = verify(func(name string) bool { return name == domain })
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if v == "" {
|
||||
v, err = verify(func(name string) bool { return strings.HasSuffix(name, dotdomain) })
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if v == "" {
|
||||
v, err = verify(func(name string) bool { return name != domain && !strings.HasSuffix(name, dotdomain) })
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if v == "" {
|
||||
// ../rfc/7208:1709
|
||||
v = "unknown"
|
||||
}
|
||||
|
||||
case 'v':
|
||||
if args.RemoteIP.To4() != nil {
|
||||
v = "in-addr"
|
||||
} else {
|
||||
v = "ip6"
|
||||
}
|
||||
case 'h':
|
||||
if args.HelloDomain.IsIP() {
|
||||
// ../rfc/7208:1621 explicitly says "domain", not "ip". We'll handle IP, probably does no harm.
|
||||
v = expandIP(args.HelloDomain.IP)
|
||||
} else {
|
||||
v = args.HelloDomain.Domain.ASCII
|
||||
}
|
||||
case 'c', 'r', 't':
|
||||
if !exp {
|
||||
return "", fmt.Errorf("%w: macro letter %c only allowed in exp", ErrMacroSyntax, c)
|
||||
}
|
||||
switch c {
|
||||
case 'c':
|
||||
v = args.LocalIP.String()
|
||||
case 'r':
|
||||
v = args.LocalHostname.ASCII
|
||||
case 't':
|
||||
v = fmt.Sprintf("%d", timeNow().Unix())
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("%w: unknown macro letter %c", ErrMacroSyntax, c)
|
||||
}
|
||||
|
||||
digits := ""
|
||||
for i < n && s[i] >= '0' && s[i] <= '9' {
|
||||
digits += string(s[i])
|
||||
i++
|
||||
}
|
||||
nlabels := -1
|
||||
if digits != "" {
|
||||
v, err := strconv.Atoi(digits)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: bad macro transformer digits %q: %s", ErrMacroSyntax, digits, err)
|
||||
}
|
||||
nlabels = v
|
||||
if nlabels == 0 {
|
||||
return "", fmt.Errorf("%w: zero labels for digits transformer", ErrMacroSyntax)
|
||||
}
|
||||
}
|
||||
|
||||
// If "r" follows, we must reverse the resulting name, splitting on a dot by default.
|
||||
// ../rfc/7208:1655
|
||||
reverse := false
|
||||
if i < n && (s[i] == 'r' || s[i] == 'R') {
|
||||
reverse = true
|
||||
i++
|
||||
}
|
||||
|
||||
// Delimiters to split on, for subset of labels and/or reversing.
|
||||
delim := ""
|
||||
for i < n {
|
||||
switch s[i] {
|
||||
case '.', '-', '+', ',', '/', '_', '=':
|
||||
delim += string(s[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if i >= n || s[i] != '}' {
|
||||
return "", fmt.Errorf("%w: missing closing } for macro", ErrMacroSyntax)
|
||||
}
|
||||
i++
|
||||
|
||||
// Only split and subset and/or reverse if necessary.
|
||||
if nlabels >= 0 || reverse || delim != "" {
|
||||
if delim == "" {
|
||||
delim = "."
|
||||
}
|
||||
t := split(v, delim)
|
||||
// ../rfc/7208:1655
|
||||
if reverse {
|
||||
nt := len(t)
|
||||
h := nt / 2
|
||||
for i := 0; i < h; i++ {
|
||||
t[i], t[nt-1-i] = t[nt-1-i], t[i]
|
||||
}
|
||||
}
|
||||
if nlabels > 0 && nlabels < len(t) {
|
||||
t = t[len(t)-nlabels:]
|
||||
}
|
||||
// Always join on dot. ../rfc/7208:1659
|
||||
v = strings.Join(t, ".")
|
||||
}
|
||||
|
||||
// ../rfc/7208:1755
|
||||
if upper {
|
||||
v = url.QueryEscape(v)
|
||||
}
|
||||
|
||||
b.WriteString(v)
|
||||
}
|
||||
r := b.String()
|
||||
if dns {
|
||||
isAbs := strings.HasSuffix(r, ".")
|
||||
r = ensureAbsDNS(r)
|
||||
if err := validateDNS(r); err != nil {
|
||||
return "", fmt.Errorf("invalid dns name: %s", err)
|
||||
}
|
||||
// If resulting name is too large, cut off labels on the left until it fits. ../rfc/7208:1749
|
||||
if len(r) > 253+1 {
|
||||
labels := strings.Split(r, ".")
|
||||
for i := range labels {
|
||||
if i == len(labels)-1 {
|
||||
return "", fmt.Errorf("expanded dns name too long")
|
||||
}
|
||||
s := strings.Join(labels[i+1:], ".")
|
||||
if len(s) <= 254 {
|
||||
r = s
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !isAbs {
|
||||
r = r[:len(r)-1]
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func expandIP(ip net.IP) string {
|
||||
ip4 := ip.To4()
|
||||
if ip4 != nil {
|
||||
return ip4.String()
|
||||
}
|
||||
v := ""
|
||||
for i, b := range ip.To16() {
|
||||
if i > 0 {
|
||||
v += "."
|
||||
}
|
||||
v += fmt.Sprintf("%x.%x", b>>4, b&0xf)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
|
||||
// check valid host names, e.g. _ is allows in DNS but not in a host name.
|
||||
func validateDNS(s string) error {
|
||||
// ../rfc/7208:800
|
||||
// note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.
|
||||
labels := strings.Split(s, ".")
|
||||
if len(labels) > 128 {
|
||||
return fmt.Errorf("more than 128 labels")
|
||||
}
|
||||
for _, label := range labels[:len(labels)-1] {
|
||||
if len(label) > 63 {
|
||||
return fmt.Errorf("label longer than 63 bytes")
|
||||
}
|
||||
|
||||
if label == "" {
|
||||
return fmt.Errorf("empty dns label")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func split(v, delim string) (r []string) {
|
||||
isdelim := func(c rune) bool {
|
||||
for _, d := range delim {
|
||||
if d == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
s := 0
|
||||
for i, c := range v {
|
||||
if isdelim(c) {
|
||||
r = append(r, v[s:i])
|
||||
s = i + 1
|
||||
}
|
||||
}
|
||||
r = append(r, v[s:])
|
||||
return r
|
||||
}
|
||||
|
||||
// explanation does a best-effort attempt to fetch an explanation for a StatusFail response.
|
||||
// If no explanation could be composed, an empty string is returned.
|
||||
func explanation(ctx context.Context, resolver dns.Resolver, r *Record, args Args) string {
|
||||
// ../rfc/7208:1485
|
||||
|
||||
// If this record is the result of an "include", we have to use the explanation
|
||||
// string of the original domain, not of this domain.
|
||||
// ../rfc/7208:1548
|
||||
expl := r.Explanation
|
||||
if args.explanation != nil {
|
||||
expl = *args.explanation
|
||||
}
|
||||
|
||||
// ../rfc/7208:1491
|
||||
if expl == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Limits for dns requests and void lookups should not be taken into account.
|
||||
// Starting with zero ensures they aren't triggered.
|
||||
args.dnsRequests = new(int)
|
||||
args.voidLookups = new(int)
|
||||
name, err := expandDomainSpecDNS(ctx, resolver, r.Explanation, args)
|
||||
if err != nil || name == "" {
|
||||
return ""
|
||||
}
|
||||
txts, err := resolver.LookupTXT(ctx, ensureAbsDNS(name))
|
||||
if err != nil || len(txts) == 0 {
|
||||
return ""
|
||||
}
|
||||
txt := strings.Join(txts, "")
|
||||
s, err := expandDomainSpecExp(ctx, resolver, txt, args)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func ensureAbsDNS(s string) string {
|
||||
if !strings.HasSuffix(s, ".") {
|
||||
return s + "."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func trackLookupLimits(args *Args) error {
|
||||
// ../rfc/7208:937
|
||||
if *args.dnsRequests >= dnsRequestsMax {
|
||||
return ErrTooManyDNSRequests
|
||||
}
|
||||
// ../rfc/7208:988
|
||||
if *args.voidLookups >= voidLookupsMax {
|
||||
return ErrTooManyVoidLookups
|
||||
}
|
||||
*args.dnsRequests++
|
||||
return nil
|
||||
}
|
||||
|
||||
func trackVoidLookup(err error, args *Args) {
|
||||
if dns.IsNotFound(err) {
|
||||
*args.voidLookups++
|
||||
}
|
||||
}
|
521
spf/spf_test.go
Normal file
521
spf/spf_test.go
Normal file
@ -0,0 +1,521 @@
|
||||
package spf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
)
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"temperror.example.": {"irrelevant"},
|
||||
"malformed.example.": {"v=spf1 !"},
|
||||
"multiple.example.": {"v=spf1", "v=spf1"},
|
||||
"nonspf.example.": {"something else"},
|
||||
"ok.example.": {"v=spf1"},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
test := func(domain string, expStatus Status, expRecord *Record, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
d := dns.Domain{ASCII: domain}
|
||||
status, txt, record, err := Lookup(context.Background(), resolver, d)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("got err %v, expected err %v", err, expErr)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if status != expStatus || txt == "" || !reflect.DeepEqual(record, expRecord) {
|
||||
t.Fatalf("got status %q, txt %q, record %#v, expected %q, ..., %#v", status, txt, record, expStatus, expRecord)
|
||||
}
|
||||
}
|
||||
|
||||
test("..", StatusNone, nil, ErrName)
|
||||
test("absent.example", StatusNone, nil, ErrNoRecord)
|
||||
test("temperror.example", StatusTemperror, nil, ErrDNS)
|
||||
test("malformed.example", StatusPermerror, nil, ErrRecordSyntax)
|
||||
test("multiple.example", StatusPermerror, nil, ErrMultipleRecords)
|
||||
test("nonspf.example", StatusNone, nil, ErrNoRecord)
|
||||
test("ok.example", StatusNone, &Record{Version: "spf1"}, nil)
|
||||
}
|
||||
|
||||
func TestExpand(t *testing.T) {
|
||||
defArgs := Args{
|
||||
senderLocalpart: "strong-bad",
|
||||
senderDomain: dns.Domain{ASCII: "email.example.com"},
|
||||
domain: dns.Domain{ASCII: "email.example.com"},
|
||||
|
||||
MailFromLocalpart: "x",
|
||||
MailFromDomain: dns.Domain{ASCII: "mox.example"},
|
||||
HelloDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mx.mox.example"}},
|
||||
LocalIP: net.ParseIP("10.10.10.10"),
|
||||
LocalHostname: dns.Domain{ASCII: "self.example"},
|
||||
}
|
||||
|
||||
resolver := dns.MockResolver{
|
||||
PTR: map[string][]string{
|
||||
"10.0.0.1": {"other.example.", "sub.mx.mox.example.", "mx.mox.example."},
|
||||
"10.0.0.2": {"other.example.", "sub.mx.mox.example.", "mx.mox.example."},
|
||||
"10.0.0.3": {"other.example.", "sub.mx.mox.example.", "mx.mox.example."},
|
||||
},
|
||||
A: map[string][]string{
|
||||
"mx.mox.example.": {"10.0.0.1"},
|
||||
"sub.mx.mox.example.": {"10.0.0.2"},
|
||||
"other.example.": {"10.0.0.3"},
|
||||
},
|
||||
}
|
||||
|
||||
mustParseIP := func(s string) net.IP {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad ip %q", s)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Examples from ../rfc/7208:1777
|
||||
test := func(dns bool, macro, ip, exp string) {
|
||||
t.Helper()
|
||||
|
||||
args := defArgs
|
||||
args.dnsRequests = new(int)
|
||||
args.voidLookups = new(int)
|
||||
if ip != "" {
|
||||
args.RemoteIP = mustParseIP(ip)
|
||||
}
|
||||
|
||||
r, err := expandDomainSpec(ctx, resolver, macro, args, dns)
|
||||
if (err == nil) != (exp != "") {
|
||||
t.Fatalf("got err %v, expected expansion %q, for macro %q", err, exp, macro)
|
||||
}
|
||||
if r != exp {
|
||||
t.Fatalf("got expansion %q, expected %q, for macro %q", r, exp, macro)
|
||||
}
|
||||
}
|
||||
|
||||
testDNS := func(macro, ip, exp string) {
|
||||
t.Helper()
|
||||
test(true, macro, ip, exp)
|
||||
}
|
||||
|
||||
testExpl := func(macro, ip, exp string) {
|
||||
t.Helper()
|
||||
test(false, macro, ip, exp)
|
||||
}
|
||||
|
||||
testDNS("%{s}", "", "strong-bad@email.example.com")
|
||||
testDNS("%{o}", "", "email.example.com")
|
||||
testDNS("%{d}", "", "email.example.com")
|
||||
testDNS("%{d4}", "", "email.example.com")
|
||||
testDNS("%{d3}", "", "email.example.com")
|
||||
testDNS("%{d2}", "", "example.com")
|
||||
testDNS("%{d1}", "", "com")
|
||||
testDNS("%{dr}", "", "com.example.email")
|
||||
testDNS("%{d2r}", "", "example.email")
|
||||
testDNS("%{l}", "", "strong-bad")
|
||||
testDNS("%{l-}", "", "strong.bad")
|
||||
testDNS("%{lr}", "", "strong-bad")
|
||||
testDNS("%{lr-}", "", "bad.strong")
|
||||
testDNS("%{l1r-}", "", "strong")
|
||||
|
||||
testDNS("%", "", "")
|
||||
testDNS("%b", "", "")
|
||||
testDNS("%{", "", "")
|
||||
testDNS("%{s", "", "")
|
||||
testDNS("%{s1", "", "")
|
||||
testDNS("%{s0}", "", "")
|
||||
testDNS("%{s1r", "", "")
|
||||
testDNS("%{s99999999999999999999999999999999999999999999999999999999999999999999999}", "", "")
|
||||
|
||||
testDNS("%{ir}.%{v}._spf.%{d2}", "192.0.2.3", "3.2.0.192.in-addr._spf.example.com")
|
||||
testDNS("%{lr-}.lp._spf.%{d2}", "192.0.2.3", "bad.strong.lp._spf.example.com")
|
||||
testDNS("%{lr-}.lp.%{ir}.%{v}._spf.%{d2}", "192.0.2.3", "bad.strong.lp.3.2.0.192.in-addr._spf.example.com")
|
||||
testDNS("%{ir}.%{v}.%{l1r-}.lp._spf.%{d2}", "192.0.2.3", "3.2.0.192.in-addr.strong.lp._spf.example.com")
|
||||
testDNS("%{d2}.trusted-domains.example.net", "192.0.2.3", "example.com.trusted-domains.example.net")
|
||||
|
||||
testDNS("%{ir}.%{v}._spf.%{d2}", "2001:db8::cb01", "1.0.b.c.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6._spf.example.com")
|
||||
|
||||
// Additional.
|
||||
testDNS("%%%-%_", "10.0.0.1", "%%20 ")
|
||||
testDNS("%{p}", "10.0.0.1", "mx.mox.example.")
|
||||
testDNS("%{p}", "10.0.0.2", "sub.mx.mox.example.")
|
||||
testDNS("%{p}", "10.0.0.3", "other.example.")
|
||||
testDNS("%{p}", "10.0.0.4", "unknown")
|
||||
testExpl("%{c}", "10.0.0.1", "10.10.10.10")
|
||||
testExpl("%{r}", "10.0.0.1", "self.example")
|
||||
orig := timeNow
|
||||
now := orig()
|
||||
defer func() {
|
||||
timeNow = orig
|
||||
}()
|
||||
timeNow = func() time.Time {
|
||||
return now
|
||||
}
|
||||
testExpl("%{t}", "10.0.0.1", fmt.Sprintf("%d", now.Unix()))
|
||||
// DNS name can be 253 bytes long, each label can be 63 bytes.
|
||||
xlabel := make([]byte, 62)
|
||||
for i := range xlabel {
|
||||
xlabel[i] = 'a'
|
||||
}
|
||||
label := string(xlabel)
|
||||
name := label + "." + label + "." + label + "." + label // 4*62+3 = 251
|
||||
testDNS("x."+name, "10.0.0.1", "x."+name) // Still fits.
|
||||
testDNS("xx."+name, "10.0.0.1", name) // Does not fit, "xx." is truncated to make it fit.
|
||||
testDNS("%{p}..", "10.0.0.1", "")
|
||||
testDNS("%{h}", "10.0.0.1", "mx.mox.example")
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
xip := func(s string) net.IP {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
t.Fatalf("bad ip: %q", s)
|
||||
}
|
||||
return ip
|
||||
}
|
||||
iplist := func(l ...string) []net.IP {
|
||||
r := make([]net.IP, len(l))
|
||||
for i, s := range l {
|
||||
r[i] = xip(s)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ../rfc/7208:2975 Appendix A. Extended Examples
|
||||
r := dns.MockResolver{
|
||||
PTR: map[string][]string{
|
||||
"192.0.2.10": {"example.com."},
|
||||
"192.0.2.11": {"example.com."},
|
||||
"192.0.2.65": {"amy.example.com."},
|
||||
"192.0.2.66": {"bob.example.com."},
|
||||
"192.0.2.129": {"mail-a.example.com."},
|
||||
"192.0.2.130": {"mail-b.example.com."},
|
||||
"192.0.2.140": {"mail-c.example.org."},
|
||||
"10.0.0.4": {"bob.example.com."},
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
// Additional from DNSBL, ../rfc/7208:3115
|
||||
"mobile-users._spf.example.com.": {"v=spf1 exists:%{l1r+}.%{d}"},
|
||||
"remote-users._spf.example.com.": {"v=spf1 exists:%{ir}.%{l1r+}.%{d}"},
|
||||
|
||||
// Additional ../rfc/7208:3171
|
||||
"ip4._spf.example.com.": {"v=spf1 -ip4:192.0.2.0/24 +all"},
|
||||
"ptr._spf.example.com.": {"v=spf1 -ptr:example.com +all"}, // ../rfc/7208-eid6216 ../rfc/7208:3172
|
||||
|
||||
// Additional tests
|
||||
"_spf.example.com.": {"v=spf1 include:_netblock.example.com -all"},
|
||||
"_netblock.example.com.": {"v=spf1 ip4:192.0.2.128/28 -all"},
|
||||
},
|
||||
A: map[string][]string{
|
||||
"example.com.": {"192.0.2.10", "192.0.2.11"},
|
||||
"amy.example.com.": {"192.0.2.65"},
|
||||
"bob.example.com.": {"192.0.2.66"},
|
||||
"mail-a.example.com.": {"192.0.2.129"},
|
||||
"mail-b.example.com.": {"192.0.2.130"},
|
||||
"mail-c.example.org.": {"192.0.2.140"},
|
||||
|
||||
// Additional from DNSBL, ../rfc/7208:3115
|
||||
"mary.mobile-users._spf.example.com.": {"127.0.0.2"},
|
||||
"fred.mobile-users._spf.example.com.": {"127.0.0.2"},
|
||||
"15.15.168.192.joel.remote-users._spf.example.com.": {"127.0.0.2"},
|
||||
"16.15.168.192.joel.remote-users._spf.example.com.": {"127.0.0.2"},
|
||||
},
|
||||
AAAA: map[string][]string{},
|
||||
MX: map[string][]*net.MX{
|
||||
"example.com.": {
|
||||
{Host: "mail-a.example.com.", Pref: 10},
|
||||
{Host: "mail-b.example.com.", Pref: 20},
|
||||
},
|
||||
"example.org.": {
|
||||
{Host: "mail-c.example.org.", Pref: 10},
|
||||
},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{},
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
verify := func(ip net.IP, localpart string, status Status) {
|
||||
t.Helper()
|
||||
|
||||
args := Args{
|
||||
MailFromLocalpart: smtp.Localpart(localpart),
|
||||
MailFromDomain: dns.Domain{ASCII: "example.com"},
|
||||
RemoteIP: ip,
|
||||
LocalIP: xip("127.0.0.1"),
|
||||
LocalHostname: dns.Domain{ASCII: "localhost"},
|
||||
}
|
||||
received, _, _, err := Verify(ctx, r, args)
|
||||
if received.Result != status {
|
||||
t.Fatalf("got status %q, expected %q, for ip %q (err %v)", received.Result, status, ip, err)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
test := func(txt string, ips []net.IP, only bool) {
|
||||
r.TXT["example.com."] = []string{txt}
|
||||
seen := map[string]struct{}{}
|
||||
for _, ip := range ips {
|
||||
verify(ip, "", StatusPass)
|
||||
seen[ip.String()] = struct{}{}
|
||||
}
|
||||
if !only {
|
||||
return
|
||||
}
|
||||
for ip := range r.PTR {
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
verify(xip(ip), "", StatusFail)
|
||||
}
|
||||
}
|
||||
|
||||
// ../rfc/7208:3031 A.1. Simple Examples
|
||||
test("v=spf1 +all", iplist("192.0.2.129", "1.2.3.4"), false)
|
||||
test("v=spf1 a -all", iplist("192.0.2.10", "192.0.2.11"), true)
|
||||
test("v=spf1 a:example.org -all", iplist(), true)
|
||||
test("v=spf1 mx -all", iplist("192.0.2.129", "192.0.2.130"), true)
|
||||
test("v=spf1 mx:example.org -all", iplist("192.0.2.140"), true)
|
||||
test("v=spf1 mx mx:example.org -all", iplist("192.0.2.129", "192.0.2.130", "192.0.2.140"), true)
|
||||
test("v=spf1 mx/30 mx:example.org/30 -all", iplist("192.0.2.129", "192.0.2.130", "192.0.2.140"), true)
|
||||
test("v=spf1 ptr -all", iplist("192.0.2.10", "192.0.2.11", "192.0.2.65", "192.0.2.66", "192.0.2.129", "192.0.2.130"), true)
|
||||
test("v=spf1 ip4:192.0.2.128/28 -all", iplist("192.0.2.129", "192.0.2.130", "192.0.2.140"), true)
|
||||
|
||||
// Additional tests
|
||||
test("v=spf1 redirect=_spf.example.com", iplist("192.0.2.129", "192.0.2.130", "192.0.2.140"), true)
|
||||
|
||||
// Additional from DNSBL, ../rfc/7208:3115
|
||||
r.TXT["example.com."] = []string{"v=spf1 mx include:mobile-users._spf.%{d} include:remote-users._spf.%{d} -all"}
|
||||
verify(xip("1.2.3.4"), "mary", StatusPass)
|
||||
verify(xip("1.2.3.4"), "fred", StatusPass)
|
||||
verify(xip("1.2.3.4"), "fred+wildcard", StatusPass)
|
||||
verify(xip("1.2.3.4"), "joel", StatusFail)
|
||||
verify(xip("1.2.3.4"), "other", StatusFail)
|
||||
verify(xip("192.168.15.15"), "joel", StatusPass)
|
||||
verify(xip("192.168.15.16"), "joel", StatusPass)
|
||||
verify(xip("192.168.15.17"), "joel", StatusFail)
|
||||
verify(xip("192.168.15.17"), "other", StatusFail)
|
||||
|
||||
// Additional ../rfc/7208:3171
|
||||
r.TXT["example.com."] = []string{"v=spf1 -include:ip4._spf.%{d} -include:ptr._spf.%{d} +all"}
|
||||
r.PTR["192.0.2.1"] = []string{"a.example.com."}
|
||||
r.PTR["192.0.0.1"] = []string{"b.example.com."}
|
||||
r.A["a.example.com."] = []string{"192.0.2.1"}
|
||||
r.A["b.example.com."] = []string{"192.0.0.1"}
|
||||
|
||||
verify(xip("192.0.2.1"), "", StatusPass) // IP in range and PTR matches.
|
||||
verify(xip("192.0.2.2"), "", StatusFail) // IP in range but no PTR match.
|
||||
verify(xip("192.0.0.1"), "", StatusFail) // PTR match but IP not in range.
|
||||
verify(xip("192.0.0.2"), "", StatusFail) // No PTR match and IP not in range.
|
||||
}
|
||||
|
||||
// ../rfc/7208:3093
|
||||
func TestVerifyMultipleDomain(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"example.org.": {"v=spf1 include:example.com include:example.net -all"},
|
||||
"la.example.org.": {"v=spf1 redirect=example.org"},
|
||||
"example.com.": {"v=spf1 ip4:10.0.0.1 -all"},
|
||||
"example.net.": {"v=spf1 ip4:10.0.0.2 -all"},
|
||||
},
|
||||
}
|
||||
|
||||
verify := func(domain, ip string, status Status) {
|
||||
t.Helper()
|
||||
|
||||
args := Args{
|
||||
MailFromDomain: dns.Domain{ASCII: domain},
|
||||
RemoteIP: net.ParseIP(ip),
|
||||
LocalIP: net.ParseIP("127.0.0.1"),
|
||||
LocalHostname: dns.Domain{ASCII: "localhost"},
|
||||
}
|
||||
received, _, _, err := Verify(context.Background(), resolver, args)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if received.Result != status {
|
||||
t.Fatalf("got status %q, expected %q, for ip %q", received.Result, status, ip)
|
||||
}
|
||||
}
|
||||
|
||||
verify("example.com", "10.0.0.1", StatusPass)
|
||||
verify("example.net", "10.0.0.2", StatusPass)
|
||||
verify("example.com", "10.0.0.2", StatusFail)
|
||||
verify("example.net", "10.0.0.1", StatusFail)
|
||||
verify("example.org", "10.0.0.1", StatusPass)
|
||||
verify("example.org", "10.0.0.2", StatusPass)
|
||||
verify("example.org", "10.0.0.3", StatusFail)
|
||||
verify("la.example.org", "10.0.0.1", StatusPass)
|
||||
verify("la.example.org", "10.0.0.2", StatusPass)
|
||||
verify("la.example.org", "10.0.0.3", StatusFail)
|
||||
}
|
||||
|
||||
func TestVerifyScenarios(t *testing.T) {
|
||||
test := func(resolver dns.Resolver, args Args, expStatus Status, expDomain string, expExpl string, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
recv, d, expl, err := Verify(context.Background(), resolver, args)
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("got err %v, expected %v", err, expErr)
|
||||
}
|
||||
if expStatus != recv.Result || expDomain != "" && d.ASCII != expDomain || expExpl != "" && expl != expExpl {
|
||||
t.Fatalf("got status %q, domain %q, expl %q, err %v", recv.Result, d, expl, err)
|
||||
}
|
||||
}
|
||||
|
||||
r := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"mox.example.": {"v=spf1 ip6:2001:db8::0/64 -all"},
|
||||
"void.example.": {"v=spf1 exists:absent1.example exists:absent2.example ip4:1.2.3.4 exists:absent3.example -all"},
|
||||
"loop.example.": {"v=spf1 include:loop.example -all"},
|
||||
"a-unknown.example.": {"v=spf1 a:absent.example"},
|
||||
"include-bad-expand.example.": {"v=spf1 include:%{c}"}, // macro 'c' only valid while expanding for "exp".
|
||||
"exists-bad-expand.example.": {"v=spf1 exists:%{c}"}, // macro 'c' only valid while expanding for "exp".
|
||||
"redir-bad-expand.example.": {"v=spf1 redirect=%{c}"}, // macro 'c' only valid while expanding for "exp".
|
||||
"a-bad-expand.example.": {"v=spf1 a:%{c}"}, // macro 'c' only valid while expanding for "exp".
|
||||
"mx-bad-expand.example.": {"v=spf1 mx:%{c}"}, // macro 'c' only valid while expanding for "exp".
|
||||
"ptr-bad-expand.example.": {"v=spf1 ptr:%{c}"}, // macro 'c' only valid while expanding for "exp".
|
||||
"include-temperror.example.": {"v=spf1 include:temperror.example"},
|
||||
"include-none.example.": {"v=spf1 include:absent.example"},
|
||||
"include-permerror.example.": {"v=spf1 include:permerror.example"},
|
||||
"permerror.example.": {"v=spf1 a:%%"},
|
||||
"no-mx.example.": {"v=spf1 mx -all"},
|
||||
"many-mx.example.": {"v=spf1 mx -all"},
|
||||
"many-ptr.example.": {"v=spf1 ptr:many-mx.example ~all"},
|
||||
"expl.example.": {"v=spf1 ip4:10.0.1.1 -ip4:10.0.1.2 ?all exp=details.expl.example"},
|
||||
"details.expl.example.": {"your ip %{i} is not allowed"},
|
||||
"expl-multi.example.": {"v=spf1 ip4:10.0.1.1 -ip4:10.0.1.2 ~all exp=details-multi.expl.example"},
|
||||
"details-multi.expl.example.": {"your ip ", "%{i} is not allowed"},
|
||||
},
|
||||
A: map[string][]string{
|
||||
"mail.mox.example.": {"10.0.0.1"},
|
||||
"mx1.many-mx.example.": {"10.0.1.1"},
|
||||
"mx2.many-mx.example.": {"10.0.1.2"},
|
||||
"mx3.many-mx.example.": {"10.0.1.3"},
|
||||
"mx4.many-mx.example.": {"10.0.1.4"},
|
||||
"mx5.many-mx.example.": {"10.0.1.5"},
|
||||
"mx6.many-mx.example.": {"10.0.1.6"},
|
||||
"mx7.many-mx.example.": {"10.0.1.7"},
|
||||
"mx8.many-mx.example.": {"10.0.1.8"},
|
||||
"mx9.many-mx.example.": {"10.0.1.9"},
|
||||
"mx10.many-mx.example.": {"10.0.1.10"},
|
||||
"mx11.many-mx.example.": {"10.0.1.11"},
|
||||
},
|
||||
AAAA: map[string][]string{
|
||||
"mail.mox.example.": {"2001:db8::1"},
|
||||
},
|
||||
MX: map[string][]*net.MX{
|
||||
"no-mx.example.": {{Host: ".", Pref: 10}},
|
||||
"many-mx.example.": {
|
||||
{Host: "mx1.many-mx.example.", Pref: 1},
|
||||
{Host: "mx2.many-mx.example.", Pref: 2},
|
||||
{Host: "mx3.many-mx.example.", Pref: 3},
|
||||
{Host: "mx4.many-mx.example.", Pref: 4},
|
||||
{Host: "mx5.many-mx.example.", Pref: 5},
|
||||
{Host: "mx6.many-mx.example.", Pref: 6},
|
||||
{Host: "mx7.many-mx.example.", Pref: 7},
|
||||
{Host: "mx8.many-mx.example.", Pref: 8},
|
||||
{Host: "mx9.many-mx.example.", Pref: 9},
|
||||
{Host: "mx10.many-mx.example.", Pref: 10},
|
||||
{Host: "mx11.many-mx.example.", Pref: 11},
|
||||
},
|
||||
},
|
||||
PTR: map[string][]string{
|
||||
"2001:db8::1": {"mail.mox.example."},
|
||||
"10.0.1.1": {"mx1.many-mx.example.", "mx2.many-mx.example.", "mx3.many-mx.example.", "mx4.many-mx.example.", "mx5.many-mx.example.", "mx6.many-mx.example.", "mx7.many-mx.example.", "mx8.many-mx.example.", "mx9.many-mx.example.", "mx10.many-mx.example.", "mx11.many-mx.example."},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
// IPv6 remote IP.
|
||||
test(r, Args{RemoteIP: net.ParseIP("2001:db8::1"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "mox.example"}}, StatusPass, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("2001:fa11::1"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "mox.example"}}, StatusFail, "", "", nil)
|
||||
|
||||
// Use EHLO identity.
|
||||
test(r, Args{RemoteIP: net.ParseIP("2001:db8::1"), HelloDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mox.example"}}}, StatusPass, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("2001:db8::1"), HelloDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mail.mox.example"}}}, StatusNone, "", "", ErrNoRecord)
|
||||
|
||||
// Too many void lookups.
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "void.example"}}, StatusPass, "", "", nil) // IP found after 2 void lookups, but before 3rd.
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.1.1.1"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "void.example"}}, StatusPermerror, "", "", ErrTooManyVoidLookups) // IP not found, not doing 3rd lookup.
|
||||
|
||||
// Too many DNS requests.
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "loop.example"}}, StatusPermerror, "", "", ErrTooManyDNSRequests) // Self-referencing record, will abort after 10 includes.
|
||||
|
||||
// a:other where other does not exist.
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "a-unknown.example"}}, StatusNeutral, "", "", nil)
|
||||
|
||||
// Expand with an invalid macro.
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "include-bad-expand.example"}}, StatusPermerror, "", "", ErrMacroSyntax)
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "exists-bad-expand.example"}}, StatusPermerror, "", "", ErrMacroSyntax)
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "redir-bad-expand.example"}}, StatusPermerror, "", "", ErrMacroSyntax)
|
||||
|
||||
// Expand with invalid character (because macros are not expanded).
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "a-bad-expand.example"}}, StatusPermerror, "", "", ErrName)
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "mx-bad-expand.example"}}, StatusPermerror, "", "", ErrName)
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "ptr-bad-expand.example"}}, StatusPermerror, "", "", ErrName)
|
||||
|
||||
// Include with varying results.
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "include-temperror.example"}}, StatusTemperror, "", "", ErrDNS)
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "include-none.example"}}, StatusPermerror, "", "", ErrNoRecord)
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "include-permerror.example"}}, StatusPermerror, "", "", ErrName)
|
||||
|
||||
// MX with explicit "." for "no mail".
|
||||
test(r, Args{RemoteIP: net.ParseIP("1.2.3.4"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "no-mx.example"}}, StatusFail, "", "", nil)
|
||||
|
||||
// MX names beyond 10th entry result in Permerror.
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.1"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "many-mx.example"}}, StatusPass, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.10"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "many-mx.example"}}, StatusPass, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.11"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "many-mx.example"}}, StatusPermerror, "", "", ErrTooManyDNSRequests)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.254"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "many-mx.example"}}, StatusPermerror, "", "", ErrTooManyDNSRequests)
|
||||
|
||||
// PTR names beyond 10th entry are ignored.
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.1"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "many-ptr.example"}}, StatusPass, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.2"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "many-ptr.example"}}, StatusSoftfail, "", "", nil)
|
||||
|
||||
// Explanation from txt records.
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.1"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "expl.example"}}, StatusPass, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.2"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "expl.example"}}, StatusFail, "", "your ip 10.0.1.2 is not allowed", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.3"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "expl.example"}}, StatusNeutral, "", "", nil)
|
||||
test(r, Args{RemoteIP: net.ParseIP("10.0.1.2"), MailFromLocalpart: "x", MailFromDomain: dns.Domain{ASCII: "expl-multi.example"}}, StatusFail, "", "your ip 10.0.1.2 is not allowed", nil)
|
||||
|
||||
// Verify with IP EHLO.
|
||||
test(r, Args{RemoteIP: net.ParseIP("2001:db8::1"), HelloDomain: dns.IPDomain{IP: net.ParseIP("::1")}}, StatusNone, "", "", nil)
|
||||
}
|
||||
|
||||
func TestEvaluate(t *testing.T) {
|
||||
record := &Record{}
|
||||
resolver := dns.MockResolver{}
|
||||
args := Args{}
|
||||
status, _, _, _ := Evaluate(context.Background(), record, resolver, args)
|
||||
if status != StatusNone {
|
||||
t.Fatalf("got status %q, expected none", status)
|
||||
}
|
||||
|
||||
args = Args{
|
||||
HelloDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "test.example"}},
|
||||
}
|
||||
status, mechanism, _, err := Evaluate(context.Background(), record, resolver, args)
|
||||
if status != StatusNeutral || mechanism != "default" || err != nil {
|
||||
t.Fatalf("got status %q, mechanism %q, err %v, expected neutral, default, no error", status, mechanism, err)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user