From: Ondřej Surý Date: Thu, 2 Apr 2026 08:45:03 +0000 (+0200) Subject: Add regression test for NSEC proof after unsigned-to-signed IXFR X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=8a4990d6ff692b50c3b295456b74b33441337cde;p=thirdparty%2Fbind9.git 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. --- diff --git a/bin/tests/system/isctest/query.py b/bin/tests/system/isctest/query.py index 9407dd6f472..a7e862b7f6c 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 00000000000..b7d326be001 --- /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 00000000000..560580b65d2 --- /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 00000000000..abdf03cc333 --- /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 00000000000..aaf05813f7c --- /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 00000000000..a93a4e87bfd --- /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 + )