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.

(cherry picked from commit 8a4990d6ff)
This commit is contained in:
Ondřej Surý 2026-04-02 10:45:03 +02:00 committed by Ondřej Surý (GitLab job 7145073)
parent 097c14da45
commit 1be03f3a10
6 changed files with 225 additions and 0 deletions

View 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
@ -146,3 +149,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}",
)

View file

@ -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

View file

@ -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;
};

View file

@ -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";
};

View file

@ -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

View file

@ -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
)