From 72923faaed230e5b0f4f4556cc269a119b52a8c4 Mon Sep 17 00:00:00 2001 From: Shuvam Kumar Date: Sat, 1 Aug 2026 02:43:05 +0530 Subject: [PATCH] BUG: SPF parsing no longer rejects the bare "ptr" mechanism Parse() accepted "ptr:" but rejected "ptr" with no argument: v=spf1 ptr -all ERROR: unsupported SPF part ptr v=spf1 a mx ptr -all ERROR: unsupported SPF part ptr v=spf1 ptr:example.com -all ok RFC 7208 section 5.5 gives the mechanism as ptr = "ptr" [ ":" domain-spec ] so the domain-spec is optional, and the same section closes with "It is, however, still in use as part of the SPF protocol, so compliant check_host() implementations MUST support it". Bare "ptr" was the only bare-form mechanism Parse() rejected: bare "a" and "mx" are accepted by the HasPrefix test above it, and "ptr:" by the branch this changes. An operator cannot always work around it by editing their own record, because Parse() recurses into include: targets. ftstatic.com publishes v=spf1 include:flashtalking.net ~all and flashtalking.net publishes v=spf1 mx ptr ip4:107.161.151.0/24 ~all so a TXT record carrying flatten or split metadata that includes ftstatic.com fails to normalize on a mechanism the operator does not publish and cannot change. Domains publishing bare "ptr" directly today include opendns.com, onenote.com, liveperson.net, lpsnmedia.net, weborama.fr, dtscdn.com and servedbyadbutler.com. Measured over the SPF records of the 3000 highest-ranked two-label domains of the Cisco Umbrella top-1m plus their include: and redirect= targets to depth 2, resolved 2026-08-01: 1436 distinct records, 17 of which Parse() rejects. Eight of those 17 are bare "ptr" and are otherwise valid; the remaining nine are genuinely malformed. Eight records in the corpus use bare "ptr" and two use "ptr:", so the supported form is the rarer of the two. The "ptr:" prefix test arrived in 95ebf1d3 "Include PTR types in SPF Builder (#378)" (2018-08-03) as a one-line addition and has not been touched since. The no-argument form was never covered, and no test pinned its rejection. Match "ptr" exactly rather than comparing the lowercased part, so that the case-insensitivity deliberately scoped to a, mx, ip4: and ip6: in 819253a5 (#3982) is not widened here. "PTR" stays rejected, as it is today, and "ptrfoo" stays rejected too. Co-Authored-By: Claude Opus 5 --- pkg/spflib/parse.go | 2 +- pkg/spflib/parse_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/pkg/spflib/parse.go b/pkg/spflib/parse.go index 12415fd762..daf5672e3c 100644 --- a/pkg/spflib/parse.go +++ b/pkg/spflib/parse.go @@ -93,7 +93,7 @@ func Parse(text string, dnsres Resolver) (*SPFRecord, error) { return nil, fmt.Errorf("in included SPF: %w", err) } } - } else if strings.HasPrefix(part, "exists:") || strings.HasPrefix(part, "ptr:") { + } else if strings.HasPrefix(part, "exists:") || part == "ptr" || strings.HasPrefix(part, "ptr:") { p.IsLookup = true } else { return nil, fmt.Errorf("unsupported SPF part %s", part) diff --git a/pkg/spflib/parse_test.go b/pkg/spflib/parse_test.go index fe5e76d569..e148130c6a 100644 --- a/pkg/spflib/parse_test.go +++ b/pkg/spflib/parse_test.go @@ -151,6 +151,35 @@ func TestParseQualifiedMechanisms(t *testing.T) { } } +func TestParsePtrMechanism(t *testing.T) { + tests := []struct { + input string + wantErr bool + lookups int + }{ + {"v=spf1 ptr -all", false, 1}, + {"v=spf1 a mx ptr -all", false, 3}, + {"v=spf1 mx ptr ip4:107.161.151.0/24 ~all", false, 2}, + {"v=spf1 +ptr -all", false, 1}, + {"v=spf1 ~ptr -all", false, 1}, + {"v=spf1 ?ptr -all", false, 1}, + {"v=spf1 ptr:example.com -all", false, 1}, + {"v=spf1 ptrfoo -all", true, 0}, + {"v=spf1 ptrfoo:example.com -all", true, 0}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + rec, err := Parse(tt.input, nil) + if (err != nil) != tt.wantErr { + t.Fatalf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + if err == nil && rec.Lookups() != tt.lookups { + t.Errorf("Parse(%q) lookups = %d, want %d", tt.input, rec.Lookups(), tt.lookups) + } + }) + } +} + func TestParseRedirectLast(t *testing.T) { dnsres, err := NewCache("testdata-dns1.json") if err != nil {