Commit 6c5f522546 for bind

commit 6c5f5225468322c0d0fc01463e4a712e1bd57ffb
Author: OndÅ™ej Surý <ondrej@isc.org>
Date:   Thu Sep 24 16:01:57 2026 +0200

    Add regression test for an insecure apex CNAME

    Model a signed parent with an insecure delegated child whose apex
    returns a CNAME for A queries.  This covers the validation path where
    the resolver must fetch DS from the parent without treating the CNAME
    as a self-join.

    Assisted-by: Codex:GPT-5

diff --git a/bin/tests/system/dnssec_cname_response/ans2/ans.py b/bin/tests/system/dnssec_cname_response/ans2/ans.py
index 5722c4de1d..99ce5ea834 100644
--- a/bin/tests/system/dnssec_cname_response/ans2/ans.py
+++ b/bin/tests/system/dnssec_cname_response/ans2/ans.py
@@ -20,7 +20,7 @@ import dns.zone

 from isctest.asyncserver import AsyncDnsServer, QueryContext, ResponseHandler
 from isctest.asyncserver.actions import DnsResponseSend
-from isctest.asyncserver.matchers import Domain, Matcher, Qtype
+from isctest.asyncserver.matchers import Domain, Matcher, Qname, Qtype

 # 'example.' answers DNSKEY/NSEC/NSEC3/RRSIG queries with a CNAME (the
 # meta-types whose CNAME answer the resolver and validator must cope with).
@@ -106,6 +106,14 @@ class SecureDsCnameHandler(CnameHandler):
     matcher = Domain("secure.") & Qtype(dns.rdatatype.DS)


+class InsecureApexCnameHandler(CnameHandler):
+    """
+    Synthesize an apex CNAME, which conflicts with SOA/NS in a zone file.
+    """
+
+    matcher = Qname("insecure.parent.") & Qtype(dns.rdatatype.A)
+
+
 class SignedZoneHandler(ResponseHandler):
     """
     Serve a signed zone faithfully.
@@ -190,6 +198,7 @@ class StuffedNxdomainHandler(ResponseHandler):
 def main() -> None:
     server = AsyncDnsServer(default_rcode=dns.rcode.NOERROR, default_aa=True)
     server.install_response_handlers(
+        InsecureApexCnameHandler(),
         LoneAHandler(),
         ExampleMetatypeCnameHandler(),
         SignedZoneHandler(EXAMPLE),
diff --git a/bin/tests/system/dnssec_cname_response/ans2/parent.db.in b/bin/tests/system/dnssec_cname_response/ans2/parent.db.in
new file mode 100644
index 0000000000..cc5ab1d475
--- /dev/null
+++ b/bin/tests/system/dnssec_cname_response/ans2/parent.db.in
@@ -0,0 +1,13 @@
+$TTL 300
+@       IN SOA ns.parent. root.parent. (
+                1          ; serial
+                3600       ; refresh
+                1800       ; retry
+                1814400    ; expire
+                300        ; minimum
+                )
+        IN NS   ns.parent.
+ns      IN A    10.53.0.2
+
+insecure       IN NS   ns.insecure.parent.
+ns.insecure    IN A    10.53.0.2
diff --git a/bin/tests/system/dnssec_cname_response/ans2/zones/insecure.parent.db b/bin/tests/system/dnssec_cname_response/ans2/zones/insecure.parent.db
new file mode 100644
index 0000000000..ea8daf590d
--- /dev/null
+++ b/bin/tests/system/dnssec_cname_response/ans2/zones/insecure.parent.db
@@ -0,0 +1,13 @@
+$TTL 300
+@       IN SOA ns.insecure.parent. root.insecure.parent. (
+                1          ; serial
+                3600       ; refresh
+                1800       ; retry
+                1814400    ; expire
+                300        ; minimum
+                )
+        IN NS   ns.insecure.parent.
+ns              IN A    10.53.0.2
+cname-target    IN A    192.0.2.1
+@               IN MX   10 mail.insecure.parent.
+mail            IN A    10.53.0.2
diff --git a/bin/tests/system/dnssec_cname_response/ns3/named.conf.j2 b/bin/tests/system/dnssec_cname_response/ns3/named.conf.j2
index 13c820e34e..dcce618a07 100644
--- a/bin/tests/system/dnssec_cname_response/ns3/named.conf.j2
+++ b/bin/tests/system/dnssec_cname_response/ns3/named.conf.j2
@@ -16,6 +16,11 @@ zone "secure." {
 	server-addresses { 10.53.0.2; };
 };

+zone "parent." {
+	type static-stub;
+	server-addresses { 10.53.0.2; };
+};
+
 zone "stuffed." {
 	type static-stub;
 	server-addresses { 10.53.0.2; };
diff --git a/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2 b/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2
index b45daebd8f..a426fa22e9 100644
--- a/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2
+++ b/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2
@@ -1,5 +1,6 @@
 trust-anchors {
 	example. static-key 257 3 14 "@ksk_public_key@";
 	secure. static-key 257 3 14 "@secure_ksk_public_key@";
+	parent. static-key 257 3 14 "@parent_ksk_public_key@";
 	@stuffed_ta.domain@ @stuffed_ta.type@ @stuffed_ta.contents@;
 };
diff --git a/bin/tests/system/dnssec_cname_response/ns4/named.conf.j2 b/bin/tests/system/dnssec_cname_response/ns4/named.conf.j2
new file mode 100644
index 0000000000..695db4fc0d
--- /dev/null
+++ b/bin/tests/system/dnssec_cname_response/ns4/named.conf.j2
@@ -0,0 +1,30 @@
+options {
+	{% include_indented "_common/options.conf.j2" %}
+	allow-transfer { any; };
+	dnssec-validation yes;
+	qname-minimization off;
+};
+
+{% include "_common/controls.conf.j2" %}
+
+zone "example." {
+	type static-stub;
+	server-addresses { 10.53.0.2; };
+};
+
+zone "secure." {
+	type static-stub;
+	server-addresses { 10.53.0.2; };
+};
+
+zone "parent." {
+	type static-stub;
+	server-addresses { 10.53.0.2; };
+};
+
+zone "stuffed." {
+	type static-stub;
+	server-addresses { 10.53.0.2; };
+};
+
+include "trusted.conf";
diff --git a/bin/tests/system/dnssec_cname_response/ns4/trusted.conf.j2 b/bin/tests/system/dnssec_cname_response/ns4/trusted.conf.j2
new file mode 100644
index 0000000000..a426fa22e9
--- /dev/null
+++ b/bin/tests/system/dnssec_cname_response/ns4/trusted.conf.j2
@@ -0,0 +1,6 @@
+trust-anchors {
+	example. static-key 257 3 14 "@ksk_public_key@";
+	secure. static-key 257 3 14 "@secure_ksk_public_key@";
+	parent. static-key 257 3 14 "@parent_ksk_public_key@";
+	@stuffed_ta.domain@ @stuffed_ta.type@ @stuffed_ta.contents@;
+};
diff --git a/bin/tests/system/dnssec_cname_response/tests_cname_rejection.py b/bin/tests/system/dnssec_cname_response/tests_cname_rejection.py
index e4bdc3aab9..28a1fec05a 100644
--- a/bin/tests/system/dnssec_cname_response/tests_cname_rejection.py
+++ b/bin/tests/system/dnssec_cname_response/tests_cname_rejection.py
@@ -19,6 +19,8 @@ from dns.rdtypes.dnskeybase import Flag

 import dns.dnssec
 import dns.rdataclass
+import dns.rdatatype
+import dns.rrset
 import dns.zone
 import pytest

@@ -80,6 +82,11 @@ def bootstrap():
             "secure_ksk_public_key": _sign_zone(
                 "ans2/secure.db.in", "ans2/secure.signed.db", "secure."
             ),
+            "parent_ksk_public_key": _sign_zone(
+                "ans2/parent.db.in",
+                "ans2/zones/parent.db.signed",
+                "parent.",
+            ),
             "stuffed_ta": _sign_nsec3_zone(
                 "stuffed.db.in", "stuffed.signed.zone", "stuffed."
             ),
@@ -89,13 +96,45 @@ def bootstrap():
     return result


-def _assert_ns3_alive():
-    """
-    Fail if ns3 is no longer answering (e.g. it hit an assertion).
-    """
+def _assert_alive(ip):
     liveness = isctest.query.create("version.bind.", "TXT", dns.rdataclass.CH, rd=False)
-    res = isctest.query.tcp(liveness, "10.53.0.3", timeout=5)
-    assert res is not None, "ns3 did not answer a liveness query -- it may have crashed"
+    res = isctest.query.tcp(liveness, ip, timeout=5)
+    assert (
+        res is not None
+    ), f"{ip} did not answer a liveness query -- it may have crashed"
+
+
+# With QNAME minimization (ns3), validator and client fetch options differ,
+# testing a sibling DS fetch; ns4 tests a direct join of the client fetch.
+RESOLVERS = ["ns3", "ns4"]
+
+EXPECTED_CNAME = dns.rrset.from_text(
+    "insecure.parent.",
+    300,
+    dns.rdataclass.IN,
+    dns.rdatatype.CNAME,
+    "cname-target.insecure.parent.",
+)
+EXPECTED_A = dns.rrset.from_text(
+    "cname-target.insecure.parent.",
+    300,
+    dns.rdataclass.IN,
+    dns.rdatatype.A,
+    "192.0.2.1",
+)
+
+
+def _query_insecure_parent(ns, qtype):
+    return isctest.query.tcp(isctest.query.create("insecure.parent.", qtype), ns.ip)
+
+
+def _check_insecure_cname_chain(res):
+    isctest.check.noerror(res)
+    isctest.check.noadflag(res)
+    answers = {rrset.rdtype: rrset for rrset in res.answer}
+    assert set(answers) == {dns.rdatatype.CNAME, dns.rdatatype.A}, res
+    assert answers[dns.rdatatype.CNAME] == EXPECTED_CNAME, res
+    assert answers[dns.rdatatype.A] == EXPECTED_A, res


 @pytest.mark.parametrize("qtype", ["DNSKEY", "NSEC", "NSEC3", "RRSIG"])
@@ -127,7 +166,7 @@ def test_direct_metatype_query_does_not_crash_resolver(qtype):
     # We do not assert a particular rcode here -- SERVFAIL or a chased
     # answer are both acceptable. The point is that named survives.
     assert res is not None, f"no response to direct {qtype} query"
-    _assert_ns3_alive()
+    _assert_alive("10.53.0.3")


 def test_rrsig_lone_record_does_not_stall_resolver():
@@ -147,7 +186,7 @@ def test_rrsig_lone_record_does_not_stall_resolver():

     assert elapsed_time < 5.0, f"RRSIG query took too long: {elapsed_time}s"
     assert res is not None, "no response to lone-record RRSIG query"
-    _assert_ns3_alive()
+    _assert_alive("10.53.0.3")


 def test_cname_for_validator_dnskey_fetch(ns3):
@@ -173,22 +212,93 @@ def test_cname_for_validator_dnskey_fetch(ns3):
     isctest.check.servfail(res)


-def test_ds_cname_does_not_deadlock():
+@pytest.mark.parametrize("resolver", RESOLVERS)
+def test_ds_cname_does_not_deadlock(servers, resolver):
     """
     An unsigned CNAME answer to a DS query makes validation fetch the same DS.
     Reject the fetch loop promptly instead of waiting for a timeout (GL#5878).
     """
+    ns = servers[resolver]
+    log_loop = Re(r"fetch loop detected resolving 'insecure\.secure/DS")
     msg = isctest.query.create("insecure.secure.", "DS")

     start_time = time.time()
-    res = isctest.query.tcp(msg, "10.53.0.3", timeout=8)
+    with ns.watch_log_from_here(timeout=5) as watcher:
+        res = isctest.query.tcp(msg, ns.ip, timeout=8)
+        watcher.wait_for_line(log_loop)
     elapsed_time = time.time() - start_time

     assert (
         elapsed_time < 5.0
     ), f"DS query took too long: {elapsed_time}s (possible deadlock)"
     isctest.check.servfail(res)
-    _assert_ns3_alive()
+    _assert_alive(ns.ip)
+
+
+@pytest.mark.parametrize("resolver", RESOLVERS)
+def test_cname_at_insecure_delegation_is_accepted(servers, resolver):
+    """
+    An insecure apex CNAME must allow fetching the parent's DS denial and
+    remain usable from cache (GL#6435).
+    """
+    ns = servers[resolver]
+
+    res = _query_insecure_parent(ns, "A")
+    _check_insecure_cname_chain(res)
+
+    res = _query_insecure_parent(ns, "DS")
+    isctest.check.noerror(res)
+    isctest.check.empty_answer(res)
+
+    res = _query_insecure_parent(ns, "NS")
+    isctest.check.noerror(res)
+    answers = {rrset.rdtype: rrset for rrset in res.answer}
+    assert answers.get(dns.rdatatype.CNAME) == EXPECTED_CNAME, res
+
+    # Repeat with a warm cache.
+    res = _query_insecure_parent(ns, "A")
+    _check_insecure_cname_chain(res)
+
+    assert not ns.log.grep(Re(r"deadlock found resolving 'insecure\.parent"))
+    assert not ns.log.grep(Re(r"fetch loop detected resolving 'insecure\.parent"))
+
+
+def test_apex_cname_coexists_with_other_types(ns3):
+    """
+    Caching an apex CNAME must preserve an existing MX RRset.
+    """
+    expected_mx = dns.rrset.from_text(
+        "insecure.parent.",
+        300,
+        dns.rdataclass.IN,
+        dns.rdatatype.MX,
+        "10 mail.insecure.parent.",
+    )
+
+    def check_mx(res):
+        isctest.check.noerror(res)
+        assert len(res.answer) == 1
+        isctest.check.rrsets_equal(res.answer[0], expected_mx)
+
+    # Cache MX first; a cached CNAME would answer the MX query via the alias.
+    ns3.rndc("flushtree insecure.parent")
+
+    res = _query_insecure_parent(ns3, "MX")
+    check_mx(res)
+
+    res = _query_insecure_parent(ns3, "A")
+    _check_insecure_cname_chain(res)
+
+    res = _query_insecure_parent(ns3, "MX")
+    check_mx(res)
+
+    with ns3.watch_log_from_here() as watcher:
+        ns3.rndc("dumpdb -cache")
+        watcher.wait_for_line("dumpdb complete")
+    dump = isctest.text.TextFile(f"{ns3.identifier}/named_dump.db")
+    # Match unique RDATA because the dump omits repeated owner names.
+    assert len(dump.grep(Re(r"\tMX\t10 mail\.insecure\.parent\.$"))) == 1
+    assert len(dump.grep(Re(r"\tCNAME\tcname-target\.insecure\.parent\.$"))) == 1


 def test_unsolicited_nsec3_proofs_are_rejected(ns3):
@@ -208,4 +318,4 @@ def test_unsolicited_nsec3_proofs_are_rejected(ns3):

     assert elapsed_time < 5.0, f"Query took too long: {elapsed_time}s"
     isctest.check.servfail(res)
-    _assert_ns3_alive()
+    _assert_alive("10.53.0.3")
diff --git a/tests/dns/qpdb_test.c b/tests/dns/qpdb_test.c
index 4c25a948ef..09259c3847 100644
--- a/tests/dns/qpdb_test.c
+++ b/tests/dns/qpdb_test.c
@@ -353,14 +353,7 @@ ISC_LOOP_TEST_IMPL(cname_precedence) {
 		const isc_result_t expected_result;
 		const dns_rdatatype_t expected_type;
 		const bool expected_stale;
-		/*
-		 * Caching fresh data retires the expired RRsets at the node,
-		 * so when the stale RRset is inserted first it is already
-		 * gone by the time the fresh one is cached.  Set when the
-		 * stale RRset was the expected answer: that insertion order
-		 * finds nothing instead.
-		 */
-		const bool purged;
+		const bool one_direction;
 	} testcases[] = {
 		/* Both fresh: the requested type wins over the alias. */
 		{
@@ -432,10 +425,21 @@ ISC_LOOP_TEST_IMPL(cname_precedence) {
 			.type2 = a,
 			.rank2 = fresh,
 			.qtype = txt,
+			.expected_result = ISC_R_NOTFOUND,
+			.expected_type = none,
+			.expected_stale = false,
+			.one_direction = true,
+		},
+		{
+			.type1 = a,
+			.rank1 = fresh,
+			.type2 = cname,
+			.rank2 = stale,
+			.qtype = txt,
 			.expected_result = DNS_R_CNAME,
 			.expected_type = cname,
-			.expected_stale = true,
-			.purged = true,
+			.expected_stale = stale,
+			.one_direction = true,
 		},
 		{
 			.type1 = cname,
@@ -558,27 +562,17 @@ ISC_LOOP_TEST_IMPL(cname_precedence) {
 		const dns_rdatatype_t expected_type =
 			testcases[i].expected_type;
 		const bool expected_stale = testcases[i].expected_stale;
-		const bool purged = testcases[i].purged;
+		const bool one_direction = testcases[i].one_direction;

 		/*
 		 * A fresh RRset cached after a stale one retires it; with
 		 * 'purged' set, that insertion order is expected to find
 		 * nothing for the query.
 		 */
-		if (purged && rank1 == stale && rank2 == fresh) {
-			check_cname_precedence(mctx, type1, rank1, type2, rank2,
-					       qtype, ISC_R_NOTFOUND, none,
-					       false);
-		} else {
-			check_cname_precedence(mctx, type1, rank1, type2, rank2,
-					       qtype, expected_result,
-					       expected_type, expected_stale);
-		}
-		if (purged && rank2 == stale && rank1 == fresh) {
-			check_cname_precedence(mctx, type2, rank2, type1, rank1,
-					       qtype, ISC_R_NOTFOUND, none,
-					       false);
-		} else {
+		check_cname_precedence(mctx, type1, rank1, type2, rank2, qtype,
+				       expected_result, expected_type,
+				       expected_stale);
+		if (!one_direction) {
 			check_cname_precedence(mctx, type2, rank2, type1, rank1,
 					       qtype, expected_result,
 					       expected_type, expected_stale);