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);