# 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,
super().__init__()
@property
- def domains(self) -> list:
+ def domains(self) -> list[str]:
return [self.zone.origin.to_text()]
async def get_responses(
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(
},
),
CnameZoneHandler(SECURE, {dns.rdatatype.DS}),
+ StuffedNsec3Handler(STUFFED),
)
server.run()
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 = {
"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}")
), 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()