git » spf » commit 0ae0998

ptr: Fix subdomain checking

author Alberto Bertogli
2026-08-17 21:48:19 UTC
committer Alberto Bertogli
2026-08-22 08:58:13 UTC
parent 991f9bdde46b13b219065fb8d6a4d7ce9215b892

ptr: Fix subdomain checking

As per RFC, for ptr matches we want to check the domain is the same, or
a subdomain. From https://tools.ietf.org/html/rfc7208#section-5.5:

> Check all validated domain names to see if they either match the
>  <target-name> domain or are a subdomain of the <target-name> domain.
>  If any do, this mechanism matches.  If no validated domain name can
>  be found, or if none of the validated domain names match or are a
>  subdomain of the <target-name>, this mechanism fails to match.

Today we check this with a plain suffix comparison, so the match can
happen in the middle of a label: "notexample.com" is accepted as a match
for "ptr:example.com". This is a bug, and can cause incorrect passes.

This patch fixes the problem by using a new isSubdomain helper.

To implement isSubdomain, we have to do ASCII-only case-insensitive
comparison, due to dubtleties around DNS case-sensitivity rules, and the
possibility of non-ASCII data in the names.

spf.go +63 -5
spf_test.go +95 -0
testdata/blitirispf-tests.yml +58 -0

diff --git a/spf.go b/spf.go
index a8adf06..b976c44 100644
--- a/spf.go
+++ b/spf.go
@@ -625,9 +625,7 @@ func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, e
 			r.trace("ptr forward resolution %q -> %q", n, addrs)
 			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))
+					r.ipNames = append(r.ipNames, n)
 					break
 				}
 			}
@@ -635,9 +633,8 @@ func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, e
 	}
 
 	r.trace("ptr evaluating %q in %q", ptrDomain, r.ipNames)
-	ptrDomain = strings.ToLower(ptrDomain)
 	for _, n := range r.ipNames {
-		if strings.HasSuffix(n, ptrDomain+".") {
+		if isSubdomain(n, ptrDomain) {
 			r.trace("ptr match: %q", n)
 			return true, res, ErrMatchedPTR
 		}
@@ -646,6 +643,67 @@ func (r *resolution) ptrField(res Result, field, domain string) (bool, Result, e
 	return false, "", nil
 }
 
+// isSubdomain returns whether the given name is the domain itself, or a
+// subdomain of it. A trailing dot on either of them is ignored, so it can be
+// used to compare the fully qualified names we get from DNS against the ones
+// that appear in a record.
+//
+// Note the match has to be on a label boundary: "notexample.com" is NOT a
+// subdomain of "example.com".
+// https://tools.ietf.org/html/rfc7208#section-5.5
+func isSubdomain(name, domain string) bool {
+	nameLabels := labels(name)
+	domainLabels := labels(domain)
+
+	// The name has to have at least as many labels as the domain.
+	if len(nameLabels) < len(domainLabels) {
+		return false
+	}
+
+	// And its trailing labels have to be the domain's.
+	nameLabels = nameLabels[len(nameLabels)-len(domainLabels):]
+	return slices.EqualFunc(nameLabels, domainLabels, asciiEqualFold)
+}
+
+// labels splits a domain name into its labels, ignoring the trailing dot.
+func labels(domain string) []string {
+	return strings.Split(strings.TrimSuffix(domain, "."), ".")
+}
+
+// asciiEqualFold is like strings.EqualFold, but it only considers ASCII
+// letters to be case-insensitive.
+//
+// DNS names are octet strings, compared octet by octet, and only ASCII
+// letters are case-insensitive; so comparing bytes is exactly the DNS rule,
+// and it holds even if the name is not valid UTF-8.
+//
+// An Unicode-aware comparison would be incorrect: it can consider equal two
+// names that DNS considers different (for example, strings.ToLower turns the
+// U+212A KELVIN SIGN into an ASCII "k"). Note we don't enforce that records
+// are 7-bit ASCII, so non-ASCII names do reach this.
+//
+// Folding only ASCII is safe to do byte by byte because no byte of a
+// multi-byte UTF-8 sequence is < 0x80, so we can never alter one by mistake.
+// https://tools.ietf.org/html/rfc4343
+func asciiEqualFold(a, b string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for i := range len(a) {
+		if asciiToLower(a[i]) != asciiToLower(b[i]) {
+			return false
+		}
+	}
+	return true
+}
+
+func asciiToLower(c byte) byte {
+	if 'A' <= c && c <= 'Z' {
+		return c + ('a' - 'A')
+	}
+	return c
+}
+
 // existsField processes a "exists" field.
 // https://tools.ietf.org/html/rfc7208#section-5.7
 func (r *resolution) existsField(res Result, field, domain string) (bool, Result, error) {
diff --git a/spf_test.go b/spf_test.go
index 9cc523c..05f426f 100644
--- a/spf_test.go
+++ b/spf_test.go
@@ -509,6 +509,101 @@ 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
diff --git a/testdata/blitirispf-tests.yml b/testdata/blitirispf-tests.yml
index 0e14493..9cdbac0 100644
--- a/testdata/blitirispf-tests.yml
+++ b/testdata/blitirispf-tests.yml
@@ -564,3 +564,61 @@ zonedata:
     - PTR: bad.com
     - PTR: badv6.com
     - PTR: many.com
+---
+description: PTR domain matching
+tests:
+  ptr-exact:
+    description: |
+      The validated name is the target domain itself, so it matches.
+    mailfrom: "foo@exact.com"
+    host: 1.2.3.4
+    result: pass
+  ptr-subdomain:
+    description: |
+      The validated name is a subdomain of the target domain, so it matches.
+    mailfrom: "foo@sub.com"
+    host: 1.2.3.5
+    result: pass
+  ptr-not-subdomain:
+    description: |
+      The validated name ends with the target domain as a string, but not at
+      a label boundary, so it must NOT match: RFC 7208 section 5.5 requires
+      the name to be the target domain, or a subdomain of it.
+    mailfrom: "foo@notsub.com"
+    host: 1.2.3.6
+    result: fail
+  ptr-target-trailing-dot:
+    description: |
+      A target domain written with a trailing dot matches the same names as
+      one written without it.
+    mailfrom: "foo@dot.com"
+    host: 1.2.3.7
+    result: pass
+zonedata:
+  exact.com:
+    - SPF: v=spf1 ptr:exactname.com -all
+  exactname.com:
+    - A: 1.2.3.4
+  4.3.2.1.in-addr.arpa:
+    - PTR: exactname.com
+
+  sub.com:
+    - SPF: v=spf1 ptr:subname.com -all
+  deep.sub.subname.com:
+    - A: 1.2.3.5
+  5.3.2.1.in-addr.arpa:
+    - PTR: deep.sub.subname.com
+
+  notsub.com:
+    - SPF: v=spf1 ptr:example.com -all
+  notexample.com:
+    - A: 1.2.3.6
+  6.3.2.1.in-addr.arpa:
+    - PTR: notexample.com
+
+  dot.com:
+    - SPF: v=spf1 ptr:dotname.com. -all
+  dotname.com:
+    - A: 1.2.3.7
+  7.3.2.1.in-addr.arpa:
+    - PTR: dotname.com