BUG: SPF parsing no longer rejects the bare "ptr" mechanism - #4640
Open
shuvamk wants to merge 1 commit into
Open
BUG: SPF parsing no longer rejects the bare "ptr" mechanism#4640shuvamk wants to merge 1 commit into
shuvamk wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
spflib.Parseacceptsptr:<domain>but rejects the bareptrmechanism. Run againstmain@fdc38db6:mainv=spf1 ptr -allERROR: unsupported SPF part ptrv=spf1 a mx ptr -allERROR: unsupported SPF part ptrv=spf1 mx ptr ip4:107.161.151.0/24 ~allERROR: unsupported SPF part ptrv=spf1 +ptr -allERROR: unsupported SPF part ptrv=spf1 ptr:example.com -allv=spf1 a -all/v=spf1 mx -allRFC 7208 §5.5 gives the mechanism as
so the
domain-specis optional, and the section closes with "It is, however, still inuse as part of the SPF protocol, so compliant
check_host()implementations MUST supportit." Bare
ptris the only bare-form mechanismParse()rejects — bareaandmxare picked up by the
HasPrefix(lcpart, "a") || HasPrefix(lcpart, "mx")test above it.The operator cannot always fix their own record
Parse()recurses intoinclude:targets, so the rejection can come from a third party.Resolved today, 2026-08-01:
(Both names also publish unrelated verification TXT records; the
grepkeeps the outputreadable.)
A TXT record carrying
flattenorsplitmetadata that includesftstatic.comthereforefails to normalize at
pkg/normalize/flatten.go:43, on a mechanism the operator does notpublish and cannot change. Domains publishing bare
ptrdirectly, 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:andredirect=target reachable from them to depth 2, allresolved 2026-08-01. 2243 domain→record pairs, 1436 distinct records.
mainAll 8 of the newly-accepted records are bare
ptrand are otherwise valid SPF. The9 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.comandinclude: spf.protection.outlook.com),include=sendgrid.net,exp=…, a doubledv=spf1inside one record, a barespf1.dm.aliyun.comwith noinclude:, 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 lookupcounts — 0 differing. The only behavioural delta in the whole corpus is the 8 records that
stop being rejected.
Bare
ptraccounts for 8 of the 17 rejections (47%). In the same corpus 8 records usebare
ptrand only 2 useptr:<domain>— dnscontrol supports the rarer form and rejectsthe commoner one.
Cause
The
ptr:test arrived in 95ebf1d "Include PTR types in SPF Builder (#378)"(2018-08-03) as a one-line addition:
It has not been touched since, the no-argument form was never covered, and no test pinned
the rejection. The one existing
ptrcase inparse_test.gois a positive test forptr:sparkpostmail.com.Fix
One line in
pkg/spflib/parse.go:ptris a lookup mechanism, soIsLookupis set and it counts against the RFC 7208 §4.6.4limit of 10, exactly as
ptr:<domain>already does. Qualified forms work without furtherchange because the qualifier is stripped from
partbefore this branch.Flatten()copiesa
ptrpart through unchanged (IncludeRecord == nil), verified end to end on theftstatic.com→flashtalking.netchain: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.netis not yours toedit, 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 acceptPTR. I did not do that:tokens present are a version tag (
v=SPF1), hex in an IPv6 literal, and twoinclude:arguments — in every case the keyword itself is lowercase.
So
PTRstays rejected, exactly as it is onmaintoday. Happy to switch tolcpartifyou would rather have the leniency.
Tests
TestParsePtrMechanisminpkg/spflib/parse_test.go, table-driven in the same style asTestParseQualifiedMechanisms. It pins both directions: bareptr,ptramong othermechanisms with the lookup count checked, all three qualified forms,
ptr:<domain>stillaccepted, and
ptrfoo/ptrfoo:example.comstill rejected.Verified it fails without the fix. In a detached worktree at the PR commit, reverting
only
pkg/spflib/parse.go: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
nilas the resolver, asTestSplitandTestMultiStringSplitalready 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 theexisting
TestParsefails withconnect: 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@fdc38db6and on the branch:maingo test -count=1 ./...golangci-lint runstaticcheck ./...go build+bin/fmtjson+go mod tidy+go generate ./...+go fmt ./...+go fix ./...thengit diff --exit-codeBIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BINDmainis 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/spflibfeeds a//go:generatedirective.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 5trailer. Theheuristic 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 bareHasPrefix(part, "ptr:"). Every DNS fact above was re-resolved live at the time ofwriting, the corpus sweep was executed rather than estimated, and the RFC quotes were
checked against the published text of RFC 7208 rather than recalled.