git » spf » commit 991f9bd

ptr: Validate names against the IP being checked

author Alberto Bertogli
2026-08-17 21:08:40 UTC
committer Alberto Bertogli
2026-08-22 08:58:13 UTC
parent 7a83916d6b64d8ef0baf3f2b12432b3eed594f49

ptr: Validate names against the IP being checked

When evaluating a `ptr`, we should check that the IP being checked is
among the ones returned, as per RFC 7208 section 5.5:

> For each record returned, validate the domain name by looking up its
> IP addresses. [...] If <ip> is among the returned IP addresses, then
> that domain name  is validated.

Today, the forward resolution only checks that the name resolves to
something, not that it includes the IP we are checking. That is a bug
and can allow some checks to pass incorrectly.

This patch fixes the bug by only adding a name to the list to check if
one of the addresses it resolves to is the IP being checked.  None of
the current tests cover this case (that's how the bug snuck in), so this
patch also adds explicits tests for it.

Note the behaviour was cross-checked against libspf2 and pyspf, and both
libraries implement it correctly.

spf.go +10 -6
spf_test.go +5 -1
testdata/blitirispf-tests.yml +51 -0

diff --git a/spf.go b/spf.go
index bc73e36..a8adf06 100644
--- a/spf.go
+++ b/spf.go
@@ -614,18 +614,22 @@ func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, e
 		}
 
 		for _, n := range ns {
-			// Validate the record by doing a forward resolution: it has to
-			// have some A/AAAA.
+			// Validate the name by doing a forward resolution: it is only
+			// validated if it resolves back to the IP we are checking.
+			// https://tools.ietf.org/html/rfc7208#section-5.5
 			addrs, err := r.resolver.LookupIPAddr(r.ctx, n)
 			if err != nil {
 				// RFC explicitly says to skip domains which error here.
 				continue
 			}
 			r.trace("ptr forward resolution %q -> %q", n, addrs)
-			if len(addrs) > 0 {
-				// Append the lower-case variants so we do a case-insensitive
-				// lookup below.
-				r.ipNames = append(r.ipNames, strings.ToLower(n))
+			for _, addr := range addrs {
+				if addr.IP.Equal(r.ip) {
+					// Append the lower-case variants so we do a
+					// case-insensitive lookup below.
+					r.ipNames = append(r.ipNames, strings.ToLower(n))
+					break
+				}
 			}
 		}
 	}
diff --git a/spf_test.go b/spf_test.go
index 5ba1623..9cc523c 100644
--- a/spf_test.go
+++ b/spf_test.go
@@ -125,7 +125,11 @@ func TestIPv6(t *testing.T) {
 		{"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},
-		{"v=spf1 ptr -all", Pass, ErrMatchedPTR},
+		// "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},
diff --git a/testdata/blitirispf-tests.yml b/testdata/blitirispf-tests.yml
index 914e353..0e14493 100644
--- a/testdata/blitirispf-tests.yml
+++ b/testdata/blitirispf-tests.yml
@@ -513,3 +513,54 @@ zonedata:
     - SPF: v=spf1 redirect=doesnotexist
   sender:
     - A: 1.2.3.4
+---
+description: PTR forward confirmation
+tests:
+  ptr-forward-confirmed:
+    description: |
+      A PTR name that resolves back to the IP being checked is validated, and
+      matches.
+    mailfrom: "foo@good.com"
+    host: 1.2.3.4
+    result: pass
+  ptr-resolves-elsewhere:
+    description: |
+      A PTR name that resolves, but to a different address, must NOT be
+      validated: RFC 7208 section 5.5 requires <ip> to be among the addresses
+      returned for the name, not just that the name resolves to something.
+    mailfrom: "foo@bad.com"
+    host: 1.2.3.4
+    result: fail
+  ptr-resolves-elsewhere-v6:
+    description: |
+      As above, but the name resolves to an address of a different family.
+    mailfrom: "foo@badv6.com"
+    host: 1.2.3.4
+    result: fail
+  ptr-one-of-many:
+    description: |
+      A name with several addresses is validated if any one of them is the IP
+      being checked.
+    mailfrom: "foo@many.com"
+    host: 1.2.3.4
+    result: pass
+zonedata:
+  good.com:
+    - SPF: v=spf1 ptr:good.com -all
+    - A: 1.2.3.4
+  bad.com:
+    - SPF: v=spf1 ptr:bad.com -all
+    - A: 9.9.9.9
+  badv6.com:
+    - SPF: v=spf1 ptr:badv6.com -all
+    - AAAA: 2001:db8::1
+  many.com:
+    - SPF: v=spf1 ptr:many.com -all
+    - A: 10.0.0.1
+    - A: 1.2.3.4
+    - A: 10.0.0.2
+  4.3.2.1.in-addr.arpa:
+    - PTR: good.com
+    - PTR: bad.com
+    - PTR: badv6.com
+    - PTR: many.com