]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Add regression test for NSEC proof after unsigned-to-signed IXFR
authorOndřej Surý <ondrej@isc.org>
Thu, 2 Apr 2026 08:45:03 +0000 (10:45 +0200)
committerOndřej Surý <ondrej@isc.org>
Fri, 3 Apr 2026 04:33:31 +0000 (06:33 +0200)
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
bin/tests/system/nsec_ixfr/ns1/example.db.in [new file with mode: 0644]
bin/tests/system/nsec_ixfr/ns1/named.conf.j2 [new file with mode: 0644]
bin/tests/system/nsec_ixfr/ns2/named.conf.j2 [new file with mode: 0644]
bin/tests/system/nsec_ixfr/setup.sh [new file with mode: 0755]
bin/tests/system/nsec_ixfr/tests_nsec_ixfr.py [new file with mode: 0644]

index 9407dd6f472c01118e397d922e39f56fcbd80331..a7e862b7f6c95fb5422dd84b45bb3f16ec5fbb91 100644 (file)
@@ -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 (file)
index 0000000..b7d326b
--- /dev/null
@@ -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 (file)
index 0000000..560580b
--- /dev/null
@@ -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 (file)
index 0000000..abdf03c
--- /dev/null
@@ -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 (executable)
index 0000000..aaf0581
--- /dev/null
@@ -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 (file)
index 0000000..a93a4e8
--- /dev/null
@@ -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
+    )