From: Ondřej Surý Date: Thu, 4 Jun 2026 18:01:37 +0000 (+0200) Subject: Add a system test for CD=1 NXDOMAIN cache protection X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=ddb6cb5c9382cd15f721289ffe38f193c06b6622;p=thirdparty%2Fbind9.git Add a system test for CD=1 NXDOMAIN cache protection 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 --- diff --git a/bin/tests/system/cdnxdomain/ns1/named.conf.j2 b/bin/tests/system/cdnxdomain/ns1/named.conf.j2 new file mode 100644 index 00000000000..bd75facb6dd --- /dev/null +++ b/bin/tests/system/cdnxdomain/ns1/named.conf.j2 @@ -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 index 00000000000..1c6af49c843 --- /dev/null +++ b/bin/tests/system/cdnxdomain/ns1/trusted.conf.j2 @@ -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 index 00000000000..f05dd33c733 --- /dev/null +++ b/bin/tests/system/cdnxdomain/ns2/named.conf.j2 @@ -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 index 00000000000..4f211f0be51 --- /dev/null +++ b/bin/tests/system/cdnxdomain/tests_cdnxdomain.py @@ -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)