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 9ca2077274)
This commit is contained in:
Petr Špaček 2025-06-05 15:15:08 +02:00
parent c45c748d50
commit 1c906025e8

View file

@ -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))
@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)
# 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),
)
wname = ZONE.source_of_synthesis(qname)
assume(wname in ZONE.reachable_wildcards)
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
)
nsec3_owner = dns.name.from_text(name_hash, SUFFIX)
query = dns.message.make_query(
name, dns.rdatatype.A, 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)
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)
wname = ZONE.source_of_synthesis(qname)
# expecting proof that wildcard owner does not have rdatatype requested
check_nsec3_owner(wname, response)
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)
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.
# <one_label_under>.<closest_encloser>, 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)