BUG: SPF flattening removes redirect= modifiers that an "all" mechanism ignores - #4634
Open
shuvamk wants to merge 1 commit into
Open
BUG: SPF flattening removes redirect= modifiers that an "all" mechanism ignores#4634shuvamk wants to merge 1 commit into
shuvamk wants to merge 1 commit into
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>
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.