From 4aedf7e9dda89bed7b3c6f22ad7078cd3bcbcb8b Mon Sep 17 00:00:00 2001 From: Colin Vidal Date: Fri, 10 Apr 2026 14:54:49 +0200 Subject: [PATCH 1/2] Do not resend after BADCOOKIE answer on TCP When an upstream server answers BADCOOKIE, no matter the transport used, the resolver eventually resends the query using TCP. However, if the upstream server responds with BADCOOKIE again over TCP, the resolver would keep resending until the maximum query count is reached. This is now fixed by stopping resending once the query has already been sent over TCP. --- lib/dns/resolver.c | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/dns/resolver.c b/lib/dns/resolver.c index d7418d494a..742748d2f3 100644 --- a/lib/dns/resolver.c +++ b/lib/dns/resolver.c @@ -9804,7 +9804,9 @@ rctx_badserver(respctx_t *rctx, isc_result_t result) { rctx->broken_server = DNS_R_BADVERS; rctx->next_server = true; #endif /* if DNS_EDNS_VERSION > 0 */ - } else if (rcode == dns_rcode_badcookie && rctx->query->rmessage->cc_ok) + } else if (rcode == dns_rcode_badcookie && + rctx->query->rmessage->cc_ok && + (rctx->retryopts & DNS_FETCHOPT_TCP) == 0) { /* * We have recorded the new cookie. From 47a80bbd8769a013e5caffea3737d30372ab4755 Mon Sep 17 00:00:00 2001 From: Colin Vidal Date: Fri, 10 Apr 2026 14:55:09 +0200 Subject: [PATCH 2/2] Update resend_loop_badcookie system test Update the resend_loop_badcookie system test to ensure there is no attempt to resend the query using TCP when getting BADCOOKIE from an upstream server using this transport already. --- bin/tests/system/resend_loop/ans3/ans.py | 50 ++++++++------- .../system/resend_loop/ns4/named.conf.j2 | 1 + .../system/resend_loop/tests_resend_loop.py | 61 ++++++++++++++++++- 3 files changed, 88 insertions(+), 24 deletions(-) diff --git a/bin/tests/system/resend_loop/ans3/ans.py b/bin/tests/system/resend_loop/ans3/ans.py index 217bae0301..d0cb6d2935 100644 --- a/bin/tests/system/resend_loop/ans3/ans.py +++ b/bin/tests/system/resend_loop/ans3/ans.py @@ -14,14 +14,17 @@ from collections.abc import AsyncGenerator import dns.edns import dns.name import dns.rcode +import dns.rdataclass import dns.rdatatype import dns.rrset from isctest.asyncserver import ( AsyncDnsServer, DnsResponseSend, + QnameHandler, QueryContext, ResponseHandler, + StaticResponseHandler, ) @@ -41,31 +44,33 @@ def _get_cookie(qctx: QueryContext): return None -class PrimeHandler(ResponseHandler): - """ - Specifically handle priming query for "." NS (type 2) - """ +def rrset( + qname: dns.name.Name | str, + rtype: dns.rdatatype.RdataType, + rdata: str, + ttl: int = 300, +) -> dns.rrset.RRset: + return dns.rrset.from_text(qname, ttl, dns.rdataclass.IN, rtype, rdata) - def match(self, qctx: QueryContext) -> bool: - return len(qctx.qname.labels) == 0 and qctx.qtype == dns.rdatatype.NS - async def get_responses( - self, qctx: QueryContext - ) -> AsyncGenerator[DnsResponseSend, None]: +class RootNSHandler(QnameHandler, StaticResponseHandler): + qnames = ["."] + answer = [ + rrset(".", dns.rdatatype.NS, "a.root-servers.nil."), + ] + additional = [ + rrset("a.root-servers.nil.", dns.rdatatype.A, "10.53.0.3"), + ] - ns_rrset = dns.rrset.from_text( - ".", dns.rdatatype.NS, qctx.qclass, "a.root-servers.nil." - ) - a_rrset = dns.rrset.from_text( - "a.root-servers.nil.", dns.rdatatype.A, qctx.qclass, "10.53.0.3" - ) - response = qctx.prepare_new_response(with_zone_data=False) - response.set_rcode(dns.rcode.NOERROR) - response.answer.append(ns_rrset) - response.additional.append(a_rrset) - - yield DnsResponseSend(response, authoritative=True) +class ExampleNSHandler(QnameHandler, StaticResponseHandler): + qnames = ["example."] + answer = [ + rrset("example.", dns.rdatatype.NS, "ns.example."), + ] + additional = [ + rrset("ns.example.", dns.rdatatype.A, "10.53.0.3"), + ] class CookieHandler(ResponseHandler): @@ -111,7 +116,8 @@ class NoErrorHandler(ResponseHandler): def resend_server() -> AsyncDnsServer: server = AsyncDnsServer(default_aa=True, default_rcode=dns.rcode.NOERROR) server.install_response_handlers( - PrimeHandler(), + RootNSHandler(), + ExampleNSHandler(), CookieHandler(), NoErrorHandler(), ) diff --git a/bin/tests/system/resend_loop/ns4/named.conf.j2 b/bin/tests/system/resend_loop/ns4/named.conf.j2 index 360bc12e17..e686eaaa42 100644 --- a/bin/tests/system/resend_loop/ns4/named.conf.j2 +++ b/bin/tests/system/resend_loop/ns4/named.conf.j2 @@ -6,6 +6,7 @@ options { pid-file "named.pid"; listen-on { 10.53.0.4; }; listen-on-v6 { none; }; + query-source-v6 none; recursion yes; dnssec-validation no; }; diff --git a/bin/tests/system/resend_loop/tests_resend_loop.py b/bin/tests/system/resend_loop/tests_resend_loop.py index f7ed4d3da6..a9a236fa58 100644 --- a/bin/tests/system/resend_loop/tests_resend_loop.py +++ b/bin/tests/system/resend_loop/tests_resend_loop.py @@ -9,18 +9,75 @@ # See the COPYRIGHT file distributed with this work for additional # information regarding copyright ownership. +from re import compile as Re + import dns.message import isctest +# This test verifies the query pattern when the upstream behaves badly. +# In this scenario, the upstream server (ans3) always responds with a +# BADCOOKIE error for queries within the "example" zone, even on TCP. +# The resolver (ns4), should not resend the same queries over and over +# again, up to the max-query-count threshold. Instead, the expected +# pattern is: +# 1. Priming query, getting the NS for . +# 2. Getting the NS for example. +# 3. Trying to resolve test.example. +# 4. Trying again, but now with the server cookie. +# 5. Trying again, now over TCP. +# +# This means we expect 5 recursion queries trying to resolve test.example. def test_resend_loop_badcookie(ns4): - expected_log = "exceeded max queries resolving 'test.example/A'" + sending_packet = Re("sending packet from 10.53.0.4#[0-9]+ to 10.53.0.3#[0-9]+") + received_packet = Re("received packet from 10.53.0.3#[0-9]+ to 10.53.0.4#[0-9]+") + + log_sequence = [ + # 1. Priming query, getting the NS for . + sending_packet, + Re("COOKIE: [0-9a-z]{16}$"), + Re(".\\s+IN\\s+NS"), + # 2. Getting the NS for example. + sending_packet, + Re("COOKIE: [0-9a-z]{16}$"), + Re("example.\\s+IN\\s+NS"), + # 3. Trying to resolve test.example. + sending_packet, + Re("COOKIE: [0-9a-z]{16}$"), + Re("test.example.\\s+IN\\s+A"), + # Get the first BADCOOKIE error. + "UDP response", + received_packet, + "BADCOOKIE", + Re("COOKIE: [0-9a-z]{16}1122334455667788"), + Re("test.example.\\s+IN\\s+A"), + # 4. Trying again, but now with the server cookie. + sending_packet, + Re("test.example.\\s+IN\\s+A"), + # Get BADCOOKIE error again. + "UDP response", + received_packet, + "BADCOOKIE", + Re("COOKIE: [0-9a-z]{16}1122334455667788"), + Re("test.example.\\s+IN\\s+A"), + # 5. Trying again, now over TCP. + sending_packet, + Re("test.example.\\s+IN\\s+A"), + # Fails and give up. + "TCP response", + received_packet, + "BADCOOKIE", + Re("COOKIE: [0-9a-z]{16}1122334455667788"), + Re("test.example.\\s+IN\\s+A"), + ] msg = dns.message.make_query("test.example", "A") with ns4.watch_log_from_here() as watcher: res = isctest.query.udp(msg, ns4.ip) - watcher.wait_for_line(expected_log) + watcher.wait_for_sequence(log_sequence) + + assert len(ns4.log.grep(sending_packet)) == 5 isctest.check.servfail(res)