]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Cover excessive NSEC3 proofs
authorOndřej Surý <ondrej@sury.org>
Sun, 7 Jun 2026 08:19:44 +0000 (10:19 +0200)
committerMichał Kępień <michal@isc.org>
Fri, 10 Jul 2026 07:26:46 +0000 (09:26 +0200)
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
bin/tests/system/dnssec_cname_response/ans2/ans.py
bin/tests/system/dnssec_cname_response/ans2/stuffed.db.in [new file with mode: 0644]
bin/tests/system/dnssec_cname_response/ns3/named.conf.j2
bin/tests/system/dnssec_cname_response/ns3/trusted.conf.j2
bin/tests/system/dnssec_cname_response/tests_cname_rejection.py

index c6fc05b4dd69757dfa74bda339e7536ba472e883..9a70759e2338f0549c935cf0b52459df8ac4b611 100644 (file)
@@ -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 (file)
index 0000000..7b3c0fb
--- /dev/null
@@ -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"
index fae33981cf87c1a66d4b04de719b36bbb11dbc19..fbabe4ce83846d6f6ebe77a6ddb79c6af262f058 100644 (file)
@@ -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";
index 52195bb379b4fc485043a934f5cf7ea68509c10b..b45daebd8fefa81e8350a8ec8379b3423470be97 100644 (file)
@@ -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@;
 };
index 6d3ab2997ccba35e79397b2170ce9e631ad3e2f0..5933736c765f6cb8699dcb6dc99d5cb0010a88d2 100644 (file)
@@ -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()