package spf
import (
"context"
"net"
"strings"
"testing"
)
func NewDefaultResolver() *testResolver {
dns := newTestResolver()
defaultResolver = dns
return dns
}
func init() {
// Override the default resolver to make sure the tests are not using the
// one from net. Individual tests will override this as well, but just in
// case.
NewDefaultResolver()
}
var (
ip1110 = net.ParseIP("1.1.1.0")
ip1111 = net.ParseIP("1.1.1.1")
ip6666 = net.ParseIP("2001:db8::68")
ip6660 = net.ParseIP("2001:db8::0")
)
func TestBasic(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
cases := []struct {
txt string
res Result
err error
}{
{"", None, ErrNoResult},
{"blah", None, ErrNoResult},
{"v=spf1", Neutral, nil},
{"v=spf1 ", Neutral, nil},
{"v=spf1 -", PermError, ErrUnknownField},
{"v=spf1 all", Pass, ErrMatchedAll},
{"v=spf1 exp=blah +all", Pass, ErrMatchedAll},
{"v=spf1 +all", Pass, ErrMatchedAll},
{"v=spf1 -all ", Fail, ErrMatchedAll},
{"v=spf1 ~all", SoftFail, ErrMatchedAll},
{"v=spf1 ?all", Neutral, ErrMatchedAll},
{"v=spf1 a ~all", SoftFail, ErrMatchedAll},
{"v=spf1 a/24", Neutral, nil},
{"v=spf1 a:d1110/24", Pass, ErrMatchedA},
{"v=spf1 a:d1110/montoto", PermError, ErrInvalidMask},
{"v=spf1 a:d1110/99", PermError, ErrInvalidMask},
{"v=spf1 a:d1110/32", Neutral, nil},
{"v=spf1 a:d1110", Neutral, nil},
{"v=spf1 a:d1111", Pass, ErrMatchedA},
{"v=spf1 a:nothing/24", Neutral, nil},
{"v=spf1 mx", Neutral, nil},
{"v=spf1 mx/24", Neutral, nil},
{"v=spf1 mx:a/montoto ~all", PermError, ErrInvalidMask},
{"v=spf1 mx:d1110/24 ~all", Pass, ErrMatchedMX},
{"v=spf1 mx:d1110/24//100 ~all", Pass, ErrMatchedMX},
{"v=spf1 mx:d1110/24//129 ~all", PermError, ErrInvalidMask},
{"v=spf1 mx:d1110/24/100 ~all", PermError, ErrInvalidMask},
{"v=spf1 mx:d1110/99 ~all", PermError, ErrInvalidMask},
{"v=spf1 ip4:1.2.3.4 ~all", SoftFail, ErrMatchedAll},
{"v=spf1 ip6:12 ~all", PermError, ErrInvalidIP},
{"v=spf1 ip4:1.1.1.1 -all", Pass, ErrMatchedIP},
{"v=spf1 ip4:1.1.1.1/24 -all", Pass, ErrMatchedIP},
{"v=spf1 ip4:1.1.1.1/lala -all", PermError, ErrInvalidMask},
{"v=spf1 ip4:1.1.1.1/33 -all", PermError, ErrInvalidMask},
{"v=spf1 include:doesnotexist", PermError, ErrNoResult},
{"v=spf1 ptr -all", Pass, ErrMatchedPTR},
{"v=spf1 ptr:d1111 -all", Pass, ErrMatchedPTR},
{"v=spf1 ptr:lalala -all", Pass, ErrMatchedPTR},
{"v=spf1 ptr:doesnotexist -all", Fail, ErrMatchedAll},
{"v=spf1 blah", PermError, ErrUnknownField},
{"v=spf1 exists:d1111 -all", Pass, ErrMatchedExists},
{"v=spf1 redirect=", PermError, ErrInvalidDomain},
// An empty domain-spec is not allowed; it must not be silently
// treated as the current domain.
// https://tools.ietf.org/html/rfc7208#section-5.3
{"v=spf1 a: -all", PermError, ErrInvalidDomain},
{"v=spf1 mx: -all", PermError, ErrInvalidDomain},
{"v=spf1 a:/24 -all", PermError, ErrInvalidMask},
{"v=spf1 mx:/24 -all", PermError, ErrInvalidMask},
{"v=spf1 a:// -all", PermError, ErrInvalidMask},
// While the ones with an actual domain still work.
{"v=spf1 a:d1111 -all", Pass, ErrMatchedA},
{"v=spf1 a -all", Fail, ErrMatchedAll},
{"v=spf1 mx -all", Fail, ErrMatchedAll},
// Unrecognized modifiers are ignored, so evaluation continues.
// https://tools.ietf.org/html/rfc7208#section-6
{"v=spf1 ra=postmaster -all", Fail, ErrMatchedAll},
{"v=spf1 rp=100 -all", Fail, ErrMatchedAll},
{"v=spf1 rr=e ip4:1.1.1.1 -all", Pass, ErrMatchedIP},
{"v=spf1 moo.cow-far_out=man:dog/cat ip4:1.1.1.1 -all", Pass, ErrMatchedIP},
{"v=spf1 x=%{d} -all", Fail, ErrMatchedAll},
{"v=spf1 a1=x a2=y a3=z -all", Fail, ErrMatchedAll},
// But the name has to be valid: it starts with a letter, and the
// "=" comes before any ":" or "/".
{"v=spf1 moo.cow/far_out=man -all", PermError, ErrUnknownField},
{"v=spf1 moo.cow:far_out=man -all", PermError, ErrUnknownField},
{"v=spf1 1abc=x -all", PermError, ErrUnknownField},
{"v=spf1 =x -all", PermError, ErrUnknownField},
// Modifiers take no qualifier, so this is not one.
{"v=spf1 -ra=postmaster all", PermError, ErrUnknownField},
}
dns.Ip["d1111"] = []net.IP{ip1111}
dns.Ip["d1110"] = []net.IP{ip1110}
dns.Mx["d1110"] = []*net.MX{mx("d1110", 5), mx("nothing", 10)}
dns.Addr["1.1.1.1"] = []string{"lalala.", "xx.domain.", "d1111."}
dns.Ip["lalala"] = []net.IP{ip1111}
dns.Ip["xx.domain"] = []net.IP{ip1111}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if (res == TempError || res == PermError) && (err == nil) {
t.Errorf("%q: expected error, got nil", c.txt)
}
if res != c.res {
t.Errorf("%q: expected %q, got %q", c.txt, c.res, res)
}
if err != c.err {
t.Errorf("%q: expected error [%v], got [%v]", c.txt, c.err, err)
}
}
}
func TestIPv6(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
cases := []struct {
txt string
res Result
err error
}{
{"v=spf1 all", Pass, ErrMatchedAll},
{"v=spf1 a ~all", SoftFail, ErrMatchedAll},
{"v=spf1 a/24", Neutral, nil},
{"v=spf1 a:d6660//24", Pass, ErrMatchedA},
{"v=spf1 a:d6660/24//100", Pass, ErrMatchedA},
{"v=spf1 a:d6660", Neutral, nil},
{"v=spf1 a:d6666", Pass, ErrMatchedA},
{"v=spf1 a:nothing//24", Neutral, nil},
{"v=spf1 mx:d6660//24 ~all", Pass, ErrMatchedMX},
{"v=spf1 mx:d6660/24//100 ~all", Pass, ErrMatchedMX},
{"v=spf1 mx:d6660/24/100 ~all", PermError, ErrInvalidMask},
{"v=spf1 ip6:2001:db8::68 ~all", Pass, ErrMatchedIP},
{"v=spf1 ip6:2001:db8::1/24 ~all", Pass, ErrMatchedIP},
{"v=spf1 ip6:2001:db8::1/100 ~all", Pass, ErrMatchedIP},
// "domain" is one of the PTR names for our IP, but it resolves to
// ip1111 and not to the IP we are checking, so it is not validated
// and the implicit "ptr" does not match.
// https://tools.ietf.org/html/rfc7208#section-5.5
{"v=spf1 ptr -all", Fail, ErrMatchedAll},
{"v=spf1 ptr:d6666 -all", Pass, ErrMatchedPTR},
{"v=spf1 ptr:sonlas6 -all", Pass, ErrMatchedPTR},
{"v=spf1 ptr:sonlas7 -all", Fail, ErrMatchedAll},
}
dns.Ip["d6666"] = []net.IP{ip6666}
dns.Ip["d6660"] = []net.IP{ip6660}
dns.Mx["d6660"] = []*net.MX{mx("d6660", 5), mx("nothing", 10)}
dns.Addr["2001:db8::68"] = []string{"sonlas6.", "domain.", "d6666."}
dns.Ip["domain"] = []net.IP{ip1111}
dns.Ip["sonlas6"] = []net.IP{ip6666}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip6666, "domain")
if (res == TempError || res == PermError) && (err == nil) {
t.Errorf("%q: expected error, got nil", c.txt)
}
if res != c.res {
t.Errorf("%q: expected %q, got %q", c.txt, c.res, res)
}
if err != c.err {
t.Errorf("%q: expected error [%v], got [%v]", c.txt, c.err, err)
}
}
}
func TestInclude(t *testing.T) {
// Test that the include is doing a recursive lookup.
// If we got a match on 1.1.1.1, is because include:domain2 did not match.
dns := NewDefaultResolver()
dns.Txt["domain"] = []string{"v=spf1 include:domain2 ip4:1.1.1.1"}
defaultTrace = t.Logf
cases := []struct {
txt string
res Result
err error
}{
{"", PermError, ErrNoResult},
{"v=spf1 all", Pass, ErrMatchedAll},
// domain2 did not pass, so continued and matched parent's ip4.
{"v=spf1", Pass, ErrMatchedIP},
{"v=spf1 -all", Pass, ErrMatchedIP},
}
for _, c := range cases {
dns.Txt["domain2"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if res != c.res || err != c.err {
t.Errorf("%q: expected [%v/%v], got [%v/%v]",
c.txt, c.res, c.err, res, err)
}
}
}
func TestRecursionLimit(t *testing.T) {
dns := NewDefaultResolver()
dns.Txt["domain"] = []string{"v=spf1 include:domain ~all"}
defaultTrace = t.Logf
res, err := CheckHost(ip1111, "domain")
if res != PermError || err != ErrLookupLimitReached {
t.Errorf("expected permerror, got %v (%v)", res, err)
}
}
func TestLookupLimit(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
// "a:d1110" never matches the IP we check, so we use it to consume
// lookups; each occurrence counts as one.
// https://tools.ietf.org/html/rfc7208#section-4.6.4
ten := strings.Repeat("a:d1110 ", 10)
cases := []struct {
txt string
res Result
err error
}{
// Ten lookups are allowed, and the fields after them are evaluated.
{"v=spf1 " + ten + "-all", Fail, ErrMatchedAll},
{"v=spf1 " + ten + "ip4:1.1.1.1", Pass, ErrMatchedIP},
// The 11th lookup is not performed, even if it would match.
{"v=spf1 " + ten + "a:d1111 -all", PermError, ErrLookupLimitReached},
{"v=spf1 " + ten + "mx:d1110 -all", PermError, ErrLookupLimitReached},
{"v=spf1 " + ten + "ptr:d1111 -all", PermError, ErrLookupLimitReached},
{"v=spf1 " + ten + "exists:d1111 -all", PermError, ErrLookupLimitReached},
{"v=spf1 " + ten + "include:alldomain -all", PermError, ErrLookupLimitReached},
{"v=spf1 " + ten + "redirect=alldomain", PermError, ErrLookupLimitReached},
// The 11th lookup is the last field, so there is nothing after it to
// catch the excess.
{"v=spf1 " + ten + "a:d1111", PermError, ErrLookupLimitReached},
}
dns.Ip["d1111"] = []net.IP{ip1111}
dns.Ip["d1110"] = []net.IP{ip1110}
dns.Mx["d1110"] = []*net.MX{mx("d1111", 5)}
dns.Addr["1.1.1.1"] = []string{"d1111."}
dns.Txt["alldomain"] = []string{"v=spf1 +all"}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if res != c.res {
t.Errorf("%q: expected %q, got %q", c.txt, c.res, res)
}
if err != c.err {
t.Errorf("%q: expected error [%v], got [%v]", c.txt, c.err, err)
}
}
}
func TestRedirect(t *testing.T) {
dns := NewDefaultResolver()
dns.Txt["domain"] = []string{"v=spf1 redirect=domain2"}
dns.Txt["domain2"] = []string{"v=spf1 ip4:1.1.1.1 -all"}
defaultTrace = t.Logf
res, err := CheckHost(ip1111, "domain")
if res != Pass {
t.Errorf("expected pass, got %v (%v)", res, err)
}
}
func TestInvalidRedirect(t *testing.T) {
// Redirect to a non-existing host; the inner check returns None, but due
// to the redirection, this lookup should return PermError.
// https://tools.ietf.org/html/rfc7208#section-6.1
dns := NewDefaultResolver()
dns.Txt["domain"] = []string{"v=spf1 redirect=doesnotexist"}
defaultTrace = t.Logf
res, err := CheckHost(ip1111, "doesnotexist")
if res != None {
t.Errorf("expected none, got %v (%v)", res, err)
}
res, err = CheckHost(ip1111, "domain")
if res != PermError || err != ErrNoResult {
t.Errorf("expected permerror, got %v (%v)", res, err)
}
}
func TestRedirectOrder(t *testing.T) {
// We should only check redirects after all mechanisms, even if the
// redirect modifier appears before them.
dns := NewDefaultResolver()
dns.Txt["faildom"] = []string{"v=spf1 -all"}
defaultTrace = t.Logf
// Modifier names are case-insensitive, so how the modifier is spelled
// must not change whether it is deferred to the end.
// https://tools.ietf.org/html/rfc7208#section-4.6.1
cases := []struct {
txt string
res Result
}{
{"v=spf1 redirect=faildom", Fail},
{"v=spf1 redirect=faildom all", Pass},
{"v=spf1 Redirect=faildom", Fail},
{"v=spf1 Redirect=faildom all", Pass},
{"v=spf1 REDIRECT=faildom all", Pass},
{"v=spf1 ReDiReCt=faildom all", Pass},
}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if res != c.res || err != ErrMatchedAll {
t.Errorf("%q: expected %v, got %v (%v)", c.txt, c.res, res, err)
}
}
}
func TestModifierNames(t *testing.T) {
dns := NewDefaultResolver()
dns.Txt["alldom"] = []string{"v=spf1 +all"}
defaultTrace = t.Logf
cases := []struct {
txt string
res Result
err error
}{
// At most one redirect is allowed, and the check must not depend on
// how each of them is spelled.
// https://tools.ietf.org/html/rfc7208#section-6
{"v=spf1 redirect=alldom redirect=alldom", PermError, ErrInvalidDomain},
{"v=spf1 redirect=alldom Redirect=alldom", PermError, ErrInvalidDomain},
{"v=spf1 Redirect=alldom REDIRECT=alldom", PermError, ErrInvalidDomain},
// A single one is fine, either way.
{"v=spf1 redirect=alldom", Pass, ErrMatchedAll},
{"v=spf1 Redirect=alldom", Pass, ErrMatchedAll},
// "exp" is ignored, in any case.
{"v=spf1 exp=blah +all", Pass, ErrMatchedAll},
{"v=spf1 Exp=blah +all", Pass, ErrMatchedAll},
{"v=spf1 EXP=blah +all", Pass, ErrMatchedAll},
// Modifiers take no qualifier, so these are not modifiers, and not
// valid mechanisms either.
// https://tools.ietf.org/html/rfc7208#section-4.6.1
{"v=spf1 -redirect=alldom all", PermError, ErrUnknownField},
{"v=spf1 +redirect=alldom all", PermError, ErrUnknownField},
{"v=spf1 -exp=blah all", PermError, ErrUnknownField},
}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if res != c.res || err != c.err {
t.Errorf("%q: expected [%v/%v], got [%v/%v]",
c.txt, c.res, c.err, res, err)
}
}
}
func TestNoRecord(t *testing.T) {
dns := NewDefaultResolver()
dns.Txt["d1"] = []string{""}
dns.Txt["d2"] = []string{"loco", "v=spf2"}
dns.Errors["nospf"] = &net.DNSError{
Err: "record not found for testing",
IsNotFound: true,
}
defaultTrace = t.Logf
for _, domain := range []string{"d1", "d2", "d3", "nospf"} {
res, err := CheckHost(ip1111, domain)
if res != None {
t.Errorf("expected none, got %v (%v)", res, err)
}
}
}
func TestDNSTemporaryErrors(t *testing.T) {
dns := NewDefaultResolver()
dnsError := &net.DNSError{
Err: "temporary error for testing",
IsTemporary: true,
}
// Domain "tmperr" will fail resolution with a temporary error.
dns.Errors["tmperr"] = dnsError
dns.Errors["1.1.1.1"] = dnsError
dns.Mx["tmpmx"] = []*net.MX{mx("tmperr", 10)}
defaultTrace = t.Logf
cases := []struct {
txt string
res Result
}{
{"v=spf1 include:tmperr", TempError},
{"v=spf1 a:tmperr", TempError},
{"v=spf1 mx:tmperr", TempError},
{"v=spf1 ptr:tmperr", TempError},
{"v=spf1 mx:tmpmx", TempError},
}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if res != c.res {
t.Errorf("%q: expected %v, got %v (%v)",
c.txt, c.res, res, err)
}
}
}
func TestDNSPermanentErrors(t *testing.T) {
dns := NewDefaultResolver()
dnsError := &net.DNSError{
Err: "permanent error for testing",
IsTemporary: false,
}
// Domain "permerr" will fail resolution with a permanent error.
dns.Errors["permerr"] = dnsError
dns.Errors["1.1.1.1"] = dnsError
dns.Mx["permmx"] = []*net.MX{mx("permerr", 10)}
defaultTrace = t.Logf
cases := []struct {
txt string
res Result
}{
// Top-level checks will return a permanent error.
{"v=spf1 include:permerr", PermError},
// RFC specifies that on any DNS error (other than NXDOMAIN),
// we must return TempError.
// https://www.rfc-editor.org/rfc/rfc7208#section-5
{"v=spf1 a:permerr", TempError},
{"v=spf1 mx:permerr", TempError},
{"v=spf1 ptr:permerr", TempError},
{"v=spf1 mx:permmx", TempError},
}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHost(ip1111, "domain")
if res != c.res {
t.Errorf("%q: expected %v, got %v (%v)",
c.txt, c.res, res, err)
}
}
}
func TestMXWithInvalidRecord(t *testing.T) {
dns := NewDefaultResolver()
dnsError := &net.DNSError{
Err: "permanent error for testing",
IsTemporary: false,
}
// MX lookup on "dom2" will return an error and also some records.
// We expect the resolution to use the valid records, and ignore
// the error.
dns.Txt["domain"] = []string{"v=spf1 mx:dom2 -all"}
dns.Mx["dom2"] = []*net.MX{mx("oneoneoneone", 10)}
dns.Errors["dom2"] = dnsError
dns.Ip["oneoneoneone"] = []net.IP{ip1111}
defaultTrace = t.Logf
res, err := CheckHost(ip1111, "domain")
if res != Pass {
t.Errorf("expected pass, got %v (%v)", res, err)
}
}
func TestMacros(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
// Most of the cases are covered by the standard test suite, so this is
// targeted at gaps in coverage.
cases := []struct {
txt string
res Result
err error
}{
{"v=spf1 ptr:%{fff} -all", PermError, ErrInvalidMacro},
{"v=spf1 mx:%{fff} -all", PermError, ErrInvalidMacro},
{"v=spf1 redirect=%{fff}", PermError, ErrInvalidMacro},
{"v=spf1 a:%{o0}", PermError, ErrInvalidMacro},
{"v=spf1 +a:sss-%{s}-sss", Pass, ErrMatchedA},
{"v=spf1 +a:ooo-%{o}-ooo", Pass, ErrMatchedA},
{"v=spf1 +a:OOO-%{O}-OOO", Pass, ErrMatchedA},
{"v=spf1 +a:ppp-%{p}-ppp", Pass, ErrMatchedA},
{"v=spf1 +a:hhh-%{h}-hhh", Pass, ErrMatchedA},
{"v=spf1 +a:vvv-%{v}-vvv", Pass, ErrMatchedA},
{"v=spf1 a:%{x}", PermError, ErrInvalidMacro},
{"v=spf1 +a:ooo-%{o7}-ooo", Pass, ErrMatchedA},
{"v=spf1 exists:%{ir}.vvv -all", Pass, ErrMatchedExists},
}
dns.Ip["sss-user@domain-sss"] = []net.IP{ip6666}
dns.Ip["ooo-domain-ooo"] = []net.IP{ip6666}
dns.Ip["ppp-unknown-ppp"] = []net.IP{ip6666}
dns.Ip["vvv-ip6-vvv"] = []net.IP{ip6666}
dns.Ip["hhh-helo-hhh"] = []net.IP{ip6666}
dns.Ip["8.6.0.0.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.vvv"] = []net.IP{ip1111}
for _, c := range cases {
dns.Txt["domain"] = []string{c.txt}
res, err := CheckHostWithSender(ip6666, "helo", "user@domain")
if (res == TempError || res == PermError) && (err == nil) {
t.Errorf("%q: expected error, got nil", c.txt)
}
if res != c.res {
t.Errorf("%q: expected %q, got %q", c.txt, c.res, res)
}
if err != c.err {
t.Errorf("%q: expected error [%v], got [%v]", c.txt, c.err, err)
}
}
}
func TestMacrosV4(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
// Like TestMacros above, but specifically for IPv4.
// It's easier to have a separate suite.
// While at it, test some of the reversals, for variety.
cases := []struct {
txt string
res Result
err error
}{
{"v=spf1 +a:sr-%{sr}-sr", Pass, ErrMatchedA},
{"v=spf1 +a:sra-%{sr.}-sra", Pass, ErrMatchedA},
{"v=spf1 +a:o7-%{o7}-o7", Pass, ErrMatchedA},
{"v=spf1 +a:o1-%{o1}-o1", Pass, ErrMatchedA},
{"v=spf1 +a:o1r-%{o1r}-o1r", Pass, ErrMatchedA},
{"v=spf1 +a:vvv-%{v}-vvv", Pass, ErrMatchedA},
}
dns.Ip["sr-com.user@domain-sr"] = []net.IP{ip1111}
dns.Ip["sra-com.user@domain-sra"] = []net.IP{ip1111}
dns.Ip["o7-domain.com-o7"] = []net.IP{ip1111}
dns.Ip["o1-com-o1"] = []net.IP{ip1111}
dns.Ip["o1r-domain-o1r"] = []net.IP{ip1111}
dns.Ip["vvv-in-addr-vvv"] = []net.IP{ip1111}
for _, c := range cases {
dns.Txt["domain.com"] = []string{c.txt}
res, err := CheckHostWithSender(ip1111, "helo", "user@domain.com")
if (res == TempError || res == PermError) && (err == nil) {
t.Errorf("%q: expected error, got nil", c.txt)
}
if res != c.res {
t.Errorf("%q: expected %q, got %q", c.txt, c.res, res)
}
if err != c.err {
t.Errorf("%q: expected error [%v], got [%v]", c.txt, c.err, err)
}
}
}
func mx(host string, pref uint16) *net.MX {
return &net.MX{Host: host, Pref: pref}
}
func mkDM(v4, v6 int) dualMasks {
return dualMasks{net.CIDRMask(v4, 32), net.CIDRMask(v6, 128)}
}
func TestIsSubdomainHelper(t *testing.T) {
cases := []struct {
name string
domain string
ok bool
}{
// The name is the domain itself.
{"example.com", "example.com", true},
{"example.com.", "example.com", true},
{"example.com", "example.com.", true},
{"example.com.", "example.com.", true},
// The name is a subdomain of the domain.
{"sub.example.com", "example.com", true},
{"sub.example.com.", "example.com", true},
{"a.b.c.example.com", "example.com", true},
{"sub.example.com", "sub.example.com", true},
// Case insensitivity, on both sides.
{"EXAMPLE.com", "example.COM", true},
{"SUB.Example.Com.", "eXaMpLe.cOm", true},
// The match must be on a label boundary.
{"notexample.com", "example.com", false},
{"xexample.com", "example.com", false},
{"sub.notexample.com", "example.com", false},
// Other non-matches.
{"example.com", "sub.example.com", false},
{"example.org", "example.com", false},
{"example.com", "", false},
{"", "example.com", false},
{"", "", true},
// A dot alone is not a valid label, so it does not turn into a
// match against the empty domain.
{".", "example.com", false},
// Case folding is ASCII-only: DNS is case-insensitive for ASCII,
// so these are different names, even though Unicode case folding
// considers them equal. Note both sides have the same length, so
// they are really compared and not rejected by the length checks.
{"Σ.example.com", "σ.example.com", false},
{"sub.Σ.example.com", "sub.σ.example.com", false},
{"А.example.com", "а.example.com", false},
// The same non-ASCII bytes on both sides do match.
{"Σ.example.com", "Σ.example.com", true},
{"sub.Σ.example.com", "Σ.example.com", true},
// U+212A KELVIN SIGN, which strings.ToLower turns into an ASCII
// "k". This one is caught by the length check (3 bytes vs 1), but
// it is why we no longer lowercase the names beforehand.
{"K.example.com", "k.example.com", false},
}
for _, c := range cases {
if ok := isSubdomain(c.name, c.domain); ok != c.ok {
t.Errorf("isSubdomain(%q, %q): expected %v, got %v",
c.name, c.domain, c.ok, ok)
}
}
}
func TestAsciiEqualFoldHelper(t *testing.T) {
// isSubdomain only ever calls this with equal-length strings, so the
// length check is not reachable from there; test it directly.
cases := []struct {
a, b string
ok bool
}{
{"", "", true},
{"abc", "abc", true},
{"ABC", "abc", true},
{"aBc", "AbC", true},
{"abc", "abd", false},
// Different lengths.
{"abc", "ab", false},
{"ab", "abc", false},
{"", "a", false},
// Non-ASCII is compared byte by byte, without case folding.
{"Σ", "σ", false},
{"Σ", "Σ", true},
}
for _, c := range cases {
if ok := asciiEqualFold(c.a, c.b); ok != c.ok {
t.Errorf("asciiEqualFold(%q, %q): expected %v, got %v",
c.a, c.b, c.ok, ok)
}
}
}
func TestIPMatchHelper(t *testing.T) {
cases := []struct {
ip net.IP
tomatch net.IP
masks dualMasks
ok bool
}{
{ip1111, ip1110, mkDM(24, -1), true},
{ip1111, ip1111, mkDM(-1, -1), true},
{ip1111, ip1110, mkDM(-1, -1), false},
{ip1111, ip1110, mkDM(32, -1), false},
{ip1111, ip1110, mkDM(99, -1), false},
{ip6666, ip6660, mkDM(-1, 100), true},
{ip6666, ip6666, mkDM(-1, -1), true},
{ip6666, ip6660, mkDM(-1, -1), false},
{ip6666, ip6660, mkDM(-1, 128), false},
{ip6666, ip6660, mkDM(-1, 200), false},
}
for _, c := range cases {
ok := ipMatch(c.ip, c.tomatch, c.masks)
if ok != c.ok {
t.Errorf("[%s %s/%v]: expected %v, got %v",
c.ip, c.tomatch, c.masks, c.ok, ok)
}
}
}
func TestInvalidIP(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
// A record that would match anything, to make sure we are rejecting the
// address and not just failing to match it.
dns.Txt["domain"] = []string{"v=spf1 exists:%{i}.d1111 +all"}
dns.Ip["d1111"] = []net.IP{ip1111}
// net.IP is a byte slice, so it can hold things that are not valid
// addresses. net.ParseIP returns nil on error, which is the most likely
// one to show up in practice.
cases := []net.IP{
nil,
net.ParseIP("this is not an ip"),
{},
{1, 2, 3},
{1, 2, 3, 4, 5},
}
for _, ip := range cases {
res, err := CheckHost(ip, "domain")
if res != PermError || err != ErrInvalidIPAddress {
t.Errorf("CheckHost(%v): expected %v/%v, got %v/%v",
[]byte(ip), PermError, ErrInvalidIPAddress, res, err)
}
res, err = CheckHostWithSender(ip, "helo", "user@domain")
if res != PermError || err != ErrInvalidIPAddress {
t.Errorf("CheckHostWithSender(%v): expected %v/%v, got %v/%v",
[]byte(ip), PermError, ErrInvalidIPAddress, res, err)
}
}
}
func TestIPToMacroStr(t *testing.T) {
cases := []struct {
ip net.IP
out string
}{
{ip1111, "1.1.1.1"},
{net.ParseIP("::ffff:1.1.1.1"), "1.1.1.1"},
{ip6666, "2.0.0.1.0.d.b.8.0.0.0.0.0.0.0.0." +
"0.0.0.0.0.0.0.0.0.0.0.0.0.0.6.8"},
// Invalid addresses: the entry points reject them, but the helper
// must not panic on them either.
{nil, ""},
{net.IP{1, 2, 3}, ""},
}
for _, c := range cases {
if out := ipToMacroStr(c.ip); out != c.out {
t.Errorf("ipToMacroStr(%v): expected %q, got %q",
[]byte(c.ip), c.out, out)
}
}
}
func TestValidMacro(t *testing.T) {
// Test that valid macros are expanded, and in particular that the macro
// grammar is not tightened too much: each of these exercises a different
// combination of the optional transformers.
r := resolution{
ip: ip1111,
sender: "user@a.b.example.com",
helo: "helo",
trace: t.Logf,
}
cases := []struct {
macro string
out string
}{
{"%{d}", "a.b.example.com"},
{"%{d4}", "a.b.example.com"},
{"%{d9}", "a.b.example.com"},
{"%{d2}", "example.com"},
{"%{dr}", "com.example.b.a"},
{"%{d2r}", "b.a"},
{"%{dR}", "com.example.b.a"},
{"%{l}", "user"},
{"%{o}", "a.b.example.com"},
{"%{h}", "helo"},
{"%{i}", "1.1.1.1"},
{"%{v}", "in-addr"},
{"%{ir}", "1.1.1.1"},
// Explicit delimiters, alone and combined with the rest.
{"%{d-}", "a.b.example.com"},
{"%{o.-}", "a.b.example.com"},
{"%{l+}", "user"},
{"%{s_}", "user@a.b.example.com"},
// Uppercase letters mean the result is URL-escaped.
{"%{S}", "user%40a.b.example.com"},
{"%{D2}", "example.com"},
// Literals and escapes around the macros.
{"foo.%{d}.bar", "foo.a.b.example.com.bar"},
{"%%", "%"},
{"%_", " "},
{"%-", "%20"},
{"%{d}%{l}", "a.b.example.comuser"},
{"no-macros-here", "no-macros-here"},
}
for _, c := range cases {
out, err := r.expandMacros(c.macro, "a.b.example.com")
if err != nil {
t.Errorf("%q: unexpected error %v", c.macro, err)
}
if out != c.out {
t.Errorf("%q: expected %q, got %q", c.macro, c.out, out)
}
}
}
func TestInvalidMacro(t *testing.T) {
// Test that the macro expansion detects some invalid macros.
macros := []string{
// Unknown macro letters.
"%{x}", "%{z}", "%{c}", "%{r}", "%{t}",
// Junk around an otherwise valid macro letter: the macro body has
// to match the grammar entirely.
"%{zzd}", "%{ssss}", "%{d!}", "%{s1x}", "%{1d}", "%{dr2}",
"%{d2rr}", "%{}", "%{ }", "%{d }",
// Unterminated macros: we must not silently return the truncated
// value expanded so far.
"%{d", "foo%{d", "foo%{", "foo%", "%",
"foo%{d2r", "%{d}%{s",
// Invalid character right after the "%".
"%d", "%a{d}", "% ",
}
for _, macro := range macros {
r := resolution{
ip: ip1111,
count: 0,
sender: "sender.com",
trace: t.Logf,
}
out, err := r.expandMacros(macro, "sender.com")
if out != "" || err != ErrInvalidMacro {
t.Errorf(`[%s]:expected ""/%v, got %q/%v`,
macro, ErrInvalidMacro, out, err)
}
}
}
// Test that the null tracer doesn't cause unexpected issues, since all the
// other tests override it.
func TestNullTrace(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = nullTrace
dns.Txt["domain1"] = []string{"v=spf1 include:domain2"}
dns.Txt["domain2"] = []string{"v=spf1 +all"}
// Do a normal resolution, check it passes.
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1")
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
}
func TestOverrideLookupLimit(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
dns.Txt["domain1"] = []string{"v=spf1 include:domain2"}
dns.Txt["domain2"] = []string{"v=spf1 include:domain3"}
dns.Txt["domain3"] = []string{"v=spf1 include:domain4"}
dns.Txt["domain4"] = []string{"v=spf1 +all"}
// The default of 10 should be plenty enough.
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1")
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
// Set the limit to 3, which is just enough.
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
OverrideLookupLimit(3))
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
// Set the limit to 2, which is not enough.
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
OverrideLookupLimit(2))
if res != PermError || err != ErrLookupLimitReached {
t.Errorf("expected permerror/lookup limit reached, got %q / %q",
res, err)
}
}
func TestOverrideVoidLookupLimit(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
nxDomainErr := &net.DNSError{
Err: "no such domain",
IsTemporary: false,
IsNotFound: true,
}
dns.Txt["domain1"] = []string{"v=spf1 exists:%{i}.one include:domain2"}
dns.Txt["domain2"] = []string{"v=spf1 exists:%{i}.two include:domain3"}
dns.Txt["domain3"] = []string{"v=spf1 exists:%{i}.three include:domain4"}
dns.Txt["domain4"] = []string{"v=spf1 +all"}
checkLimits := func() {
// The default of 2
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1")
if res != PermError {
t.Errorf("expected permerror, got %q / %q", res, err)
}
// Set the limit to 10, which is excessive.
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
OverrideVoidLookupLimit(10))
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
// Set the limit to 1, which is not enough.
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
OverrideVoidLookupLimit(1))
if res != PermError || err != ErrVoidLookupLimitReached {
t.Errorf("expected permerror/void lookup limit reached, got %q / %q",
res, err)
}
}
// First, check for NXDOMAIN.
dns.Errors["1.1.1.1.one"] = nxDomainErr
dns.Errors["1.1.1.1.two"] = nxDomainErr
dns.Errors["1.1.1.1.three"] = nxDomainErr
checkLimits()
// Then, check for empty answers (after clearing the errors).
delete(dns.Errors, "1.1.1.1.one")
delete(dns.Errors, "1.1.1.1.two")
delete(dns.Errors, "1.1.1.1.three")
dns.Ip["1.1.1.1.one"] = nil
dns.Ip["1.1.1.1.two"] = nil
dns.Ip["1.1.1.1.three"] = nil
checkLimits()
}
func TestWithContext(t *testing.T) {
dns := NewDefaultResolver()
defaultTrace = t.Logf
dns.Txt["domain1"] = []string{"v=spf1 include:domain2"}
dns.Txt["domain2"] = []string{"v=spf1 +all"}
// With a normal context.
ctx := context.Background()
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1",
WithContext(ctx))
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
// With a cancelled context.
ctx, cancelF := context.WithCancel(context.Background())
cancelF()
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
WithContext(ctx))
if res != PermError || err != context.Canceled {
t.Errorf("expected permerror/context cancelled, got %q / %q", res, err)
}
}
func TestWithResolver(t *testing.T) {
// Use a custom resolver, making sure it's different from the default.
defaultResolver = newTestResolver()
dns := newTestResolver()
defaultTrace = t.Logf
dns.Txt["domain1"] = []string{"v=spf1 include:domain2"}
dns.Txt["domain2"] = []string{"v=spf1 +all"}
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1",
WithResolver(dns))
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
}
// Test some corner cases when resolver.LookupIPAddr returns an invalid
// address. This can happen if using a buggy custom resolver.
func TestBadResolverResponse(t *testing.T) {
dns := newTestResolver()
defaultTrace = t.Logf
// When LookupIPAddr returns an invalid ip, for an "a" field.
dns.Ip["domain1"] = []net.IP{nil}
dns.Txt["domain1"] = []string{"v=spf1 a:domain1 -all"}
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1",
WithResolver(dns))
if res != Fail {
t.Errorf("expected fail, got %q / %q", res, err)
}
// Same as above, except the field has a mask.
dns.Ip["domain1"] = []net.IP{nil}
dns.Txt["domain1"] = []string{"v=spf1 a:domain1//24 -all"}
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
WithResolver(dns))
if res != Fail {
t.Errorf("expected fail, got %q / %q", res, err)
}
// When LookupIPAddr returns an invalid ip, for an "mx" field.
dns.Ip["mx.domain1"] = []net.IP{nil}
dns.Mx["domain1"] = []*net.MX{mx("mx.domain1", 5)}
dns.Txt["domain1"] = []string{"v=spf1 mx:domain1 -all"}
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
WithResolver(dns))
if res != Fail {
t.Errorf("expected fail, got %q / %q", res, err)
}
// Same as above, except the field has a mask.
dns.Ip["mx.domain1"] = []net.IP{nil}
dns.Mx["domain1"] = []*net.MX{mx("mx.domain1", 5)}
dns.Txt["domain1"] = []string{"v=spf1 mx:domain1//24 -all"}
res, err = CheckHostWithSender(ip1111, "helo", "user@domain1",
WithResolver(dns))
if res != Fail {
t.Errorf("expected fail, got %q / %q", res, err)
}
}
func TestWithTraceFunc(t *testing.T) {
calls := 0
var trace TraceFunc = func(f string, a ...any) {
calls++
t.Logf("tracing "+f, a...)
}
dns := NewDefaultResolver()
dns.Txt["domain1"] = []string{"v=spf1 include:domain2"}
dns.Txt["domain2"] = []string{"v=spf1 +all"}
// Do a normal resolution, check it passes.
res, err := CheckHostWithSender(ip1111, "helo", "user@domain1",
WithTraceFunc(trace))
if res != Pass {
t.Errorf("expected pass, got %q / %q", res, err)
}
if calls == 0 {
t.Errorf("expected >0 trace function calls, got 0")
}
}