fix: usr: fetch loop detection improvements

Fixes a case where an in-domain NS with an expired glue would fail to resolve.

Let's consider the following parent-side delegation (both for `foo.example.` and `dnshost.example.`

```
foo.example.            3600    NS      ns.dnshost.example.
dnshost.example.        3600    NS      ns.dnshost.example.
ns.dnshost.example.     3600    A       1.2.3.4
```
    
Then the child-side of `dnshost.example.`:

```    
dnshost.example.        300     NS      ns.dnshost.example.
ns.dnshost.example.     300     A       1.2.3.4
```
    
And then the child-side of `foo.example.`:

```
foo.example             3600    NS      ns.dnshost.example.
a.foo.example           300     A       5.6.7.8
```

While there is a zone misconfiguration (the TTL of the delegation and glue doesn't match in the parent and the child), it is possible to resolve `a.foo.example` on a cold-cache resolver. However, after the `ns.dnshost.example.` glue expires, the resolution would have failed with a "fetch loop detected" error. This is now fixed.

Closes #5588

Merge branch '5588-loopfetches' into 'main'

See merge request isc-projects/bind9!11535
This commit is contained in:
Colin Vidal 2026-02-11 15:12:02 +01:00
commit bc0e9f1ccb
15 changed files with 410 additions and 24 deletions

View file

@ -120,6 +120,9 @@ extern unsigned int dns_zone_mkey_hour;
extern unsigned int dns_zone_mkey_day;
extern unsigned int dns_zone_mkey_month;
extern unsigned int dns_adb_entrywindow;
extern unsigned int dns_adb_cachemin;
static bool want_stats = false;
static char absolute_conffile[PATH_MAX];
static char saved_command_line[4096] = { 0 };
@ -723,6 +726,10 @@ parse_T_opt(char *option) {
transferstuck = true;
} else if (!strncmp(option, "tat=", 4)) {
named_g_tat_interval = atoi(option + 4);
} else if (!strncmp(option, "adbentrywindow=", 15)) {
dns_adb_entrywindow = atoi(option + 15);
} else if (!strncmp(option, "adbcachemin=", 12)) {
dns_adb_cachemin = atoi(option + 12);
} else {
fprintf(stderr, "unknown -T flag '%s'\n", option);
}

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
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; };
recursion no;
dnssec-validation no;
};
view "default" {
zone "." {
type primary;
file "root.db";
};
};
key rndc_key {
secret "1234abcd8765";
algorithm @DEFAULT_HMAC@;
};
controls {
inet 10.53.0.1 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};

View file

@ -0,0 +1,24 @@
; 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.
$TTL 300
. IN SOA marka.isc.org. a.root.servers.nil. (
2010 ; serial
600 ; refresh
600 ; retry
1200 ; expire
600 ; minimum
)
. NS a.root-servers.nil.
a.root-servers.nil. A 10.53.0.1
tld. NS ns.tld.
ns.tld. A 10.53.0.2

View file

@ -0,0 +1,37 @@
/*
* 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.
*/
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; };
recursion no;
dnssec-validation no;
};
zone "tld." {
type primary;
file "tld.db";
};
key rndc_key {
secret "1234abcd8765";
algorithm @DEFAULT_HMAC@;
};
controls {
inet 10.53.0.2 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};

View file

@ -0,0 +1,28 @@
; 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.
$TTL 300
tld. IN SOA marka.isc.org. ns.tld. (
2010 ; serial
600 ; refresh
600 ; retry
1200 ; expire
600 ; minimum
)
tld. NS ns.tld.
ns.tld. A 10.53.0.2
example.tld. NS ns.dnshoster.tld.
missing.tld. NS ns.missing.tld.
dnshoster.tld. NS ns.dnshoster.tld.
; Delegation's glue has a TTL of 300 on parent-side
ns.dnshoster.tld. A 10.53.0.3

View file

@ -0,0 +1,24 @@
; 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.
$TTL 300
dnshoster.tld. IN SOA marka.isc.org. ns.dnshoster.tld. (
2010 ; serial
600 ; refresh
600 ; retry
1200 ; expire
600 ; minimum
)
; The TTL of the delegation's glue child-side is 2 seconds.
dnshoster.tld. NS ns.dnshoster.tld.
ns.dnshoster.tld. 2 A 10.53.0.3
a.dnshoster.tld. 2 A 10.53.0.10

View file

@ -0,0 +1,22 @@
; 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.
$TTL 300
example.tld. IN SOA marka.isc.org. ns.dnshoster.tld. (
2010 ; serial
600 ; refresh
600 ; retry
1200 ; expire
600 ; minimum
)
example.tld. NS ns.dnshoster.tld.
a.example.tld. 2 A 10.53.0.10

View file

@ -0,0 +1,42 @@
/*
* 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.
*/
options {
query-source address 10.53.0.3;
notify-source 10.53.0.3;
transfer-source 10.53.0.3;
port @PORT@;
pid-file "named.pid";
listen-on { 10.53.0.3; };
recursion no;
dnssec-validation no;
};
zone "dnshoster.tld." {
type primary;
file "dnshoster.tld.db";
};
zone "example.tld." {
type primary;
file "example.tld.db";
};
key rndc_key {
secret "1234abcd8765";
algorithm @DEFAULT_HMAC@;
};
controls {
inet 10.53.0.3 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};

View file

@ -0,0 +1 @@
-D expiredglue-ns4 -m record -c named.conf -d 99 -g -4 -T adbentrywindow=0 -T adbcachemin=1 -T maxcachesize=2097152

View file

@ -0,0 +1,37 @@
/*
* 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.
*/
options {
query-source address 10.53.0.4;
notify-source 10.53.0.4;
transfer-source 10.53.0.4;
port @PORT@;
pid-file "named.pid";
listen-on { 10.53.0.4; };
recursion yes;
dnssec-validation no;
};
zone "." {
type hint;
file "root.hint";
};
key rndc_key {
secret "1234abcd8765";
algorithm @DEFAULT_HMAC@;
};
controls {
inet 10.53.0.4 port @CONTROLPORT@ allow { any; } keys { rndc_key; };
};

View file

@ -0,0 +1,14 @@
; 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.
$TTL 999999
. IN NS a.root-servers.nil.
a.root-servers.nil. IN A 10.53.0.1

View file

@ -0,0 +1,55 @@
# 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.
import time
import isctest
def test_expiredglue(ns4):
msg1 = isctest.query.create("a.example.tld.", "A")
res1 = isctest.query.udp(msg1, ns4.ip)
isctest.check.noerror(res1)
isctest.check.rr_count_eq(res1.answer, 1)
msg2 = isctest.query.create("a.dnshoster.tld.", "A")
res2 = isctest.query.udp(msg2, ns4.ip)
isctest.check.rr_count_eq(res2.answer, 1)
msg3 = isctest.query.create("ns.dnshoster.tld.", "A")
res3 = isctest.query.udp(msg3, ns4.ip)
isctest.check.rr_count_eq(res3.answer, 1)
time.sleep(3)
# Even if the glue is expired but the delegation is not, named
# is able to "recover" by looking up the hints again and does
# not bails out with a fetch loop detection.
res1_2 = isctest.query.udp(msg1, ns4.ip)
isctest.check.same_data(res1_2, res1)
time.sleep(3)
res2_2 = isctest.query.udp(msg2, ns4.ip)
isctest.check.same_data(res2_2, res2)
time.sleep(3)
res3_2 = isctest.query.udp(msg3, ns4.ip)
isctest.check.same_data(res3_2, res3)
def test_loopdetected(ns4):
msg = isctest.query.create("a.missing.tld.", "A")
with ns4.watch_log_from_here() as watcher:
res = isctest.query.udp(msg, ns4.ip)
# However, this is a valid fetch loop, and named detects it.
watcher.wait_for_line("loop detected resolving 'ns.missing.tld/A'")
isctest.check.servfail(res)

View file

@ -63,12 +63,8 @@
/*!
* For type 3 negative cache entries, we will remember that the address is
* broken for this long. XXXMLG This is also used for actual addresses, too.
* The intent is to keep us from constantly asking about A/AAAA records
* if the zone has extremely low TTLs.
*/
#define ADB_CACHE_MINIMUM 10 /*%< seconds */
#define ADB_CACHE_MAXIMUM 86400 /*%< seconds (86400 = 24 hours) */
#define ADB_ENTRY_WINDOW 60 /*%< seconds */
#define ADB_HASH_SIZE (1 << 12)
@ -284,6 +280,12 @@ ISC_REFCOUNT_TRACE_DECL(dns_adbentry);
ISC_REFCOUNT_DECL(dns_adbentry);
#endif
/*
* ADB settings that can be tweaked with named -T option
*/
unsigned int dns_adb_entrywindow = 60;
unsigned int dns_adb_cachemin = 10;
/*
* Internal functions (and prototypes).
*/
@ -457,10 +459,10 @@ enum {
* Due to the ttlclamp(), the TTL is never 0 unless the trust is ultimate,
* in which case we need to set the expiration to have immediate effect.
*/
#define ADJUSTED_EXPIRE(expire, now, ttl) \
((ttl != 0) \
? ISC_MIN(expire, ISC_MAX(now + ADB_ENTRY_WINDOW, now + ttl)) \
: INT_MAX)
#define ADJUSTED_EXPIRE(expire, now, ttl) \
((ttl != 0) ? ISC_MIN(expire, \
ISC_MAX(now + dns_adb_entrywindow, now + ttl)) \
: INT_MAX)
/*
* Error states.
@ -533,8 +535,12 @@ inc_adbstats(dns_adb_t *adb, isc_statscounter_t counter) {
static dns_ttl_t
ttlclamp(dns_ttl_t ttl) {
if (ttl < ADB_CACHE_MINIMUM) {
ttl = ADB_CACHE_MINIMUM;
if (ttl < dns_adb_cachemin) {
/*
* Avoid to constantly ask about A/AAAA records if the zone has
* extremely low TTLs.
*/
ttl = dns_adb_cachemin;
}
if (ttl > ADB_CACHE_MAXIMUM) {
ttl = ADB_CACHE_MAXIMUM;
@ -567,7 +573,11 @@ import_rdataset(dns_adbname_t *adbname, dns_rdataset_t *rdataset,
case dns_trust_additional:
case dns_trust_pending_answer:
case dns_trust_pending_additional:
rdataset->ttl = ADB_CACHE_MINIMUM;
/*
* Avoid to constantly ask about A/AAAA records if the zone has
* extremely low TTLs.
*/
rdataset->ttl = dns_adb_cachemin;
break;
case dns_trust_ultimate:
rdataset->ttl = 0;
@ -972,7 +982,7 @@ new_adbentry(dns_adb_t *adb, const isc_sockaddr_t *addr, isc_stdtime_t now) {
.quota = adb->quota,
.references = ISC_REFCOUNT_INITIALIZER(1),
.adb = dns_adb_ref(adb),
.expires = now + ADB_ENTRY_WINDOW,
.expires = now + dns_adb_entrywindow,
.loop = isc_loop_ref(isc_loop()),
.magic = DNS_ADBENTRY_MAGIC,
};
@ -2018,6 +2028,10 @@ post_copy:
find->cbarg = cbarg;
}
if (wanted_fetches) {
find->options |= DNS_ADBFIND_STARTEDFETCH;
}
*findp = find;
UNLOCK(&adbname->lock);

View file

@ -194,6 +194,10 @@ struct dns_adbfind {
*/
#define DNS_ADBFIND_STATICSTUB 0x00001000
#define DNS_ADBFIND_NOVALIDATE 0x00002000
/*%
* This specific find created a fetch
*/
#define DNS_ADBFIND_STARTEDFETCH 0x00010000
/*%
* The answers to queries come back as a list of these.

View file

@ -1087,6 +1087,9 @@ set_stats(dns_resolver_t *res, isc_statscounter_t counter, uint64_t val) {
}
}
static bool
waiting_for_fetch(fetchctx_t *fctx, const dns_name_t *name,
dns_rdatatype_t type, const dns_name_t *domain);
static void
valcreate(fetchctx_t *fctx, dns_message_t *message, dns_adbaddrinfo_t *addrinfo,
dns_name_t *name, dns_rdatatype_t type, dns_rdataset_t *rdataset,
@ -3340,9 +3343,10 @@ sort_finds(dns_adbfindlist_t *findlist, unsigned int bias) {
}
/*
* Return true iff the ADB find has a pending fetch for 'type'. This is
* used to find out whether we're in a loop, where a fetch is waiting for a
* find which is waiting for that same fetch.
* Return true iff the ADB find has an already pending fetch for 'type'. This
* is used to find out whether we're in a loop, where a fetch is waiting for a
* find which is waiting for that same fetch. So if the current find actually
* started the fetch, we know it can't be a loop, so we returns false.
*
* Note: This could be done with either an equivalence check (e.g.,
* query_pending == DNS_ADBFIND_INET) or with a bit check, as below. If
@ -3361,7 +3365,11 @@ sort_finds(dns_adbfindlist_t *findlist, unsigned int bias) {
* evil.
*/
static bool
waiting_for(dns_adbfind_t *find, dns_rdatatype_t type) {
already_waiting_for(dns_adbfind_t *find, dns_rdatatype_t type) {
if ((find->options & DNS_ADBFIND_STARTEDFETCH) != 0) {
return false;
}
switch (type) {
case dns_rdatatype_a:
return (find->query_pending & DNS_ADBFIND_INET) != 0;
@ -3471,22 +3479,25 @@ findname(fetchctx_t *fctx, const dns_name_t *name, in_port_t port,
* We don't know any of the addresses for this name.
*
* The find may be waiting on a resolver fetch for a server
* address. We need to make sure it isn't waiting on *this*
* address. We need to make sure it isn't waiting before *this*
* fetch, because if it is, we won't be answering it and it
* won't be answering us.
*/
if (waiting_for(find, fctx->type) && dns_name_equal(name, fctx->name)) {
fctx->adberr++;
if (already_waiting_for(find, fctx->type) &&
dns_name_equal(name, fctx->name))
{
isc_log_write(DNS_LOGCATEGORY_RESOLVER, DNS_LOGMODULE_RESOLVER,
ISC_LOG_INFO, "loop detected resolving '%s'",
fctx->info);
fctx->adberr++;
if ((find->options & DNS_ADBFIND_WANTEVENT) != 0) {
dns_adb_cancelfind(find);
} else {
dns_adb_destroyfind(&find);
fetchctx_detach(&fctx);
}
return;
}
@ -10126,12 +10137,27 @@ get_attached_fctx(dns_resolver_t *res, isc_loop_t *loop, const dns_name_t *name,
return ISC_R_SUCCESS;
}
static bool
is_samedomain(const dns_name_t *domain1, const dns_name_t *domain2) {
if (domain1 == NULL && domain2 == NULL) {
return true;
}
if (domain1 == NULL || domain2 == NULL) {
return false;
}
return !dns_name_compare(domain1, domain2);
}
static bool
waiting_for_fetch(fetchctx_t *fctx, const dns_name_t *name,
dns_rdatatype_t type) {
dns_rdatatype_t type, const dns_name_t *domain) {
while (fctx != NULL) {
if (type == fctx->type && !dns_name_compare(name, fctx->name)) {
return true;
if (is_samedomain(domain, fctx->domain)) {
return true;
}
}
fctx = fctx->parent;
}
@ -10181,18 +10207,30 @@ dns_resolver_createfetch(dns_resolver_t *res, const dns_name_t *name,
log_fetch(name, type);
if (waiting_for_fetch(parent, name, type)) {
/*
* This fetch loop detection enable to guard against loop scenarios
* where the DNSSEC is involved. See
* `4d307ac67a0e3f9831c9a4e66ac481e2f9ceebb5`. This is a complementary
* detection with the ADB lookup loop detection (in `findname()`).
*/
if (waiting_for_fetch(parent, name, type, domain)) {
if (isc_log_wouldlog(ISC_LOG_INFO)) {
char namebuf[DNS_NAME_FORMATSIZE + 1];
char typebuf[DNS_RDATATYPE_FORMATSIZE];
char domainbuf[DNS_NAME_FORMATSIZE + 1] = { 0 };
dns_name_format(name, namebuf, sizeof(namebuf));
dns_rdatatype_format(type, typebuf, sizeof(typebuf));
if (domain != NULL) {
dns_name_format(domain, domainbuf,
sizeof(domainbuf));
}
isc_log_write(DNS_LOGCATEGORY_RESOLVER,
DNS_LOGMODULE_RESOLVER, ISC_LOG_DEBUG(2),
"fetch loop detected resolving '%s/%s'",
namebuf, typebuf);
"fetch loop detected resolving '%s/%s "
"(in '%s'?)",
namebuf, typebuf, domainbuf);
}
return DNS_R_LOOPDETECTED;
}