From: Ondřej Surý Date: Sun, 7 Jun 2026 08:19:44 +0000 (+0200) Subject: Cover excessive NSEC3 proofs X-Git-Tag: v9.21.24~11^2 X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=7ef3d5e195f1aae68e6667081639e1c8a228bac8;p=thirdparty%2Fbind9.git Cover excessive NSEC3 proofs Add a system test for NXDOMAIN responses stuffed with NSEC3 proof RRsets, expecting SERVFAIL once the per-fetch validation quota is reached. Assisted-by: OpenAI: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 c6fc05b4dd6..9a70759e233 100644 --- a/bin/tests/system/dnssec_cname_response/ans2/ans.py +++ b/bin/tests/system/dnssec_cname_response/ans2/ans.py @@ -35,6 +35,10 @@ EXAMPLE = dns.zone.from_file("example.signed.db", origin="example.", relativize= # can validate down to the zone and reach the DS query. SECURE = dns.zone.from_file("secure.signed.db", origin="secure.", relativize=False) +# 'stuffed.' is served faithfully for existing data, but NXDOMAIN answers +# contain every NSEC3 RRset in the zone to exercise negative-proof filtering. +STUFFED = dns.zone.from_file("stuffed.signed.zone", origin="stuffed.", relativize=False) + def _append_rrset_with_rrsig( zone: dns.zone.Zone, @@ -69,7 +73,7 @@ class CnameZoneHandler(DomainHandler): super().__init__() @property - def domains(self) -> list: + def domains(self) -> list[str]: return [self.zone.origin.to_text()] async def get_responses( @@ -151,6 +155,67 @@ class LoneRecordHandler(DomainHandler): yield DnsResponseSend(qctx.response) +class StuffedNsec3Handler(DomainHandler): + """Answer NXDOMAIN with every NSEC3 RRset from a signed zone.""" + + def __init__(self, zone: dns.zone.Zone) -> None: + self.zone = zone + self.nsec3_authority = [] + super().__init__() + + soa_rds = self.zone.get_rdataset(self.zone.origin, dns.rdatatype.SOA) + _append_rrset_with_rrsig( + self.zone, + self.nsec3_authority, + self.zone.origin, + self.zone.rdclass, + dns.rdatatype.SOA, + soa_rds, + ) + + for name, node in self.zone.items(): + rdset = node.get_rdataset(self.zone.rdclass, dns.rdatatype.NSEC3) + if rdset is None: + continue + _append_rrset_with_rrsig( + self.zone, + self.nsec3_authority, + name, + self.zone.rdclass, + dns.rdatatype.NSEC3, + rdset, + ) + + @property + def domains(self) -> list: + return [self.zone.origin.to_text()] + + async def get_responses( + self, qctx: QueryContext + ) -> AsyncGenerator[DnsResponseSend, None]: + qctx.prepare_new_response(with_zone_data=False) + qctx.response.flags |= dns.flags.AA + + node = self.zone.get_node(qctx.qname) + if node is not None: + rds = node.get_rdataset(qctx.qclass, qctx.qtype) + if rds is not None: + _append_rrset_with_rrsig( + self.zone, + qctx.response.answer, + qctx.qname, + qctx.qclass, + qctx.qtype, + rds, + ) + yield DnsResponseSend(qctx.response) + return + + qctx.response.set_rcode(dns.rcode.NXDOMAIN) + qctx.response.authority.extend(self.nsec3_authority) + yield DnsResponseSend(qctx.response) + + def main() -> None: server = AsyncDnsServer(default_rcode=dns.rcode.NOERROR, default_aa=True) server.install_response_handlers( @@ -165,6 +230,7 @@ def main() -> None: }, ), CnameZoneHandler(SECURE, {dns.rdatatype.DS}), + StuffedNsec3Handler(STUFFED), ) server.run() diff --git a/bin/tests/system/dnssec_cname_response/ans2/stuffed.db.in b/bin/tests/system/dnssec_cname_response/ans2/stuffed.db.in new file mode 100644 index 00000000000..7b3c0fb284c --- /dev/null +++ b/bin/tests/system/dnssec_cname_response/ans2/stuffed.db.in @@ -0,0 +1,34 @@ +$TTL 300 +@ IN SOA ns.stuffed. root.stuffed. ( + 1 ; serial + 3600 ; refresh + 1800 ; retry + 1814400 ; expire + 300 ; minimum + ) + IN NS ns.stuffed. +ns IN A 10.53.0.2 +a00 IN TXT "data" +a01 IN TXT "data" +a02 IN TXT "data" +a03 IN TXT "data" +a04 IN TXT "data" +a05 IN TXT "data" +a06 IN TXT "data" +a07 IN TXT "data" +a08 IN TXT "data" +a09 IN TXT "data" +a10 IN TXT "data" +a11 IN TXT "data" +a12 IN TXT "data" +a13 IN TXT "data" +a14 IN TXT "data" +a15 IN TXT "data" +a16 IN TXT "data" +a17 IN TXT "data" +a18 IN TXT "data" +a19 IN TXT "data" +a20 IN TXT "data" +a21 IN TXT "data" +a22 IN TXT "data" +a23 IN TXT "data" 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 fae33981cf8..fbabe4ce838 100644 --- a/bin/tests/system/dnssec_cname_response/ns3/named.conf.j2 +++ b/bin/tests/system/dnssec_cname_response/ns3/named.conf.j2 @@ -21,4 +21,9 @@ zone "secure." { 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/ns3/trusted.conf.j2 b/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2 index 52195bb379b..b45daebd8fe 100644 --- a/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2 +++ b/bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2 @@ -1,4 +1,5 @@ trust-anchors { example. static-key 257 3 14 "@ksk_public_key@"; secure. static-key 257 3 14 "@secure_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 6d3ab2997cc..5933736c765 100644 --- a/bin/tests/system/dnssec_cname_response/tests_cname_rejection.py +++ b/bin/tests/system/dnssec_cname_response/tests_cname_rejection.py @@ -50,6 +50,21 @@ def _sign_zone(db_in, signed_out, origin): return base64.b64encode(ksk_dnskey.key).decode() +def _sign_nsec3_zone(db_in, signed_out, origin) -> isctest.template.TrustAnchor: + """Sign 'db_in' with NSEC3 using dnssec-signzone; write 'signed_out'; + return the KSK as a static-key trust anchor.""" + zone = isctest.zone.Zone( + origin, + isctest.template.Nameserver("ans2"), + signed=True, + filepath_unsigned=db_in, + filepath_signed=signed_out, + ) + zone.add_keys() + zone.sign("-3 - -H 0") + return zone.trust_anchors("static-key")[0] + + def bootstrap(): try: result = { @@ -59,6 +74,9 @@ def bootstrap(): "secure_ksk_public_key": _sign_zone( "ans2/secure.db.in", "ans2/secure.signed.db", "secure." ), + "stuffed_ta": _sign_nsec3_zone( + "stuffed.db.in", "stuffed.signed.zone", "stuffed." + ), } except ImportError as exc: pytest.skip(f"{exc}") @@ -170,3 +188,23 @@ def test_ds_cname_does_not_deadlock(): ), f"DS query took too long: {elapsed_time}s (possible deadlock)" isctest.check.servfail(res) _assert_ns3_alive() + + +def test_unsolicited_nsec3_proofs_are_rejected(ns3): + """ + A malicious authoritative server can place every NSEC3 RRset and its + RRSIGs in an NXDOMAIN authority section. The resolver must reject the + response before validating all those unsolicited proof RRsets. + """ + log_max_validations = Re(r"maximum number of validations exceeded") + msg = isctest.query.create("absent.stuffed.", "A") + + start_time = time.time() + with ns3.watch_log_from_here(timeout=5) as watcher: + res = isctest.query.tcp(msg, "10.53.0.3", timeout=8) + watcher.wait_for_line(log_max_validations) + elapsed_time = time.time() - start_time + + assert elapsed_time < 5.0, f"Query took too long: {elapsed_time}s" + isctest.check.servfail(res) + _assert_ns3_alive()