| author | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-08-17 21:48:19 UTC |
| committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2026-08-22 08:58:13 UTC |
| parent | 991f9bdde46b13b219065fb8d6a4d7ce9215b892 |
| 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