From: Petr Špaček Date: Thu, 5 Jun 2025 13:15:08 +0000 (+0200) Subject: Split NXDOMAIN/NOERROR/NODATA test cases X-Git-Tag: v9.18.39~11^2~12 X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=87974b62d5283b40c9d11e7e83943018efd18d0a;p=thirdparty%2Fbind9.git Split NXDOMAIN/NOERROR/NODATA test cases Untangling individual cases allows for clearer documentation and makes it easier to build similar but slightly different test cases. Wildcard NODATA answer was added. (cherry picked from commit 9ca2077274908d86599e0161cf2c0ccc140b224f) --- diff --git a/bin/tests/system/dnssec/tests_nsec3.py b/bin/tests/system/dnssec/tests_nsec3.py index f7f98be3646..c7824abf0b0 100755 --- a/bin/tests/system/dnssec/tests_nsec3.py +++ b/bin/tests/system/dnssec/tests_nsec3.py @@ -25,6 +25,7 @@ import dns.query import dns.rcode import dns.rdataclass import dns.rdatatype +import dns.rdtypes.ANY.RRSIG import dns.rrset from isctest.hypothesis.strategies import dns_names @@ -42,13 +43,36 @@ ZONE = isctest.name.ZoneAnalyzer.read_path( ) +def do_test_query(qname, qtype, server, named_port) -> dns.message.Message: + query = dns.message.make_query(qname, qtype, use_edns=True, want_dnssec=True) + response = isctest.query.tcp(query, server, named_port, timeout=TIMEOUT) + isctest.check.is_response_to(response, query) + assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN) + return response + + +def assume_nx_and_no_delegation(qname): + assume(qname not in ZONE.all_existing_names) + + # name must not be under a delegation or DNAME: + # it would not work with resolver ns4 + assume( + not isctest.name.is_related_to_any( + qname, + (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN), + ZONE.reachable_delegations.union(ZONE.reachable_dnames), + ) + ) + + def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: """ - Test if 'hashed_name' is covered by an NSEC3 record in 'rrset'. + Test if 'hashed_name' is covered by an NSEC3 record in 'rrset', i.e. the name does not exist. """ prev_name = rrset.name for nsec3 in rrset: + assert nsec3.flags == 0, "opt-out not supported by test logic" next_name = nsec3.next_name(SUFFIX) # Single name case. @@ -72,6 +96,7 @@ def nsec3_covers(rrset: dns.rrset.RRset, hashed_name: dns.name.Name) -> bool: def check_nsec3_covers(name: dns.name.Name, response: dns.message.Message) -> None: + """Given name provably does not exist""" name_is_covered = False nhash = dns.dnssec.nsec3_hash( @@ -93,89 +118,135 @@ def check_nsec3_covers(name: dns.name.Name, response: dns.message.Message) -> No @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] ) -@given(name=dns_names(suffix=SUFFIX)) -# @given(name=just(dns.name.from_text(f"\000.\001.{SUFFIX}"))) -# @given(name=just(dns.name.from_text(f"a.wild.{SUFFIX}"))) -def test_dnssec_nsec3_nxdomain(server, name: dns.name.Name, named_port: int) -> None: - noqname_test(server, name, named_port) +@given(qname=dns_names(suffix=SUFFIX)) +def test_nxdomain(server, qname: dns.name.Name, named_port: int) -> None: + """A real NXDOMAIN, no wildcards involved""" + assume_nx_and_no_delegation(qname) + wname = ZONE.source_of_synthesis(qname) + assume(wname not in ZONE.reachable_wildcards) + + check_nxdomain(server, named_port, qname) @pytest.mark.parametrize( "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] ) -@given(name=dns_names(suffix=ZONE.reachable.union(ZONE.ents))) -def test_dnssec_nsec3_subdomain_nxdomain( - server, name: dns.name.Name, named_port: int -) -> None: - noqname_test(server, name, named_port) +@given(qname=dns_names(suffix=ZONE.ents)) +def test_ents(server, qname: dns.name.Name, named_port: int) -> None: + """ENT can have a wildcard under it""" + assume_nx_and_no_delegation(qname) + wname = ZONE.source_of_synthesis(qname) + # does qname match a wildcard under ENT? + if wname in ZONE.reachable_wildcards: + check_wildcard_synthesis(server, named_port, qname) + else: + check_nxdomain(server, named_port, qname) -def noqname_test(server, name: dns.name.Name, named_port: int) -> None: - # randomly generated name must not exist - assume(name not in (ZONE.all_existing_names)) - # name must not be under a delegation or DNAME: - # it would not work with resolver ns4 - assume( - not isctest.name.is_related_to_any( - name, - (dns.name.NameRelation.EQUAL, dns.name.NameRelation.SUBDOMAIN), - ZONE.reachable_delegations.union(ZONE.reachable_dnames), - ) - ) +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] +) +@given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents)) +def test_wildcard_synthesis(server, qname: dns.name.Name, named_port: int) -> None: + assume(qname not in ZONE.all_existing_names) + + wname = ZONE.source_of_synthesis(qname) + assume(wname in ZONE.reachable_wildcards) - query = dns.message.make_query( - name, dns.rdatatype.A, use_edns=True, want_dnssec=True + check_wildcard_synthesis(server, named_port, qname) + + +@pytest.mark.parametrize( + "server", [pytest.param(AUTH, id="ns3"), pytest.param(RESOLVER, id="ns4")] +) +@given(qname=dns_names(suffix=ZONE.reachable_wildcard_parents)) +def test_wildcard_nodata(server, qname: dns.name.Name, named_port: int) -> None: + assume(qname not in ZONE.all_existing_names) + + wname = ZONE.source_of_synthesis(qname) + assume(wname in ZONE.reachable_wildcards) + + check_wildcard_nodata(server, named_port, qname) + + +def check_nsec3_owner(owner: dns.name.Name, response): + """Check response has NSEC3 RR matching given owner name, i.e. the name exists.""" + name_hash = dns.dnssec.nsec3_hash( + owner, salt=None, iterations=0, algorithm=NSEC3Hash.SHA1 ) - response = isctest.query.tcp(query, server, named_port, timeout=TIMEOUT) - isctest.check.is_response_to(response, query) - assert response.rcode() in (dns.rcode.NOERROR, dns.rcode.NXDOMAIN) + nsec3_owner = dns.name.from_text(name_hash, SUFFIX) + + nsec3_found = False + for rrset in response.authority: + if rrset.match( + nsec3_owner, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE + ): + nsec3_found = True + assert ( + nsec3_found + ), f"Expected matching NSEC3 for {owner} (hash={name_hash}) not found:\n{response}" + - ce, nce = ZONE.closest_encloser(name) - # Response has NSEC3 that covers the next closer name +def check_wildcard_nodata(server, named_port: int, qname: dns.name.Name) -> None: + response = do_test_query(qname, dns.rdatatype.AAAA, server, named_port) + assert response.rcode() is dns.rcode.NOERROR + + ce, nce = ZONE.closest_encloser(qname) + check_nsec3_owner(ce, response) check_nsec3_covers(nce, response) - wname = ZONE.source_of_synthesis(name) - if wname in ZONE.reachable_wildcards: - wname_parent = dns.name.Name(wname[1:]) - assert name.is_subdomain(wname_parent) - # expecting wildcard response with a signed A RRset - assert response.rcode() is dns.rcode.NOERROR - answer_sig = response.get_rrset( - section="ANSWER", - name=name, - rdclass=dns.rdataclass.IN, - rdtype=dns.rdatatype.RRSIG, - covers=dns.rdatatype.A, - ) - assert answer_sig is not None - assert len(answer_sig) == 1 - # RRSIG labels field, RFC 4034 section 3.1.3 does not count: - # - root label - # - leftmost * label - wildcard_parent_labels = answer_sig[0].labels + 1 # add root but not leftmost * - assert wildcard_parent_labels < len(name) - # ce should be wildcard name w/o wildcard label, nce one label longer - assert ce == name.split(wildcard_parent_labels)[1] - assert nce == name.split(wildcard_parent_labels + 1)[1] - else: - # no wildcard synthesis -> NXDOMAIN - assert response.rcode() is dns.rcode.NXDOMAIN - # Response must have closest encloser NSEC3 - ce_hash = dns.dnssec.nsec3_hash( - ce, salt=None, iterations=0, algorithm=NSEC3Hash.SHA1 - ) - ce_nsec3 = dns.name.from_text(ce_hash, SUFFIX) - - ce_nsec3_match = False - for rrset in response.authority: - if rrset.match( - ce_nsec3, dns.rdataclass.IN, dns.rdatatype.NSEC3, dns.rdatatype.NONE - ): - ce_nsec3_match = True - assert ( - ce_nsec3_match - ), f"Expected matching NSEC3 for {ce} (hash={ce_hash}) not found:\n {response}" - - # Response has NSEC3 that covers the wildcard - check_nsec3_covers(wname, response) + wname = ZONE.source_of_synthesis(qname) + # expecting proof that wildcard owner does not have rdatatype requested + check_nsec3_owner(wname, response) + + +def check_nxdomain(server, named_port: int, qname: dns.name.Name) -> None: + response = do_test_query(qname, dns.rdatatype.A, server, named_port) + assert response.rcode() is dns.rcode.NXDOMAIN + + ce, nce = ZONE.closest_encloser(qname) + check_nsec3_owner(ce, response) + check_nsec3_covers(nce, response) + + wname = ZONE.source_of_synthesis(qname) + check_nsec3_covers(wname, response) + + +def check_wildcard_synthesis(server, named_port: int, qname: dns.name.Name) -> None: + """Expect wildcard response with a signed A RRset""" + response = do_test_query(qname, dns.rdatatype.A, server, named_port) + assert response.rcode() is dns.rcode.NOERROR + + answer_sig = response.get_rrset( + section="ANSWER", + name=qname, + rdclass=dns.rdataclass.IN, + rdtype=dns.rdatatype.RRSIG, + covers=dns.rdatatype.A, + ) + assert answer_sig is not None + assert len(answer_sig) == 1 + rrsig = answer_sig[0] + assert isinstance(rrsig, dns.rdtypes.ANY.RRSIG.RRSIG) + # RRSIG labels field RFC 4034 section 3.1.3 does not count: + # - root label + # - leftmost * label + wildcard_parent_labels = rrsig.labels + 1 # add root but not leftmost * + assert wildcard_parent_labels < len(qname) + + # 1. We have RRSIG from the wildcard '*.something', which proves the node + # 'something' exists (by definition - it has a child, so it exists, but + # maybe it is an ENT). Thus we expect closest encloser = 'something' + # 2. If wildcard synthesis is legitimate, QNAME itself and no nodes between + # QNAME and the closest encloser can exist. Because of DNS node existence + # rules it's sufficient to prove non-existence of next-closer name, i.e. + # ., to deny existence of the whole + # subtree down to QNAME. + + ce, nce = ZONE.closest_encloser(qname) + assert ce == qname.split(wildcard_parent_labels)[1] + # ce is proven to exist by the RRSIG + assert nce == qname.split(wildcard_parent_labels + 1)[1] + # nce must be proven to NOT exist + check_nsec3_covers(nce, response)