BUG: SPF parsing detects include loops instead of crashing with a stack overflow - #4641
Open
shuvamk wants to merge 1 commit into
Open
BUG: SPF parsing detects include loops instead of crashing with a stack overflow#4641shuvamk wants to merge 1 commit into
shuvamk wants to merge 1 commit into
Conversation
Parse() called itself for every include: and redirect= term it resolved, with no record of the chain it was already resolving. A cyclic chain -- a domain that includes itself, or two domains that include each other -- recursed until the process died with "fatal error: stack overflow", exit code 2, ~500 lines of traceback, in under a second. A Go stack overflow is a runtime.throw, not a panic, so recover() cannot turn it into an error: it takes the whole dnscontrol run down. The chain being walked is a third party's data. An operator who marks a TXT record flatten: or split: has dnscontrol resolve whatever the vendor publishes at preview/push time. Thread the chain of domains currently being resolved through an unexported helper and refuse to descend into a domain that is already in its own ancestry. The exported signature is unchanged. The set is scoped to the current path, not global: it is extended on descent and unwound on return. A domain reached twice through two independent branches (a and b both including shared.example.com) is not a loop and parses today, so a global set would reject records that currently work. The chain comparison is case-insensitive, consistent with 819253a ("fix(spf): Be case-insensitive when parsing SPF records"). It is not needed to stop the recursion: with an exact match the loop is still caught, one hop later, with a redundant node in the reported chain. This does not enforce the RFC 7208 4.6.4 ten-lookup cap. That bounds deep-but-finite chains too, which is what flattening exists to fix. With the fix, the reproducer in DNSControl#4633 exits 1 with ERROR: in included SPF: SPF include loop: vendor.example -> vendor.example Fixes DNSControl#4633 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.
Implements direction 1 (visited-set) from #4633, as you asked. Fixes #4633.
The problem
Parse()recursed into everyinclude:andredirect=it resolved without keeping any record of the chain it was already resolving, so a cyclic chain never terminated. All five inputs below are hermetic (afakeResolvermap, no network); each is a test case in this PR. The "this PR" column shows the loop portion of the error — the full string also carries the existingin included SPF:wrapper, and it is that full string the tests assert.Parse()onmain@ fdc38dba→v=spf1 include:a ~allfatal error: stack overflowSPF include loop: a.example.com -> a.example.coma→include:b,b→include:afatal error: stack overflowSPF include loop: a.example.com -> b.example.com -> a.example.coma→v=spf1 redirect=afatal error: stack overflowSPF include loop: a.example.com -> a.example.com?include:a,a→v=spf1 +include:a ~allfatal error: stack overflowSPF include loop: a.example.com -> a.example.coma→v=spf1 include:A.EXAMPLE.COM ~all,A.EXAMPLE.COM→include:afatal error: stack overflowSPF include loop: a.example.com -> A.EXAMPLE.COMEnd to end, using the reproducer from the issue (
dnsconfig.jswithTXT("@", "v=spf1 include:vendor.example ~all", {flatten: "all"})and anspfcache.jsonentry pointingvendor.exampleat itself):main@ fdc38db — exit 2, 508 lines of output, 85spflib.Parseframes, 1.7 s wall:this PR — exit 1, 4 lines:
The
WARNINGis not from this change: it is the cache's staleness check re-resolvingvendor.example, which does not exist. Themainbinary prints the same line when the cache entry for that domain is made non-cyclic (v=spf1 ip4:192.0.2.0/24 ~all), so it is orthogonal — it simply never got the chance to print while the process was dying.A Go stack overflow is a
runtime.throw, not a panic, sorecover()cannot catch it — nothing aboveParse()could turn this into an error message. Executed against pristinemain: wrapping theParsecall indefer func(){ recover() }()neither recovers nor reaches the statement after it; the test binary dies withfatal error: stack overflowhaving printed neither log line. And the chain being walked is a third party's data: an operator who marks a TXT recordflatten:orsplit:has dnscontrol resolve whatever the vendor publishes at everypreview/push.Cause
pkg/spflib/parse.go:91calledParse(subRecord, dnsres)with no depth counter and no visited set. The recursive call has been there since the package was added — 01a2424, 2017-05-25, "Initial DNS Resolvers and SPF scaffolding (#123)". (The issue body credits 823e8bb for this; that commit added the flattener, andgit log -S 'IncludeRecord, err = Parse'puts the recursion in #123 four months earlier. My mistake there, corrected here.)The fix
+17/−1 in
pkg/spflib/parse.go.Parsekeeps its exact signature and seeds an unexportedparse(text, dnsres, chain []string);chainholds the domains currently being resolved, andparserefuses to descend into one already in its own ancestry.Four properties, in the order they matter:
No exported surface changes.
Parse(text string, dnsres Resolver) (*SPFRecord, error)is untouched, sopkg/normalize/flatten.go,docs/flattener/js.goand the existing tests need no edits.The chain is scoped to the current path, not global. It is extended on descent and unwound on return. A domain legitimately reached twice through independent branches is not a loop, and that shape parses today:
A single set shared across the whole walk would call that a loop and break a record that works now.
TestParseSharedIncludeIsNotALooppins it, and it passes both with and without the fix.The key is the domain, not
SPFPart.Text.Textkeeps the qualifier (+include:,?include:), so matching on it would let a qualified loop through.IncludeDomainis already qualifier-stripped. Covered by the?include:/+include:case in the table above.The chain comparison is case-insensitive, consistent with 819253a "fix(spf): Be case-insensitive when parsing SPF records (BUGFIX: Be case-insensitive when parsing SPF records #3982)". To be clear about what this does and does not buy: it is not needed to stop the recursion. With
d == domaininstead ofstrings.EqualFold, a case-alternating cycle is still caught — one hop later, with a redundant node in the chain (a.example.com -> A.EXAMPLE.COM -> a.example.comrather thana.example.com -> A.EXAMPLE.COM). The chain is built from the literalinclude:/redirect=operands, which are finite and pairwise distinct under exact matching, so depth is bounded either way. It is here because DNS names are case-insensitive and the resolver cache is keyed on the literal operand, so the exact-match form reports a loop node that is really the same domain twice. The last row of the table pins it: that subtest fails if the comparison is changed to==. If you would rather have the two-line version that matches the design in spflib.Parse() has no depth or cycle limit: a cyclic include: chain crashes dnscontrol with an unrecoverable stack overflow #4633 exactly, say the word and I will drop it.No record that parses today can be rejected by this. The guard fires only when a domain appears in its own ancestry, which is precisely the condition under which the old code could not return:
cache.GetSPFmemoizes each name inentry.resolvedSPF, so re-entering a domain replays an identical descent.The error is wrapped by the existing
in included SPF: %wat each level, so a deep loop readsin included SPF: in included SPF: SPF include loop: .... That repetition is howParsealready reports every nested error (verified: a nestedunsupported SPF partproduces the same shape onmain) and is unchanged here.This deliberately does not enforce the RFC 7208 §4.6.4 ten-lookup cap. That was direction 2 in the issue; it also bounds legitimate deep-but-finite chains, which is what flattening exists to fix. Cycles only.
Tests
pkg/spflib/parse_test.go, +81, reusing the existingfakeResolverfromflatten_test.go— no network:TestParseIncludeLoop— 5 subtests: self-loop, two-node loop,redirect=loop, qualified?include:loop, case-changing loop. Each asserts the complete error string, so the reported chain is pinned exactly, not just matched as a substring.TestParseSharedIncludeIsNotALoop— the diamond above, asserting it still parses to 3 parts and 4 lookups.Verified they fail without the fix. With
pkg/spflib/parse.goreverted tomainand the tests left in place, each of the 5 loop subtests was run under its own-runfilter (the first overflow kills the test binary, so anything after it silently never runs):All 5 behave identically:
go testexits 1, 558 lines of output, 93spflib.Parseframes. They do not report a test failure — they destroy the test binary, which is the point.TestParseSharedIncludeIsNotALooppasses without the fix, as it must. With the fix restored, all 6 pass.The case-insensitivity in
inChainis pinned separately, since a stack overflow cannot distinguish it: replacingstrings.EqualFold(d, domain)withd == domainturns exactly one subtest red and leaves the other four green.Local verification
CI does not run for outside contributors until the workflow run is approved, so here is the full gate, run on this branch (go1.26.0, darwin/arm64), with
main@ fdc38db as the baseline:maingo test -count=1 ./...golangci-lint run ./...staticcheck ./...go vet ./pkg/spflib/go-checkscommands +git statusBIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BINDGOOS=js GOARCH=wasm go build ./docs/flattener/Branched from
fdc38db6, which is where every number above was measured.mainhas since moved to278b9632(#4635, NAMECHEAP test skips); it touches nothing inpkg/spflibandgit merge-tree --write-tree origin/main <branch>exits 0, so I have left the branch unrebased rather than add noise to the diff.If you'd rather
The ten-lookup cap (direction 2) is still available and would compose with this — it bounds pathological non-cyclic nesting, which cycle detection does not. Happy to add it here or in a follow-up if you want it, and equally happy to change the error wording.
Found and written up with LLM assistance (Claude), same as #4630 and #4634.