From 38e26f25c459cf36bd954a181ff79efebd2f5e87 Mon Sep 17 00:00:00 2001 From: Omkhar Arasaratnam Date: Sat, 11 Jul 2026 06:03:52 -0400 Subject: [PATCH] plugin/file: do not expand wildcard across a closer empty non-terminal (#8223) A query for a name that sits below a closer empty non-terminal was wrongly answered with a shallower wildcard record instead of NXDOMAIN. Per RFC 4592 2.2.1 a wildcard is the source of synthesis only when the wildcard owner's parent is the closest encloser of the queried name; if an empty non-terminal exists between that parent and the queried name, it is the closer encloser and the shallower wildcard must not be expanded. Guard the wildcard expansion in Zone.Lookup with closerENTExists, which walks the strict ancestors of the queried name between the wildcard parent and the name and reports whether any of them is an empty non-terminal. Adds TestLookupWildcardRespectsCloserEmptyNonTerminal, which asserts NXDOMAIN for a name below a closer empty non-terminal and keeps a no-regression case where a plain wildcard with no closer empty non-terminal still applies. Signed-off-by: Omkhar Arasaratnam Co-authored-by: Omkhar Arasaratnam Co-authored-by: Claude Opus 4.8 (1M context) --- plugin/file/lookup.go | 29 ++++++++++++++++++- plugin/file/wildcard_test.go | 56 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/plugin/file/lookup.go b/plugin/file/lookup.go index a3d1eca41..7b5753278 100644 --- a/plugin/file/lookup.go +++ b/plugin/file/lookup.go @@ -203,7 +203,7 @@ func (z *Zone) Lookup(ctx context.Context, state request.Request, qname string) // Haven't found the original name. // Found wildcard. - if wildElem != nil { + if wildElem != nil && !closerENTExists(tr, qname, wildElem.Name()) { // set metadata value for the wildcard record that synthesized the result metadata.SetValueFunc(ctx, "zone/wildcard", func() string { return wildElem.Name() @@ -288,6 +288,33 @@ Out: return nil, ret, nil, rcode } +// closerENTExists reports whether there is an empty-non-terminal between the +// wildcard's parent and qname. Per RFC 4592, such an ENT is the closest +// encloser and the shallower wildcard does not apply to qname. +func closerENTExists(tr *tree.Tree, qname, wildcardName string) bool { + // The wildcard owner is "*."; anything with that exact prefix is not a closer encloser. + if len(wildcardName) < 2 || wildcardName[0] != '*' || wildcardName[1] != '.' { + return false + } + parent := wildcardName[2:] + // Walk strict ancestors of qname that are strict descendants of parent. + // Each ancestor is an ENT if the tree contains any name strictly below it. + name := qname + offset, end := dns.NextLabel(name, 0) + for !end { + name = name[offset:] + if name == parent || !dns.IsSubDomain(parent, name) { + return false + } + // An ENT exists at `name` iff tr.Next(name) returns a descendant of name. + if x, found := tr.Next(name); found && dns.IsSubDomain(name, x.Name()) { + return true + } + offset, end = dns.NextLabel(name, 0) + } + return false +} + // typeFromElem returns the type tp from e and adds signatures (if they exist) and do is true. func typeFromElem(elem *tree.Elem, tp uint16, do bool) []dns.RR { rrs := elem.Type(tp) diff --git a/plugin/file/wildcard_test.go b/plugin/file/wildcard_test.go index b944576db..c57484edf 100644 --- a/plugin/file/wildcard_test.go +++ b/plugin/file/wildcard_test.go @@ -354,3 +354,59 @@ func TestLookupWildcardAdditional(t *testing.T) { } } } + +const closerENTWildcard = `; example.org test file with a wildcard and a deeper empty non-terminal +$TTL 3600 +example.org. IN SOA sns.dns.icann.org. noc.dns.icann.org. 2015082541 7200 3600 1209600 3600 +example.org. IN NS b.iana-servers.net. +*.example.org. IN A 127.0.0.53 +real.deeper.example.org. IN A 127.0.0.54 +` + +var closerENTWildcardTestCases = []test.Case{ + // deeper.example.org. is an empty non-terminal (real.deeper.example.org. + // exists below it), so it is a closer encloser than the wildcard's parent + // example.org. A name under it with no record and no *.deeper.example.org. + // wildcard is an NXDOMAIN, not the *.example.org. wildcard. + { + Qname: "nonexistent.deeper.example.org.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + Ns: []dns.RR{ + test.SOA(`example.org. 3600 IN SOA sns.dns.icann.org. noc.dns.icann.org. 2015082541 7200 3600 1209600 3600`), + }, + }, + // No closer empty non-terminal between example.org. and the queried name, so + // the wildcard still applies exactly as before. + { + Qname: "nonexistent.example.org.", Qtype: dns.TypeA, + Answer: []dns.RR{test.A(`nonexistent.example.org. 3600 IN A 127.0.0.53`)}, + Ns: []dns.RR{test.NS(`example.org. 3600 IN NS b.iana-servers.net.`)}, + }, +} + +func TestLookupWildcardRespectsCloserEmptyNonTerminal(t *testing.T) { + const name = "example.org." + zone, err := Parse(strings.NewReader(closerENTWildcard), name, "stdin", 0) + if err != nil { + t.Fatalf("Expect no error when reading zone, got %q", err) + } + + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{name: zone}, Names: []string{name}}} + ctx := context.TODO() + + for _, tc := range closerENTWildcardTestCases { + m := tc.Msg() + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v", err) + return + } + + resp := rec.Msg + if err := test.SortAndCheck(resp, tc); err != nil { + t.Error(err) + } + } +}