git » spf » commit 42e3519

Fix DNS void lookup limit checks

author Alberto Bertogli
2026-08-16 13:16:50 UTC
committer Alberto Bertogli
2026-08-16 13:16:50 UTC
parent e1c8fc685fa67b85bde8577b92e36db2c16b2834

Fix DNS void lookup limit checks

The RFC text for "void lookups" is:

> As described at the end of Section 11.1, there may be cases where it
> is useful to limit the number of "terms" for which DNS queries return
> either a positive answer (RCODE 0) with an answer count of 0, or a
> "Name Error" (RCODE 3) answer.  These are sometimes collectively
> referred to as "void lookups".  SPF implementations SHOULD limit
> "void lookups" to two.  An implementation MAY choose to make such a
> limit configurable.  In this case, a default of two is RECOMMENDED.
> Exceeding the limit produces a "permerror" result.

https://datatracker.ietf.org/doc/html/rfc7208#section-4.6.4

Today, we count void lookups as they happen, but only check the limit
when moving on to the next term. If there is no next term, the excess
goes unnoticed: a record ending in a void lookup that goes over the
limit is evaluated to the end, and returns neutral instead of permerror.

The same happens across an "include": the recursive check returns
neutral, so the include is treated as not matching, instead of making
the evaluation return permerror.

The DNS lookup limit had the same problem, and was fixed in commit
8f601a66eb0d7ddb3b976950a42c3c92047891d8. This patch does the equivalent
for the void lookup counter: check the limit in countVoidLookup, right
after incrementing it, and stop the evaluation there.

Note that unlike the lookup limit, we can only tell a lookup was void
after doing it, so the check has to happen after the lookup and not
before it.

spf.go +26 -20
testdata/blitirispf-tests.yml +63 -0

diff --git a/spf.go b/spf.go
index 97d2208..4ed1539 100644
--- a/spf.go
+++ b/spf.go
@@ -357,11 +357,6 @@ func (r *resolution) Check(domain string) (Result, error) {
 			continue
 		}
 
-		if r.voidcount > r.maxvoidcount {
-			r.trace("void lookup limit reached")
-			return PermError, ErrVoidLookupLimitReached
-		}
-
 		// See if we have a qualifier, defaulting to + (pass).
 		// https://tools.ietf.org/html/rfc7208#section-4.6.2
 		result, ok := qualToResult[field[0]]
@@ -492,23 +487,26 @@ func (r *resolution) countLookup() error {
 }
 
 // Check if the given DNS error is a "void lookup" (0 answers, or nxdomain),
-// and if so increment the void lookup counter.
-func (r *resolution) checkVoidLookup(nanswers int, err error) {
+// and return an error if the limit was exceeded.
+// Unlike countLookup, we can only tell a lookup was void after doing it, so
+// fields must call this right after the lookup, and stop the evaluation if it
+// returns an error.
+// https://tools.ietf.org/html/rfc7208#section-4.6.4
+func (r *resolution) countVoidLookup(nanswers int, err error) error {
+	derr, isDNSErr := err.(*net.DNSError)
 	if err == nil && nanswers == 0 {
 		r.voidcount++
 		r.trace("void lookup: no answers")
-		return
-	}
-
-	derr, ok := err.(*net.DNSError)
-	if !ok {
-		return
-	}
-
-	if derr.IsNotFound {
+	} else if isDNSErr && derr.IsNotFound {
 		r.voidcount++
 		r.trace("void lookup: nxdomain")
 	}
+
+	if r.voidcount > r.maxvoidcount {
+		r.trace("void lookup limit reached")
+		return ErrVoidLookupLimitReached
+	}
+	return nil
 }
 
 // ipField processes an "ip" field.
@@ -560,7 +558,9 @@ func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, e
 		}
 		r.ipNames = []string{}
 		ns, err := r.resolver.LookupAddr(r.ctx, r.ip.String())
-		r.checkVoidLookup(len(ns), err)
+		if verr := r.countVoidLookup(len(ns), err); verr != nil {
+			return true, PermError, verr
+		}
 		if err != nil {
 			// https://tools.ietf.org/html/rfc7208#section-5
 			if isNotFound(err) {
@@ -625,7 +625,9 @@ func (r *resolution) existsField(res Result, field, domain string) (bool, Result
 		return true, PermError, err
 	}
 	ips, err := r.resolver.LookupIPAddr(r.ctx, eDomain)
-	r.checkVoidLookup(len(ips), err)
+	if verr := r.countVoidLookup(len(ips), err); verr != nil {
+		return true, PermError, verr
+	}
 	if err != nil {
 		// https://tools.ietf.org/html/rfc7208#section-5
 		if isNotFound(err) {
@@ -759,7 +761,9 @@ func (r *resolution) aField(res Result, field, domain string) (bool, Result, err
 		return true, PermError, err
 	}
 	ips, err := r.resolver.LookupIPAddr(r.ctx, aDomain)
-	r.checkVoidLookup(len(ips), err)
+	if verr := r.countVoidLookup(len(ips), err); verr != nil {
+		return true, PermError, verr
+	}
 	if err != nil {
 		// https://tools.ietf.org/html/rfc7208#section-5
 		if isNotFound(err) {
@@ -794,7 +798,9 @@ func (r *resolution) mxField(res Result, field, domain string) (bool, Result, er
 		return true, PermError, err
 	}
 	mxs, err := r.resolver.LookupMX(r.ctx, mxDomain)
-	r.checkVoidLookup(len(mxs), err)
+	if verr := r.countVoidLookup(len(mxs), err); verr != nil {
+		return true, PermError, verr
+	}
 
 	// If we get some results, use them even if we get an error alongisde.
 	// This happens when one of the records is invalid, because Go library can
diff --git a/testdata/blitirispf-tests.yml b/testdata/blitirispf-tests.yml
index b6f8f62..cc2c002 100644
--- a/testdata/blitirispf-tests.yml
+++ b/testdata/blitirispf-tests.yml
@@ -318,6 +318,69 @@ zonedata:
   4.3.2.1.in-addr.arpa:
     - PTR: ptrmatch.com
 ---
+description: Void lookup limit
+tests:
+  void-limit-at-2:
+    description: |
+      Check that 2 void lookups (the default limit) are allowed, and that the
+      terms after them are still evaluated.
+    mailfrom: "foo@atvoid2"
+    host: 1.2.3.4
+    result: pass
+  void-limit-over-a:
+    description: |
+      Check that exceeding the void lookup limit on an "a" term causes a
+      permerror, even when it is the last term. The limit must be checked
+      right after the lookup: checking it only when moving on to the next
+      term lets the excess go unnoticed if there is no next term.
+    mailfrom: "foo@overvoid-a"
+    host: 1.2.3.4
+    result: permerror
+  void-limit-over-mx:
+    description: |
+      Same as void-limit-over-a, for the "mx" mechanism. The domain exists but
+      has no MX records, which is a void lookup.
+    mailfrom: "foo@overvoid-mx"
+    host: 1.2.3.4
+    result: permerror
+  void-limit-over-exists:
+    description: |
+      Same as void-limit-over-a, for the "exists" mechanism.
+    mailfrom: "foo@overvoid-exists"
+    host: 1.2.3.4
+    result: permerror
+  void-limit-over-ptr:
+    description: |
+      Same as void-limit-over-a, for the "ptr" mechanism. The address has no
+      PTR records, which is a void lookup.
+    mailfrom: "foo@overvoid-ptr"
+    host: 1.2.3.4
+    result: permerror
+  void-limit-over-in-include:
+    description: |
+      Check that exceeding the void lookup limit inside an "include" makes the
+      whole evaluation return permerror, instead of just making the include
+      not match. Note the include is the last term, so nothing after it can
+      turn the excess into a permerror on its own.
+    mailfrom: "foo@overvoid-include"
+    host: 1.2.3.4
+    result: permerror
+zonedata:
+  atvoid2:
+    - SPF: v=spf1 a:void1 a:void2 ip4:1.2.3.4 -all
+  overvoid-a:
+    - SPF: v=spf1 a:void1 a:void2 a:void3
+  overvoid-mx:
+    - SPF: v=spf1 a:void1 a:void2 mx:onlya
+  overvoid-exists:
+    - SPF: v=spf1 a:void1 a:void2 exists:void3
+  overvoid-ptr:
+    - SPF: v=spf1 a:void1 a:void2 ptr
+  overvoid-include:
+    - SPF: v=spf1 include:overvoid-a
+  onlya:
+    - A: 1.2.3.4
+---
 description: MX resolution limits
 tests:
   mx-resolution-10-terms: