--- /dev/null
+# 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.
+
+"""
+Custom authoritative server for the sibling-ds test.
+
+When returning a referral for child.sibling-ds, this server injects a DS
+record for sibling.sibling-ds into the authority section. The resolver
+should reject this because the DS owner name does not match the
+delegation (NS) name.
+"""
+
+from collections.abc import AsyncGenerator
+
+import dns.rdatatype
+import dns.rrset
+
+from isctest.asyncserver import (
+ AsyncDnsServer,
+ DnsResponseSend,
+ DomainHandler,
+ QueryContext,
+ ResponseAction,
+)
+
+
+class SiblingDsInjectionHandler(DomainHandler):
+ """Inject a DS record for sibling.sibling-ds into child.sibling-ds referrals."""
+
+ domains = ["child.sibling-ds."]
+
+ async def get_responses(
+ self, qctx: QueryContext
+ ) -> AsyncGenerator[ResponseAction, None]:
+ # The default zone-data response already has the NS delegation for
+ # child.sibling-ds. and glue. Add a DS record for the *sibling* zone
+ # (wrong name for this referral).
+ sibling_ds = dns.rrset.from_text(
+ "sibling.sibling-ds.",
+ 300,
+ qctx.qclass,
+ dns.rdatatype.DS,
+ "12345 8 2 "
+ "49FD46E6C4B45C55D4AC69CBD3CD34AC1AFE51DE7B2B585ABCDEABCDEABCDEAB",
+ )
+ qctx.response.authority.append(sibling_ds)
+ yield DnsResponseSend(qctx.response)
+
+
+def main() -> None:
+ server = AsyncDnsServer()
+ server.install_response_handler(SiblingDsInjectionHandler())
+ server.run()
+
+
+if __name__ == "__main__":
+ main()
--- /dev/null
+# 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 the resolver rejects DS records for sibling zones in referrals.
+
+A custom authoritative server (ans4) returns a referral for
+child.sibling-ds that includes a DS record for sibling.sibling-ds. The
+resolver must detect that the DS owner does not match the delegation NS
+name and treat the response as a form error.
+"""
+
+from re import compile as Re
+
+from dnssec_py.common import DNSSEC_PY_MARK
+from isctest.template import NS2, Nameserver, zones
+from isctest.zone import Zone, configure_root
+
+import isctest
+
+pytestmark = DNSSEC_PY_MARK
+
+ANS4 = Nameserver("ans4")
+
+
+def bootstrap():
+ # Child zone on ns2 — the test queries a.child.sibling-ds which
+ # resolves to the default template A record (10.0.0.1).
+ child = Zone("child.sibling-ds", NS2)
+ child.configure()
+
+ # Sibling zone on ns2 — exists so the sibling DS in the referral
+ # refers to a real delegation.
+ sibling = Zone("sibling.sibling-ds", NS2)
+ sibling.configure()
+
+ # Parent zone rendered into ans4/ (subdir=None puts the .db file
+ # directly in the ans4 directory where AsyncDnsServer loads it).
+ parent = Zone("sibling-ds", ANS4, subdir=None)
+ parent.delegations = [child, sibling]
+ parent.configure()
+
+ # Root zone delegates sibling-ds. to ans4.
+ root = configure_root([parent])
+
+ return {
+ "trust_anchors": root.trust_anchors(),
+ "zones": zones([root, child, sibling]),
+ }
+
+
+def test_sibling_ds_rejected(ns9):
+ """Resolver must reject a referral that contains DS for a sibling zone."""
+ log_ds_mismatch = Re(r"DS doesn't match the delegation owner name")
+
+ msg = isctest.query.create("a.child.sibling-ds.", "A")
+
+ with ns9.watch_log_from_here() as watcher:
+ res = isctest.query.tcp(msg, ns9.ip)
+ watcher.wait_for_line(log_ds_mismatch)
+
+ isctest.check.servfail(res)