plugin/cache: do not cache SOA-less NODATA responses (#8232)

* plugin/cache: do not cache SOA-less NODATA responses

An upstream may return NOERROR with a non-empty answer that still does not
resolve the question and without an SOA record to bound a negative TTL: a
CNAME chain that does not terminate in a record of the queried type at the
chain's terminal name (an incomplete recursion result from a forwarder).
Because the answer section is non-empty, response.Typify classifies it as
NoError (positive), so the cache plugin stores it keyed on <qname,qtype> and
replays the non-answer to clients until the TTL expires.

Per RFC 2308 section 5, negative responses without an SOA record SHOULD NOT be
cached. Following RFC 1034 section 3.6.2 and RFC 2308 sections 1 and 2.2, the
effective owner name is the target at the end of the CNAME chain, and the
response is NODATA unless it carries the queried type at that terminal name;
this holds for every query type, not just A/AAAA. Skip caching such a response
(mirroring the existing NameError && !hasSOA guard) and let the next query be
resolved upstream again.

An empty answer section is deliberately left cacheable: it is indistinguishable
from a legitimate NOERROR positive response that carries its data outside the
answer section (for example the whoami plugin).

Refs coredns#6958, coredns#5077, coredns#4987.

Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>

* plugin/cache: fail closed on malformed CNAME chains in isNODATA

canonicalName now returns a validity flag and rejects chains that are not a
single unambiguous path to a terminal name: an owner with more than one
distinct CNAME target (RFC 2181 section 10.1) and a revisited owner / CNAME
loop (RFC 1034 section 3.6.2). isNODATA treats an invalid chain as a
non-answer, so a SOA-less response with such a chain is not cached. This makes
the classification order-independent (previously the first CNAME per owner
won, so a two-target owner was cacheable or not depending on wire order) and
closes the loop-with-co-located-record case. Duplicate CNAME records naming
the same target are still tolerated.

Adds regression tests for the two-distinct-targets case in both orders, the
CNAME loop with a co-located A, and the tolerated duplicate-identical-CNAME
case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d59b4564-a9df-425f-858e-aadee0f35581
Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>

* plugin/cache: split canonicalName into self-documenting helpers

Extract the per-owner CNAME lookup into uniqueCNAMETarget and loop detection
into a small case-insensitive nameSet type, leaving canonicalName as a short
driver. Signature and algorithm are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d59b4564-a9df-425f-858e-aadee0f35581
Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>

---------

Signed-off-by: Nitin Nizhawan <nitin.nizhawan@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d59b4564-a9df-425f-858e-aadee0f35581
This commit is contained in:
Nitin Nizhawan
2026-07-30 09:09:49 +05:30
committed by GitHub
parent 0f4bda24eb
commit e073d1c05b
2 changed files with 369 additions and 2 deletions

View File

@@ -282,6 +282,243 @@ func TestCacheInsertion(t *testing.T) {
},
shouldCache: true,
},
{
name: "test NOERROR dangling CNAME chain without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target1.example.net."),
test.CNAME("target1.example.net. 3600 IN CNAME target2.example.net."),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR CNAME chain ending in a different type without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.AAAA("target.example.net. 3600 IN AAAA ::1"),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR MX query CNAME chain without terminal MX without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeMX,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR MX query CNAME chain terminating in MX caches",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeMX,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.MX("target.example.net. 3600 IN MX 10 mail.example.net."),
},
RecursionAvailable: true,
},
out: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeMX,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.MX("target.example.net. 3600 IN MX 10 mail.example.net."),
},
RecursionAvailable: true,
},
shouldCache: true,
},
{
name: "test NOERROR DNSSEC dangling CNAME chain without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Do: true,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.RRSIG("alias.example.org. 3600 IN RRSIG CNAME 8 2 3600 20170521031301 20170421031301 12051 example.org. lAaEzB5teQLLKyDenatmyhca7blLRg9DoGNrhe3NReBZN5C5/pMQk8Jc u25hv2fW23/SLm5IC2zaDpp2Fzgm6Jf7e90/yLcwQPuE7JjS55WMF+HE LEh7Z6AEb+Iq4BWmNhUz6gPxD4d9eRMs7EAzk13o1NYi5/JhfL6IlaYy qkc="),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR CNAME chain with unrelated A off the chain without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.A("unrelated.example.net. 3600 IN A 192.0.2.1"),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR lone CNAME answer to an ANY query caches",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeANY,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
out: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeANY,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
shouldCache: true,
},
{
name: "test NOERROR CNAME chain terminating in A record caches",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.A("target.example.net. 3600 IN A 127.0.0.1"),
},
RecursionAvailable: true,
},
out: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.A("target.example.net. 3600 IN A 127.0.0.1"),
},
RecursionAvailable: true,
},
shouldCache: true,
},
{
name: "test NOERROR CNAME chain terminating in A record out of order caches",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.A("target.example.net. 3600 IN A 127.0.0.1"),
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
out: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.A("target.example.net. 3600 IN A 127.0.0.1"),
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
shouldCache: true,
},
{
name: "test NOERROR CNAME answer to a CNAME query caches",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeCNAME,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
out: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeCNAME,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
},
RecursionAvailable: true,
},
shouldCache: true,
},
{
name: "test NOERROR CNAME chain with two distinct targets at one owner without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target1.example.net."),
test.CNAME("alias.example.org. 3600 IN CNAME target2.example.net."),
test.A("target1.example.net. 3600 IN A 192.0.2.1"),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR CNAME chain with two distinct targets at one owner reversed order without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target2.example.net."),
test.CNAME("alias.example.org. 3600 IN CNAME target1.example.net."),
test.A("target1.example.net. 3600 IN A 192.0.2.1"),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR CNAME loop with co-located A without SOA does not cache",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.CNAME("target.example.net. 3600 IN CNAME alias.example.org."),
test.A("alias.example.org. 3600 IN A 192.0.2.1"),
},
RecursionAvailable: true,
},
shouldCache: false,
},
{
name: "test NOERROR CNAME chain with duplicate identical CNAME terminating in A caches",
in: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.A("target.example.net. 3600 IN A 127.0.0.1"),
},
RecursionAvailable: true,
},
out: test.Case{
Rcode: dns.RcodeSuccess,
Qname: "alias.example.org.", Qtype: dns.TypeA,
Answer: []dns.RR{
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.CNAME("alias.example.org. 3600 IN CNAME target.example.net."),
test.A("target.example.net. 3600 IN A 127.0.0.1"),
},
RecursionAvailable: true,
},
shouldCache: true,
},
}
now, _ := time.Parse(time.UnixDate, "Fri Apr 21 10:51:21 BST 2017")
utc := now.UTC()
@@ -780,7 +1017,7 @@ func ttlBackend(ttl int) plugin.Handler {
m.SetReply(r)
m.Response, m.RecursionAvailable = true, true
m.Answer = []dns.RR{test.A(fmt.Sprintf("example.org. %d IN A 127.0.0.53", ttl))}
m.Answer = []dns.RR{test.A(fmt.Sprintf("%s %d IN A 127.0.0.53", r.Question[0].Name, ttl))}
w.WriteMsg(m)
return dns.RcodeSuccess, nil
})
@@ -813,7 +1050,7 @@ func slowTTLBackend(ttl int, delay time.Duration, done chan<- struct{}) plugin.H
m := new(dns.Msg)
m.SetReply(r)
m.Response, m.RecursionAvailable = true, true
m.Answer = []dns.RR{test.A(fmt.Sprintf("example.org. %d IN A 127.0.0.53", ttl))}
m.Answer = []dns.RR{test.A(fmt.Sprintf("%s %d IN A 127.0.0.53", r.Question[0].Name, ttl))}
w.WriteMsg(m)
if done != nil {
close(done)