From 8a4990d6ff692b50c3b295456b74b33441337cde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sur=C3=BD?= Date: Thu, 2 Apr 2026 10:45:03 +0200 Subject: [PATCH 1/2] Add regression test for NSEC proof after unsigned-to-signed IXFR Test that a secondary receiving an IXFR transitioning a zone from unsigned to NSEC-signed returns the correct covering NSEC record for empty non-terminal names. Add isctest.query.wait_for_serial() shared helper for waiting until a server has a specific SOA serial. --- bin/tests/system/isctest/query.py | 33 +++++++ bin/tests/system/nsec_ixfr/ns1/example.db.in | 11 +++ bin/tests/system/nsec_ixfr/ns1/named.conf.j2 | 29 ++++++ bin/tests/system/nsec_ixfr/ns2/named.conf.j2 | 27 ++++++ bin/tests/system/nsec_ixfr/setup.sh | 33 +++++++ bin/tests/system/nsec_ixfr/tests_nsec_ixfr.py | 92 +++++++++++++++++++ 6 files changed, 225 insertions(+) create mode 100644 bin/tests/system/nsec_ixfr/ns1/example.db.in create mode 100644 bin/tests/system/nsec_ixfr/ns1/named.conf.j2 create mode 100644 bin/tests/system/nsec_ixfr/ns2/named.conf.j2 create mode 100755 bin/tests/system/nsec_ixfr/setup.sh create mode 100644 bin/tests/system/nsec_ixfr/tests_nsec_ixfr.py diff --git a/bin/tests/system/isctest/query.py b/bin/tests/system/isctest/query.py index 9407dd6f47..a7e862b7f6 100644 --- a/bin/tests/system/isctest/query.py +++ b/bin/tests/system/isctest/query.py @@ -18,11 +18,14 @@ import time import dns.exception import dns.flags import dns.message +import dns.name import dns.query import dns.rcode import dns.rdataclass +import dns.rdatatype import isctest.log +import isctest.run QUERY_TIMEOUT = 10 @@ -149,3 +152,33 @@ def create( if cd: msg.flags |= dns.flags.CD return msg + + +def wait_for_serial(server_ip, zone, expected_serial, timeout=30): + """Wait until the server has the expected SOA serial for the zone. + + Queries the server repeatedly until the SOA serial matches or the + timeout expires. + + 'server_ip' is the IP address to query (string). + 'zone' is the zone name (string, with or without trailing dot). + 'expected_serial' is the expected SOA serial number (int). + 'timeout' is the maximum time to wait in seconds (default 30). + """ + query = create(zone, "SOA", dnssec=False) + + def check(): + res = tcp(query, server_ip) + soa = res.get_rrset( + res.answer, + dns.name.from_text(zone), + dns.rdataclass.IN, + dns.rdatatype.SOA, + ) + return soa is not None and len(soa) == 1 and soa[0].serial == expected_serial + + isctest.run.retry_with_timeout( + check, + timeout=timeout, + msg=f"timed out waiting for serial {expected_serial} at {server_ip} for {zone}", + ) diff --git a/bin/tests/system/nsec_ixfr/ns1/example.db.in b/bin/tests/system/nsec_ixfr/ns1/example.db.in new file mode 100644 index 0000000000..b7d326be00 --- /dev/null +++ b/bin/tests/system/nsec_ixfr/ns1/example.db.in @@ -0,0 +1,11 @@ +$TTL 300 +@ IN SOA ns1.example. hostmaster.example. ( + 1 ; serial + 3600 ; refresh + 900 ; retry + 1209600 ; expire + 300 ; minimum + ) +@ IN NS ns1.example. +ns1 IN A 10.53.0.1 +*.wildcard IN A 10.0.0.1 diff --git a/bin/tests/system/nsec_ixfr/ns1/named.conf.j2 b/bin/tests/system/nsec_ixfr/ns1/named.conf.j2 new file mode 100644 index 0000000000..560580b65d --- /dev/null +++ b/bin/tests/system/nsec_ixfr/ns1/named.conf.j2 @@ -0,0 +1,29 @@ +key rndc_key { + secret "1234abcd8765"; + algorithm @DEFAULT_HMAC@; +}; + +controls { + inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +options { + query-source address 10.53.0.1; + notify-source 10.53.0.1; + transfer-source 10.53.0.1; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.1; }; + listen-on-v6 { none; }; + allow-transfer { any; }; + recursion no; + notify yes; + dnssec-validation no; +}; + +zone "example" { + type primary; + file "example.db"; + also-notify { 10.53.0.2 port @PORT@; }; + ixfr-from-differences yes; +}; diff --git a/bin/tests/system/nsec_ixfr/ns2/named.conf.j2 b/bin/tests/system/nsec_ixfr/ns2/named.conf.j2 new file mode 100644 index 0000000000..abdf03cc33 --- /dev/null +++ b/bin/tests/system/nsec_ixfr/ns2/named.conf.j2 @@ -0,0 +1,27 @@ +key rndc_key { + secret "1234abcd8765"; + algorithm @DEFAULT_HMAC@; +}; + +controls { + inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; }; +}; + +options { + query-source address 10.53.0.2; + notify-source 10.53.0.2; + transfer-source 10.53.0.2; + port @PORT@; + pid-file "named.pid"; + listen-on { 10.53.0.2; }; + listen-on-v6 { none; }; + recursion no; + notify yes; + dnssec-validation no; +}; + +zone "example" { + type secondary; + primaries { 10.53.0.1 port @PORT@; }; + file "example.db"; +}; diff --git a/bin/tests/system/nsec_ixfr/setup.sh b/bin/tests/system/nsec_ixfr/setup.sh new file mode 100755 index 0000000000..aaf05813f7 --- /dev/null +++ b/bin/tests/system/nsec_ixfr/setup.sh @@ -0,0 +1,33 @@ +#!/bin/sh + +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +# shellcheck source=conf.sh +. ../conf.sh + +set -e + +# Start with the unsigned zone (serial 1). +cp ns1/example.db.in ns1/example.db + +# Generate DNSSEC keys for NSEC signing. +"$KEYGEN" -q -f KSK -a "$DEFAULT_ALGORITHM" -K ns1 example >/dev/null +"$KEYGEN" -q -a "$DEFAULT_ALGORITHM" -K ns1 example >/dev/null + +# Create the signed zone file with serial 2. +# Bump the serial, then sign with plain NSEC (no -3 flag). +# The -S flag tells dnssec-signzone to automatically find keys and +# include DNSKEY records. +sed 's/1\([ ]*;[ ]*serial\)/2\1/' ns1/example.db.in >ns1/example.db.tosign +"$SIGNER" -P -S -K ns1 -o example -f ns1/example.db.signed \ + ns1/example.db.tosign >/dev/null 2>&1 +rm -f ns1/example.db.tosign diff --git a/bin/tests/system/nsec_ixfr/tests_nsec_ixfr.py b/bin/tests/system/nsec_ixfr/tests_nsec_ixfr.py new file mode 100644 index 0000000000..a93a4e87bf --- /dev/null +++ b/bin/tests/system/nsec_ixfr/tests_nsec_ixfr.py @@ -0,0 +1,92 @@ +#!/usr/bin/python3 + +# Copyright (C) Internet Systems Consortium, Inc. ("ISC") +# +# SPDX-License-Identifier: MPL-2.0 +# +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, you can obtain one at https://mozilla.org/MPL/2.0/. +# +# See the COPYRIGHT file distributed with this work for additional +# information regarding copyright ownership. + +"""Test that NSEC records received via IXFR produce correct denial-of-existence +proofs for empty non-terminal names. + +When a secondary receives NSEC records via IXFR (transitioning from an unsigned +zone to an NSEC-signed zone), queries for empty non-terminal names should return +the NSEC record that covers the ENT, not the zone apex NSEC.""" + +import shutil + +import dns.name +import dns.rdatatype + +import isctest + +ORIGIN = dns.name.from_text("example.") +QNAME = dns.name.from_text("wildcard.example.") + + +def test_nsec_ixfr_empty_nonterminal(ns1, ns2): + """Verify correct NSEC proof for ENT after IXFR from unsigned to signed. + + 1. Wait for ns2 to have the unsigned zone (serial 1) via AXFR. + 2. Switch ns1 to the signed zone (serial 2), reload. + 3. Wait for ns2 to pick up serial 2 (via IXFR). + 4. Query ns2 for wildcard.example. A +dnssec. + 5. Verify the AUTHORITY section contains the correct covering NSEC. + """ + + # Step 1: Wait for initial unsigned zone transfer to complete. + isctest.query.wait_for_serial(ns2.ip, "example", 1) + + # Step 2: Replace the zone on ns1 with the signed version and reload. + shutil.copy("ns1/example.db.signed", "ns1/example.db") + ns1.rndc("reload") + + # Step 3: Wait for ns2 to get the signed zone via IXFR. + isctest.query.wait_for_serial(ns2.ip, "example", 2) + + # Step 4: Query ns2 for the empty non-terminal with DNSSEC. + msg = isctest.query.create(QNAME, "A", dnssec=True) + res = isctest.query.tcp(msg, ns2.ip) + + # The ENT wildcard.example. has no A record, so this should be NOERROR + # with an empty answer (the wildcard *.wildcard.example. does not match + # wildcard.example. itself). + isctest.check.noerror(res) + assert len(res.answer) == 0, f"expected empty answer for ENT, got: {res.answer}" + + # Step 5: Verify the NSEC record covers the ENT, not the apex. + nsec_rrsets = [ + rrset for rrset in res.authority if rrset.rdtype == dns.rdatatype.NSEC + ] + assert ( + len(nsec_rrsets) > 0 + ), f"no NSEC records in authority section: {res.authority}" + + # The bug (f4b4f030) returns the apex NSEC instead of the correct + # covering NSEC, because the node's havensec flag was not set + # during IXFR. + for rrset in nsec_rrsets: + assert rrset.name != ORIGIN, ( + f"got apex NSEC '{rrset.name} -> {rrset[0].next}' instead " + f"of the covering NSEC for {QNAME}" + ) + + # Verify the returned NSEC actually covers the ENT: the NSEC owner + # must be canonically before the ENT, and the NSEC next name must be + # canonically after (or wrap around to the apex). + found_covering = False + for rrset in nsec_rrsets: + nsec_next = rrset[0].next + if rrset.name < QNAME and (nsec_next > QNAME or nsec_next <= rrset.name): + found_covering = True + + assert ( + found_covering + ), f"no NSEC covers {QNAME}; " f"NSEC records found: " + ", ".join( + f"'{rrset.name} -> {rrset[0].next}'" for rrset in nsec_rrsets + ) From 3fe3751c223eb61f6fed0e0d4602458ec639f608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Sur=C3=BD?= Date: Thu, 2 Apr 2026 10:45:15 +0200 Subject: [PATCH 2/2] Fix wrong NSEC proof for empty non-terminals after IXFR When receiving NSEC records via IXFR, the node was not marked with havensec because the condition checked the uninitialized output rdataset type instead of the input rdataset type. This caused queries for empty non-terminal names in NSEC-signed zones received via IXFR to return the zone apex NSEC instead of the correct covering NSEC record. The bug was introduced in f4b4f030. --- lib/dns/qpzone.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dns/qpzone.c b/lib/dns/qpzone.c index e8440579cf..d7604affba 100644 --- a/lib/dns/qpzone.c +++ b/lib/dns/qpzone.c @@ -5555,7 +5555,7 @@ qpzone_update_rdataset(qpzonedb_t *qpdb, qpz_version_t *version, dns_qp_t *qp, */ options = DNS_DBADD_MERGE | DNS_DBADD_EXACT | DNS_DBADD_EXACTTTL; - if (!node->havensec && ardataset.type == dns_rdatatype_nsec) { + if (!node->havensec && rds->type == dns_rdatatype_nsec) { nsec = qp; } result = qpzone_addrdataset_inner(