]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add a system test for CD=1 NXDOMAIN cache protection
authorOndřej Surý <ondrej@sury.org>
Thu, 4 Jun 2026 18:01:37 +0000 (20:01 +0200)
committerEvan Hunt <each@isc.org>
Thu, 2 Jul 2026 06:56:50 +0000 (23:56 -0700)
Cache a DNSSEC-validated A record, then make a CD=1 query elicit an
unvalidated NXDOMAIN for the name: the secure RRset must survive, and an
uncached-type query must not get the wrong RRset back.

Assisted-by: Claude:claude-opus-4-8
bin/tests/system/cdnxdomain/ns1/named.conf.j2 [new file with mode: 0644]
bin/tests/system/cdnxdomain/ns1/trusted.conf.j2 [new file with mode: 0644]
bin/tests/system/cdnxdomain/ns2/named.conf.j2 [new file with mode: 0644]
bin/tests/system/cdnxdomain/tests_cdnxdomain.py [new file with mode: 0644]

diff --git a/bin/tests/system/cdnxdomain/ns1/named.conf.j2 b/bin/tests/system/cdnxdomain/ns1/named.conf.j2
new file mode 100644 (file)
index 0000000..bd75fac
--- /dev/null
@@ -0,0 +1,39 @@
+// NS1 - validating resolver, forwards "example" to the signed NS2
+
+key rndc_key {
+       secret "1234abcd8765";
+       algorithm @DEFAULT_HMAC@;
+};
+
+controls {
+       inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
+};
+
+options {
+       query-source address 10.53.0.1;
+       notify-source 10.53.0.1;
+       transfer-source 10.53.0.1;
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.1; };
+       listen-on-v6 { none; };
+       recursion yes;
+       dnssec-validation yes;
+       servfail-ttl 0;
+};
+
+// Trust anchor for "example" (static-ds of NS2's KSK).
+include "trusted.conf";
+
+zone "." {
+       type hint;
+       file "../../_common/root.hint";
+};
+
+// Resolve "example" only via the signed authoritative server.  The static
+// trust anchor above lets the forwarded answers validate to dns_trust_secure.
+zone "example" {
+       type forward;
+       forward only;
+       forwarders { 10.53.0.2; };
+};
diff --git a/bin/tests/system/cdnxdomain/ns1/trusted.conf.j2 b/bin/tests/system/cdnxdomain/ns1/trusted.conf.j2
new file mode 100644 (file)
index 0000000..1c6af49
--- /dev/null
@@ -0,0 +1,5 @@
+trust-anchors {
+{% for ta in trust_anchors %}
+       "@ta.domain@" @ta.type@ @ta.contents@;
+{% endfor %}
+};
diff --git a/bin/tests/system/cdnxdomain/ns2/named.conf.j2 b/bin/tests/system/cdnxdomain/ns2/named.conf.j2
new file mode 100644 (file)
index 0000000..f05dd33
--- /dev/null
@@ -0,0 +1,28 @@
+// NS2 - signed authoritative server for "example"
+
+key rndc_key {
+       secret "1234abcd8765";
+       algorithm @DEFAULT_HMAC@;
+};
+
+controls {
+       inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
+};
+
+options {
+       query-source address 10.53.0.2;
+       notify-source 10.53.0.2;
+       transfer-source 10.53.0.2;
+       port @PORT@;
+       pid-file "named.pid";
+       listen-on { 10.53.0.2; };
+       listen-on-v6 { none; };
+       recursion no;
+       dnssec-validation no;
+};
+
+zone "example" {
+       type primary;
+       file "example.db.signed";
+       allow-transfer { any; };
+};
diff --git a/bin/tests/system/cdnxdomain/tests_cdnxdomain.py b/bin/tests/system/cdnxdomain/tests_cdnxdomain.py
new file mode 100644 (file)
index 0000000..4f211f0
--- /dev/null
@@ -0,0 +1,140 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0.  If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+"""
+Regression test for issue #5877: a CD=1 (checking-disabled) NXDOMAIN response
+is cached at dns_trust_pending_answer and must not evict a DNSSEC-validated
+RRset cached at dns_trust_secure for the same name.
+
+ns1 is a validating resolver that forwards "example" to the signed ns2.  We
+prime the cache with a validated a.example/A (trust=secure), flip ns2 to a
+version of the zone where a.example no longer exists, and send a CD=1 query
+that makes the resolver cache an unvalidated NXDOMAIN for a.example.  The
+originally cached, more-trusted A record must survive.
+"""
+
+import os
+import shutil
+
+import dns.zone
+
+import isctest
+
+RESOLVER = "10.53.0.1"
+AUTH = "10.53.0.2"
+A_RDATA = "10.53.0.99"
+
+
+def bootstrap():
+    alg = os.environ["DEFAULT_ALGORITHM"]
+    bits = os.environ["DEFAULT_BITS"]
+    keygen = isctest.run.EnvCmd("KEYGEN", f"-a {alg} -b {bits} -Kns2 -q")
+    signer = isctest.run.EnvCmd("SIGNER", "-S -g")
+
+    name = "example"
+
+    zsk_name = keygen(name).out.strip()
+    isctest.kasp.Key(zsk_name, keydir="ns2")
+    ksk_name = keygen(f"-f KSK {name}").out.strip()
+    ksk = isctest.kasp.Key(ksk_name, keydir="ns2")
+
+    zonetext = """
+@ 300 IN SOA ns2.example. admin.example. 0 3600 1800 604800 300
+@ NS ns2.example.
+ns2 A 10.53.0.2
+"""
+    zone = dns.zone.from_text(zonetext, origin=name)
+    zone.to_file(f"ns2/{name}-empty.db", sorted=True)
+    signer(
+        f"-P -x -O full -o {name} -f {name}-empty.db.signed {name}-empty.db", cwd="ns2"
+    )
+
+    zonetext += "a A 10.53.0.99"
+    zone = dns.zone.from_text(zonetext, origin=name)
+    zone.to_file(f"ns2/{name}-full.db", sorted=True)
+    signer(
+        f"-P -x -O full -o {name} -f {name}-full.db.signed {name}-full.db", cwd="ns2"
+    )
+
+    shutil.copyfile(f"ns2/{name}-full.db.signed", f"ns2/{name}.db.signed")
+
+    return {
+        "trust_anchors": [
+            ksk.into_ta("static-key"),
+        ],
+    }
+
+
+def _serve(ns2, system_test_dir, variant):
+    """Make ns2 serve the 'full' or 'empty' (a.example-less) signed zone."""
+    src = system_test_dir / "ns2" / f"example-{variant}.db.signed"
+    shutil.copyfile(src, system_test_dir / "ns2" / "example.db.signed")
+    ns2.reload()
+
+
+def _prime_secure_a(ns1):
+    """Cache a.example/A at trust=secure and confirm it validated (AD=1)."""
+    ns1.rndc("flush")
+    res = isctest.query.tcp(isctest.query.create("a.example", "A"), RESOLVER)
+    isctest.check.noerror(res)
+    isctest.check.adflag(res)
+    assert A_RDATA in str(res.answer), res.answer
+
+
+def test_cd1_nxdomain_keeps_secure_rrset(ns1, ns2, system_test_dir):
+    # Prime the resolver with a DNSSEC-validated a.example/A (trust=secure).
+    _serve(ns2, system_test_dir, "full")
+    _prime_secure_a(ns1)
+
+    # Flip the authoritative zone to a (still signed) version without
+    # a.example, so the name now yields a signed NXDOMAIN; confirm at ns2.
+    _serve(ns2, system_test_dir, "empty")
+    direct = isctest.query.create("a.example", "A", dnssec=False)
+    isctest.check.nxdomain(isctest.query.tcp(direct, AUTH))
+
+    # A CD=1 query elicits an unvalidated NXDOMAIN for a.example, cached at
+    # trust=pending_answer.  The the secure A record should not be evicted.
+    cd_msg = isctest.query.create("a.example", "TXT", cd=True)
+    isctest.query.tcp(cd_msg, RESOLVER)
+
+    # The resolver should not refetch from the zone; the validated A
+    # record should still be served from cache.
+    res = isctest.query.tcp(isctest.query.create("a.example", "A"), RESOLVER)
+    isctest.check.noerror(res)
+    isctest.check.adflag(res)
+    assert A_RDATA in str(res.answer), res.answer
+
+
+def test_cd1_nxdomain_uncached_type_answer(ns1, ns2, system_test_dir):
+    # Prime a.example/A at trust=secure, then make a.example yield NXDOMAIN.
+    _serve(ns2, system_test_dir, "full")
+    _prime_secure_a(ns1)
+    _serve(ns2, system_test_dir, "empty")
+
+    # CD=1 query for an UNCACHED type (AAAA): the unvalidated NXDOMAIN is
+    # rejected in favour of the secure A.  Returning the cached A record (the
+    # wrong type) in the answer section is incorrect; the answer must be empty.
+    res = isctest.query.tcp(
+        isctest.query.create("a.example", "AAAA", cd=True), RESOLVER
+    )
+    isctest.check.servfail(res)
+    isctest.check.empty_answer(res)
+
+    # a CD=0 query should now validate the NXDOMAIN, ejecting the A.
+    res = isctest.query.tcp(isctest.query.create("a.example", "AAAA"), RESOLVER)
+    isctest.check.nxdomain(res)
+    isctest.check.adflag(res)
+    isctest.check.empty_answer(res)
+
+    res = isctest.query.tcp(isctest.query.create("a.example", "A"), RESOLVER)
+    isctest.check.nxdomain(res)
+    isctest.check.adflag(res)
+    isctest.check.empty_answer(res)