Skip to content

BUG: SPF parsing no longer rejects the bare "ptr" mechanism - #4640

Open
shuvamk wants to merge 1 commit into
DNSControl:mainfrom
shuvamk:fix/spf-bare-ptr-mechanism
Open

BUG: SPF parsing no longer rejects the bare "ptr" mechanism#4640
shuvamk wants to merge 1 commit into
DNSControl:mainfrom
shuvamk:fix/spf-bare-ptr-mechanism

Conversation

@shuvamk

@shuvamk shuvamk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

spflib.Parse accepts ptr:<domain> but rejects the bare ptr mechanism. Run against
main @ fdc38db6:

input main expected
v=spf1 ptr -all ERROR: unsupported SPF part ptr ok, 1 lookup
v=spf1 a mx ptr -all ERROR: unsupported SPF part ptr ok, 3 lookups
v=spf1 mx ptr ip4:107.161.151.0/24 ~all ERROR: unsupported SPF part ptr ok, 2 lookups
v=spf1 +ptr -all ERROR: unsupported SPF part ptr ok, 1 lookup
v=spf1 ptr:example.com -all ok, 1 lookup unchanged
v=spf1 a -all / v=spf1 mx -all ok, 1 lookup unchanged

RFC 7208 §5.5 gives the mechanism as

ptr = "ptr" [ ":" domain-spec ]

so the domain-spec is optional, and the 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 is the only bare-form mechanism Parse() rejects — bare a and mx
are picked up by the HasPrefix(lcpart, "a") || HasPrefix(lcpart, "mx") test above it.

The operator cannot always fix their own record

Parse() recurses into include: targets, so the rejection can come from a third party.
Resolved today, 2026-08-01:

$ dig +short TXT ftstatic.com | grep spf1
"v=spf1 include:flashtalking.net ~all"

$ dig +short TXT flashtalking.net | grep spf1
"v=spf1 mx ptr ip4:107.161.151.0/24 ~all"

(Both names also publish unrelated verification TXT records; the grep keeps the output
readable.)

A TXT record carrying flatten or split metadata that includes ftstatic.com therefore
fails to normalize at pkg/normalize/flatten.go:43, on a mechanism the operator does not
publish and cannot change. Domains publishing bare ptr directly, all re-resolved today:
opendns.com, onenote.com, liveperson.net, lpsnmedia.net, weborama.fr,
dtscdn.com, servedbyadbutler.com.

Prevalence

Corpus: the SPF records of the 3000 highest-ranked two-label domains of the Cisco Umbrella
top-1m, plus every include: and redirect= target reachable from them to depth 2, all
resolved 2026-08-01. 2243 domain→record pairs, 1436 distinct records.

main this PR
records rejected 17 9

All 8 of the newly-accepted records are bare ptr and are otherwise valid SPF. The
9 that still fail are genuinely malformed and should keep failing: v=SPF1 ?all
(uppercase version tag), two records with a space after include:
(include: _spf.google.com and include: spf.protection.outlook.com),
include=sendgrid.net, exp=…, a doubled v=spf1 inside one record, a bare
spf1.dm.aliyun.com with no include:, and two records published with no spaces at all.

The change is strictly widening, measured rather than argued: of the 1419 records that
parse on main, all 1419 parse on this branch to identical parts and identical lookup
counts — 0 differing. The only behavioural delta in the whole corpus is the 8 records that
stop being rejected.

Bare ptr accounts for 8 of the 17 rejections (47%). In the same corpus 8 records use
bare ptr and only 2 use ptr:<domain> — dnscontrol supports the rarer form and rejects
the commoner one.

Cause

The ptr: test arrived in 95ebf1d "Include PTR types in SPF Builder (#378)"
(2018-08-03) as a one-line addition:

-	} else if strings.HasPrefix(part, "exists:") {
+	} else if strings.HasPrefix(part, "exists:") || strings.HasPrefix(part, "ptr:") {

It has not been touched since, the no-argument form was never covered, and no test pinned
the rejection. The one existing ptr case in parse_test.go is a positive test for
ptr:sparkpostmail.com.

Fix

One line in pkg/spflib/parse.go:

} else if strings.HasPrefix(part, "exists:") || part == "ptr" || strings.HasPrefix(part, "ptr:") {

ptr is a lookup mechanism, so IsLookup is set and it counts against the RFC 7208 §4.6.4
limit of 10, exactly as ptr:<domain> already does. Qualified forms work without further
change because the qualifier is stripped from part before this branch. Flatten() copies
a ptr part through unchanged (IncludeRecord == nil), verified end to end on the
ftstatic.comflashtalking.net chain:

parsed   : v=spf1 include:flashtalking.net ~all   (lookups=3)
flatten *: v=spf1 mx ptr ip4:107.161.151.0/24 ~all

The counterargument, stated plainly

RFC 7208 §5.5 is titled "ptr (do not use)" and says the mechanism SHOULD NOT be
published. Prevalence is 8/2243 = 0.36% of resolved records. The failure is a loud error,
not silent corruption. And for a record you publish yourself there is a workaround —
write ptr:<yourdomain>, which dnscontrol already accepts.

What has no workaround is the include-target case above: flashtalking.net is not yours to
edit, and RFC 7208 requires implementations to support what it publishes. That, plus the
fact that this is the single largest category of rejection in a real-world corpus, is the
argument for the one line. If you read the RFC's "do not use" as license to keep rejecting
it, that is a legitimate call and I will close this.

Alternative I considered and recommend against

lcpart == "ptr" would also accept PTR. I did not do that:

  • 819253a (BUGFIX: Be case-insensitive when parsing SPF records #3982) scoped case-insensitivity deliberately to "a, mx, ip4: and ip6:".
  • Uppercase mechanism keywords are 0 of 1436 records in the corpus. The only uppercase
    tokens present are a version tag (v=SPF1), hex in an IPv6 literal, and two include:
    arguments — in every case the keyword itself is lowercase.

So PTR stays rejected, exactly as it is on main today. Happy to switch to lcpart if
you would rather have the leniency.

Tests

TestParsePtrMechanism in pkg/spflib/parse_test.go, table-driven in the same style as
TestParseQualifiedMechanisms. It pins both directions: bare ptr, ptr among other
mechanisms with the lookup count checked, all three qualified forms, ptr:<domain> still
accepted, and ptrfoo / ptrfoo:example.com still rejected.

Verified it fails without the fix. In a detached worktree at the PR commit, reverting
only pkg/spflib/parse.go:

$ git checkout HEAD^ -- pkg/spflib/parse.go
$ go test -count=1 ./pkg/spflib/ -run TestParsePtrMechanism -v
--- FAIL: TestParsePtrMechanism (0.00s)
    --- FAIL: TestParsePtrMechanism/v=spf1_ptr_-all
    --- FAIL: TestParsePtrMechanism/v=spf1_a_mx_ptr_-all
    --- FAIL: TestParsePtrMechanism/v=spf1_mx_ptr_ip4:107.161.151.0/24_~all
    --- FAIL: TestParsePtrMechanism/v=spf1_+ptr_-all
    --- FAIL: TestParsePtrMechanism/v=spf1_~ptr_-all
    --- FAIL: TestParsePtrMechanism/v=spf1_?ptr_-all
    --- PASS: TestParsePtrMechanism/v=spf1_ptr:example.com_-all
    --- PASS: TestParsePtrMechanism/v=spf1_ptrfoo_-all
    --- PASS: TestParsePtrMechanism/v=spf1_ptrfoo:example.com_-all
FAIL

Restoring the one line, all 9 pass. The 3 that pass in both states are the ones pinning
existing behaviour, so they are not just riding along.

The test passes nil as the resolver, as TestSplit and TestMultiStringSplit already do,
so it makes no DNS queries. Confirmed empirically rather than by inspection — under
sandbox-exec -p '(version 1)(allow default)(deny network*)' the new test passes while the
existing TestParse fails with connect: operation not permitted.

Local results

CI will not run here until you approve the workflow (first-timer gate), so this is the full
local gate, on main @ fdc38db6 and on the branch:

command main branch
go test -count=1 ./... 47 ok, 59 no-test-files, 0 FAIL identical
golangci-lint run 0 issues 0 issues
staticcheck ./... 0 issues 0 issues
go build + bin/fmtjson + go mod tidy + go generate ./... + go fmt ./... + go fix ./... then git diff --exit-code exit 0, 0-file diff
BIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BIND ok, 0.57s

main is green; there is no pre-existing failure to disclose.

Diff is 1 line of source plus 28 lines of test. No regeneration needed — nothing under
pkg/spflib feeds a //go:generate directive.


Disclosure, since you asked on #4630: this was found and written with Claude Code
assistance, and the commit carries the Co-Authored-By: Claude Opus 5 trailer. The
heuristic this time was look for an optional element in an RFC ABNF production that the
parser treats as mandatory
ptr = "ptr" [ ":" domain-spec ] against a bare
HasPrefix(part, "ptr:"). Every DNS fact above was re-resolved live at the time of
writing, the corpus sweep was executed rather than estimated, and the RFC quotes were
checked against the published text of RFC 7208 rather than recalled.

Parse() accepted "ptr:<domain>" 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:<domain>" 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:<domain>", so the supported form is the rarer of the two.

The "ptr:" prefix test arrived in 95ebf1d "Include PTR types in SPF Builder
(DNSControl#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 819253a
(DNSControl#3982) is not widened here. "PTR" stays rejected, as it is today, and
"ptrfoo" stays rejected too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shuvamk
shuvamk requested a review from TomOnTime as a code owner August 1, 2026 15:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant