Repository navigation
BUG: SPF flattening removes redirect= modifiers that an "all" mechanism ignores - #4634
Merged
TomOnTime merged 4 commits intoSep 2, 2026
Merged
Conversation
…sm ignores
Flattening an include splices the child's terms into the parent. When the
child's last term is a redirect= that is not itself flattened, that term is
carried over verbatim, so a parent that ends in an "all" mechanism produces:
v=spf1 ip4:1.2.3.4 redirect=other.example.org -all
RFC 7208 Section 6.1: "Any 'redirect' modifier MUST be ignored if there is an
'all' mechanism anywhere in the record." The modifier above is therefore inert,
it is published to live DNS as noise, and spflib.Parse rejects it on the way
back in with "redirect=other.example.org must be last item", so DNSControl
cannot re-read its own output.
Flatten now drops redirect= modifiers from a record that has an "all"
mechanism. The check runs at every level of the recursion, not only at the top:
a redirect that is already dead inside a nested include must not come back to
life when the enclosing record has no "all" of its own.
Parse strips a term's qualifier before deciding what the term is, so it accepts
+redirect=, ~redirect=, -redirect= and ?redirect= as well. The removal has to
look past the qualifier the same way, which isAllMechanism already did; both
now share trimQualifier.
A redirect= in a record with no "all" is live and is left alone.
Flatten takes variadic FlattenOption values so the existing callers in
pkg/normalize and docs/flattener keep compiling. spflib.KeepIgnoredRedirects()
turns the removal off, reachable from the DSL as the SPF_BUILDER parameter
keepIgnoredRedirects (default false), following the metadata pattern already
used by flatten/split/overhead1/txtMaxSize.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TomOnTime
added a commit
that referenced
this pull request
Aug 27, 2026
Implements direction 1 (visited-set) from #4633, as you asked. Fixes #4633. ## The problem `Parse()` recursed into every `include:` and `redirect=` 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 (a `fakeResolver` map, 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 existing `in included SPF:` wrapper, and it is that full string the tests assert. | resolver contents | `Parse()` on `main` @ fdc38db | this PR | |---|---|---| | `a` → `v=spf1 include:a ~all` | `fatal error: stack overflow` | `SPF include loop: a.example.com -> a.example.com` | | `a` → `include:b`, `b` → `include:a` | `fatal error: stack overflow` | `SPF include loop: a.example.com -> b.example.com -> a.example.com` | | `a` → `v=spf1 redirect=a` | `fatal error: stack overflow` | `SPF include loop: a.example.com -> a.example.com` | | `?include:a`, `a` → `v=spf1 +include:a ~all` | `fatal error: stack overflow` | `SPF include loop: a.example.com -> a.example.com` | | `a` → `v=spf1 include:A.EXAMPLE.COM ~all`, `A.EXAMPLE.COM` → `include:a` | `fatal error: stack overflow` | `SPF include loop: a.example.com -> A.EXAMPLE.COM` | End to end, using the reproducer from the issue (`dnsconfig.js` with `TXT("@", "v=spf1 include:vendor.example ~all", {flatten: "all"})` and an `spfcache.json` entry pointing `vendor.example` at itself): `main` @ fdc38db — exit 2, 508 lines of output, 85 `spflib.Parse` frames, 1.7 s wall: ``` runtime: goroutine stack exceeds 1000000000-byte limit runtime: sp=0x7d6be5b84350 stack=[0x7d6be5b84000, 0x7d6c05b84000] fatal error: stack overflow ... github.com/DNSControl/dnscontrol/v4/pkg/spflib.Parse(...) pkg/spflib/parse.go:49 +0x80 github.com/DNSControl/dnscontrol/v4/pkg/spflib.Parse(...) pkg/spflib/parse.go:91 +0x4e8 [repeats] ``` this PR — exit 1, 4 lines: ``` 2026/08/01 21:21:50 2 Validation errors: 2026/08/01 21:21:50 ERROR: in included SPF: SPF include loop: vendor.example -> vendor.example 2026/08/01 21:21:50 WARNING: problem resolving SPF record: lookup vendor.example on 192.168.1.1:53: no such host exiting due to validation errors ``` The `WARNING` is not from this change: it is the cache's staleness check re-resolving `vendor.example`, which does not exist. The `main` binary 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, so `recover()` cannot catch it — nothing above `Parse()` could turn this into an error message. Executed against pristine `main`: wrapping the `Parse` call in `defer func(){ recover() }()` neither recovers nor reaches the statement after it; the test binary dies with `fatal error: stack overflow` having printed neither log line. And 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 every `preview`/`push`. ## Cause `pkg/spflib/parse.go:91` called `Parse(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, and `git 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`. `Parse` keeps its exact signature and seeds an unexported `parse(text, dnsres, chain []string)`; `chain` holds the domains currently being resolved, and `parse` refuses to descend into one already in its own ancestry. Four properties, in the order they matter: 1. **No exported surface changes.** `Parse(text string, dnsres Resolver) (*SPFRecord, error)` is untouched, so `pkg/normalize/flatten.go`, `docs/flattener/js.go` and the existing tests need no edits. 2. **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: ``` Parse("v=spf1 include:a.example.com include:b.example.com ~all") a.example.com -> v=spf1 include:shared.example.com ~all b.example.com -> v=spf1 include:shared.example.com ~all shared.example.com -> v=spf1 ip4:192.0.2.0/24 ~all => parts=3 lookups=4, no error (on main AND on this branch) ``` A single set shared across the whole walk would call that a loop and break a record that works now. `TestParseSharedIncludeIsNotALoop` pins it, and it passes both with and without the fix. 3. **The key is the domain, not `SPFPart.Text`.** `Text` keeps the qualifier (`+include:`, `?include:`), so matching on it would let a qualified loop through. `IncludeDomain` is already qualifier-stripped. Covered by the `?include:`/`+include:` case in the table above. 4. **The chain comparison is case-insensitive**, consistent with 819253a *"fix(spf): 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 == domain` instead of `strings.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.com` rather than `a.example.com -> A.EXAMPLE.COM`). The chain is built from the literal `include:`/`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 #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.GetSPF` memoizes each name in `entry.resolvedSPF`, so re-entering a domain replays an identical descent. The error is wrapped by the existing `in included SPF: %w` at each level, so a deep loop reads `in included SPF: in included SPF: SPF include loop: ...`. That repetition is how `Parse` already reports every nested error (verified: a nested `unsupported SPF part` produces the same shape on `main`) 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 existing `fakeResolver` from `flatten_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.go` reverted to `main` and the tests left in place, each of the 5 loop subtests was run under its own `-run` filter (the first overflow kills the test binary, so anything after it silently never runs): ``` $ go test -count=1 ./pkg/spflib/ -run 'TestParseIncludeLoop/a_domain_that_includes_itself' -v === RUN TestParseIncludeLoop === RUN TestParseIncludeLoop/a_domain_that_includes_itself runtime: goroutine stack exceeds 1000000000-byte limit runtime: sp=0x206424c60390 stack=[0x206424c60000, 0x206444c60000] fatal error: stack overflow ... FAIL github.com/DNSControl/dnscontrol/v4/pkg/spflib 1.515s FAIL ``` All 5 behave identically: `go test` exits 1, 558 lines of output, 93 `spflib.Parse` frames. They do not report a test failure — they destroy the test binary, which is the point. `TestParseSharedIncludeIsNotALoop` **passes** without the fix, as it must. With the fix restored, all 6 pass. The case-insensitivity in `inChain` is pinned separately, since a stack overflow cannot distinguish it: replacing `strings.EqualFold(d, domain)` with `d == domain` turns exactly one subtest red and leaves the other four green. ``` --- FAIL: TestParseIncludeLoop (0.00s) --- FAIL: TestParseIncludeLoop/a_loop_that_changes_the_case_of_the_domain (0.00s) parse_test.go:211: Parse("v=spf1 include:a.example.com ~all") error = "in included SPF: in included SPF: SPF include loop: a.example.com -> A.EXAMPLE.COM -> a.example.com", want "in included SPF: SPF include loop: a.example.com -> A.EXAMPLE.COM" FAIL ``` ## 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: | command | `main` | this 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 vet ./pkg/spflib/` | clean | clean | | the six `go-checks` commands + `git status` | — | only the two files above are modified | | `BIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BIND` | — | ok, 0.57s | | `GOOS=js GOARCH=wasm go build ./docs/flattener/` | — | exit 0 | Branched from `fdc38db6`, which is where every number above was measured. `main` has since moved to `278b9632` (#4635, NAMECHEAP test skips); it touches nothing in `pkg/spflib` and `git 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. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Tom Limoncelli <tal@whatexit.org>
TomOnTime
approved these changes
Aug 27, 2026
TomOnTime
approved these changes
Sep 2, 2026
TomOnTime
pushed a commit
that referenced
this pull request
Sep 19, 2026
Follow-up to #4634. While going through the recently merged pull requests, I noticed that two links on https://docs.dnscontrol.org/language-reference/domain-modifiers/spf_builder don't work. The `txtMaxSize` description shows "[multiple strings][multi-string]" and "[not go higher than 450][record-size]" as plain text, because both reference definitions are on the same line in `SPF_BUILDER.md`, so neither is recognized as a definition. That line dates back to #1809. This GitHub pull request puts each definition on its own line. Reference links with one definition per line render fine on GitBook, as on the "Writing new DNS providers" page. ## AI-attributie Assisted-by: Claude Code:claude-opus-5
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.
Follow-up to #4630, which you asked for: "Yes, please submit a separate PR that handles redirect. The default should be to remove it."
The problem
Flattening an include splices the child's terms into the parent. If the child's last term is a
redirect=whose target is not itself being flattened, that term is carried over verbatim.spfcache.json:{ "child.example.net": { "SPF": "v=spf1 ip4:1.2.3.4 redirect=other.example.org" }, "qchild.example.net": { "SPF": "v=spf1 ip4:1.2.3.4 ~redirect=other.example.org" }, "other.example.org": { "SPF": "v=spf1 ip4:9.9.9.9 -all" } }dnscontrol print-ir, fiveSPF_BUILDERcases, run onmain@fdc38db6and on this branch:SPF_BUILDERmaintodayparts: [v=spf1, include:child.example.net, -all]flatten: [child.example.net]v=spf1 ip4:1.2.3.4 redirect=other.example.org -allv=spf1 ip4:1.2.3.4 -allkeepIgnoredRedirects: truev=spf1 ip4:1.2.3.4 redirect=other.example.org -allv=spf1 ip4:1.2.3.4 redirect=other.example.org -all~redirect=v=spf1 ip4:1.2.3.4 ~redirect=other.example.org -allv=spf1 ip4:1.2.3.4 -allallin the parentv=spf1 ip4:1.2.3.4 redirect=other.example.orgv=spf1 ip4:1.2.3.4 redirect=other.example.orgparts: [v=spf1, ip4:1.2.3.4, -ALL, redirect=other.example.org]flatten: [nomatch.example]v=spf1 ip4:1.2.3.4 -ALL redirect=other.example.orgv=spf1 ip4:1.2.3.4 -ALLTwo things are wrong with rows 1 and 3. RFC 7208 §6.1: "Any 'redirect' modifier MUST be ignored if there is an 'all' mechanism anywhere in the record." So the modifier is inert, and it is published to live DNS as bytes in a record that flattening exists to shorten. And DNSControl cannot read back what it just wrote —
spflib.Parserejects aredirect=in any non-final position (pkg/spflib/parse.go:78):The cause
(*SPFRecord).Flattenstrips a trailingallfrom the flattened child and appends everything else unchanged. Nothing checks whether a spliced-inredirect=has landed in a record that has anall.Parsebreaks at a lowercasealland rejectsredirect=in any non-final position, so it will not normally emit this combination — the exception is an uppercase-ALL, which its case-sensitive check atparse.go:61walks straight past (residual 2 below).The fix
Flattendropsredirect=modifiers from a record that contains anallmechanism.The check runs at every level of the recursion, not only at the top: a
redirect=that is already dead inside a nested include must not come back to life when the enclosing record has noallof its own. Pinned by a test.Parsestrips a term's qualifier before deciding what the term is (parse.go:56-58), so it also accepts+redirect=,~redirect=,-redirect=and?redirect=— row 3 above. The removal looks past the qualifier the same wayisAllMechanismalready did; both now share a two-linetrimQualifier. Case is not folded, deliberately:Parsematchesredirect=case-sensitively and rejectsREDIRECT=outright, so folding here would diverge from what the parser accepts.A
redirect=in a record with noallis live and is left alone (row 4).The opt-out
You also said there should be a way to disable this.
Flattennow takes variadicFlattenOptionvalues, so the existing callers inpkg/normalize/flatten.goanddocs/flattener/js.gocompile unchanged.spflib.KeepIgnoredRedirects()turns the removal off.It reaches users as a new
SPF_BUILDERparameter,keepIgnoredRedirects, defaultfalse— i.e. remove, as you asked. It follows the same metadata path asflatten/split/overhead1/txtMaxSize:On the shape of that option, and an alternative I owe you. The functional-options pattern is introduced by this PR and is used nowhere else in the repo; the idiom in this very file is plain parameters (
TXTSplit(pattern string, overhead int, txtMaxSize int)). I used it because it keepsFlatten's signature source-compatible for the//go:build jsflattener indocs/flattener/js.go, which I would otherwise have to touch in the same PR. If you would rather have something lighter, the two defensible alternatives are a second exported method (FlattenKeepingIgnoredRedirects, or similar) or simplyFlatten(spec string, keepIgnoredRedirects bool)with both call sites updated — that is four lines of machinery instead of fifteen. Say the word and I will switch; I have no attachment to the options pattern.Two residuals, stated plainly.
redirect=that you did not also list inflattenalready discards that redirect target's authorizations — onmaintoday and after this PR. A conformant receiver ignores the modifier in both cases, so the evaluated result is unchanged; but a non-conformant receiver that honours it would go Pass → Fail for hosts authorised only via the redirect target.keepIgnoredRedirects: truepreserves today's exact bytes for anyone who is worried about that.Flattencall, not only when flattening relocated a redirect. Row 5 of the table is the case: nothing was flattened at all (flatten: ["nomatch.example"]), and the already-ignoredredirect=was still dropped. That is correct per §6.1 — the modifier is ignored because anallis present, regardless of how it got there — but it does mean the change is not confined to records that flattening rewrote.Tests
TestFlattenIgnoredRedirect(7 cases) andTestFlattenOutputParses(5 qualifier variants) inpkg/spflib/flatten_test.go. Both use the in-testfakeResolver, so no live DNS.I verified they fail without the fix, in two stages.
Stage 1 — the qualifier handling alone removed, everything else in place:
Stage 2 — the whole removal disabled:
Restored, all pass. The four cases that pass in both states are deliberate pins, not passengers — they hold the live redirect, the qualified live redirect, the opt-out, and the in-spec redirect against regression.
TestFlattenTrailingAllfrom #4630 also passes unchanged in every state, including its-ALLcase.Local verification
CI workflows sit at
action_requiredfor outside contributors, so here is what I ran locally againstfdc38db6:maingo test -count=1 ./...golangci-lint runstaticcheck ./...go-checkscommands +git diffprettier@3.9.5 --check pkg/js/helpers.jsBIND_DOMAIN=example.com go test ./integrationTest/ -args -provider BINDGOOS=js GOARCH=wasm go build ./docs/flattener/go generate ./...regenerated 3 lines incommands/types/dnscontrol.d.ts; they are committed. (GOOS=js GOARCH=wasm go build ./...fails onmaintoo, insidecodeberg.org/miekg/dnsandAlecAivazis/survey— pre-existing and unrelated, which is why the row above is scoped todocs/flattener, the package that actually holds the js-tagged caller.)Diff is 6 files, +178/−10, of which 115 lines are tests and 3 are generated.
Related
Separately filed at your request: #4633, the
spflib.Parse()recursion bug. Independent of this change; nothing here touches it.AI-assisted, as with #4630 — the commit carries the
Co-Authored-By: Claude Opus 5trailer.