Hi Simon,
we've found a bug where locally-configured hostnames (from /etc/hosts,
DHCP leases, or host-record) can return NXDOMAIN to clients instead of
the expected local answer. The attached patch fixes this.
The problem has two parts:
1) When a client queries a local hostname for a record type that has no
local answer (e.g. AAAA when only an A record exists in /etc/hosts),
answer_request() returns 0 and the query is forwarded upstream. Since
the domain only exists locally, upstream returns NXDOMAIN. The correct
response would be NODATA -- the domain exists, it just doesn't have a
record of the requested type.
This is particularly visible with modern Linux resolvers like
systemd-resolved as they routinely send AAAA and A queries independently
at the same time. Most often, modern versions also issue HTTPS queries.
The NXDOMAIN response to the AAAA/HTTPS query can cause the client to
treat the entire domain as non-existent, even though the A answer comes
back correctly.
2) There is already a safety net in process_reply() that converts
upstream NXDOMAIN to NODATA for locally-known domains. However, this
conversion is inside the !bogusanswer gate (line 793 on current master),
so it is skipped when DNSSEC validation fails. For local hostnames under
publicly-signed domains, the upstream NXDOMAIN proof can fail
validation, bypassing the safety net.
The fix:
- In answer_request(): before the final `return 0`, check whether the
domain has any local record with F_HOSTS/F_DHCP/F_CONFIG. If so, return
NODATA instead of forwarding. This prevents the upstream query entirely.
- In process_reply(): move the NXDOMAIN-to-NODATA conversion for
locally-known domains before the bogusanswer check, so it applies
regardless of DNSSEC validation status. Local host records are
authoritative for domain existence.
Originally reported and investigated by me in
https://github.com/pi-hole/FTL/issues/2841
Cheers,
Dominik
From bb1f182beeb5ca5c8ecc4638d302cb6ed183acc8 Mon Sep 17 00:00:00 2001
From: Dominik <[email protected]>
Date: Sun, 12 Apr 2026 13:17:53 +0200
Subject: [PATCH] Fix local host records being overridden by upstream NXDOMAIN
When a locally-configured hostname (from /etc/hosts, DHCP, or
host-record) is queried for a record type with no local answer
(e.g. AAAA when only A exists), the query is forwarded upstream.
If the domain only exists locally, upstream returns NXDOMAIN,
which incorrectly overrides the local host record's existence.
Fix in two places:
1. answer_request(): When no answer is found for the requested
type but the domain has local host/DHCP/config records, return
NODATA instead of forwarding. The domain exists locally, just
not for the requested type.
2. process_reply(): Move the NXDOMAIN-to-NODATA conversion for
locally-known domains before the DNSSEC bogus-answer gate.
Local host records are authoritative for domain existence
regardless of upstream DNSSEC validation status.
---
src/forward.c | 24 +++++++++++++-----------
src/rfc1035.c | 21 +++++++++++++++++++++
2 files changed, 34 insertions(+), 11 deletions(-)
diff --git a/src/forward.c b/src/forward.c
index 1a7c586..501d8e4 100644
--- a/src/forward.c
+++ b/src/forward.c
@@ -788,21 +788,23 @@ static size_t process_reply(struct dns_header *header, time_t now, struct server
return resize_packet(header, n, pheader, plen);
}
+ /* Convert NXDOMAIN to NODATA for locally-known domains. This must happen
+ unconditionally, even for DNSSEC BOGUS answers, since local host records
+ are authoritative for domain existence. */
+ if (!(header->hb3 & HB3_TC) && rcode == NXDOMAIN &&
+ extract_name(header, n, NULL, daemon->namebuff, EXTR_NAME_EXTRACT, 0) &&
+ (check_for_local_domain(daemon->namebuff, now) || lookup_domain(daemon->namebuff, F_CONFIG, NULL, NULL)))
+ {
+ header->hb3 |= HB3_AA;
+ SET_RCODE(header, NOERROR);
+ rcode = NOERROR;
+ cache_secure = 0;
+ }
+
if (header->hb3 & HB3_TC)
log_query(F_UPSTREAM, NULL, NULL, "truncated", 0);
else if (!bogusanswer || (header->hb4 & HB4_CD))
{
- if (rcode == NXDOMAIN && extract_name(header, n, NULL, daemon->namebuff, EXTR_NAME_EXTRACT, 0) &&
- (check_for_local_domain(daemon->namebuff, now) || lookup_domain(daemon->namebuff, F_CONFIG, NULL, NULL)))
- {
- /* if we forwarded a query for a locally known name (because it was for
- an unknown type) and the answer is NXDOMAIN, convert that to NODATA,
- since we know that the domain exists, even if upstream doesn't */
- header->hb3 |= HB3_AA;
- SET_RCODE(header, NOERROR);
- cache_secure = 0;
- }
-
if (daemon->doctors && do_doctor(header, n, daemon->namebuff))
cache_secure = 0;
diff --git a/src/rfc1035.c b/src/rfc1035.c
index 0e31b83..4714d3f 100644
--- a/src/rfc1035.c
+++ b/src/rfc1035.c
@@ -2302,6 +2302,27 @@ size_t answer_request(struct dns_header *header, char *limit, size_t qlen,
}
}
+ /* If we couldn't answer (e.g. AAAA query with only A host record, or an
+ unsupported RR type) but the domain has local records from /etc/hosts
+ or DHCP, return NODATA instead of forwarding upstream. This prevents
+ upstream NXDOMAIN responses from overriding local domain existence. */
+ if (!ans)
+ {
+ struct crec *local;
+
+ for (local = cache_find_by_name(NULL, name, now, F_IPV4 | F_IPV6 | F_CNAME);
+ local;
+ local = cache_find_by_name(local, name, now, F_IPV4 | F_IPV6 | F_CNAME))
+ if (local->flags & (F_HOSTS | F_DHCP | F_CONFIG))
+ {
+ ans = 1;
+ sec_data = 0;
+ auth = 0;
+ log_query(F_NEG | F_CONFIG | (qtype == T_A ? F_IPV4 : F_IPV6), name, NULL, NULL, 0);
+ break;
+ }
+ }
+
if (!ans)
return 0; /* failed to answer a question */
--
2.43.0
_______________________________________________
Dnsmasq-discuss mailing list
[email protected]
https://lists.thekelleys.org.uk/cgi-bin/mailman/listinfo/dnsmasq-discuss