Version in base suite: 5.2.11-0+deb13u1 Base version: pdns-recursor_5.2.11-0+deb13u1 Target version: pdns-recursor_5.2.12-0+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/p/pdns-recursor/pdns-recursor_5.2.11-0+deb13u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/p/pdns-recursor/pdns-recursor_5.2.12-0+deb13u1.dsc configure | 20 ++-- configure.ac | 2 debian/changelog | 7 + dnssecinfra.cc | 6 + effective_tld_names.dat | 47 +++++++-- pdns_recursor.1 | 2 pubsuffix.cc | 23 ++++ rec_control.1 | 2 syncres.cc | 100 ++++++++++++++------ test-syncres_cc3.cc | 11 +- test-syncres_cc5.cc | 229 ++++++++++++++++++++++++++++++++++++++++++++++++ validate.cc | 28 +++++ 12 files changed, 409 insertions(+), 68 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpi5nb8aln/pdns-recursor_5.2.11-0+deb13u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpi5nb8aln/pdns-recursor_5.2.12-0+deb13u1.dsc: no acceptable signature found diff -Nru pdns-recursor-5.2.11/configure pdns-recursor-5.2.12/configure --- pdns-recursor-5.2.11/configure 2026-06-09 07:36:44.000000000 +0000 +++ pdns-recursor-5.2.12/configure 2026-07-07 09:30:24.000000000 +0000 @@ -1,6 +1,6 @@ #! /bin/sh # Guess values for system-dependent variables and create Makefiles. -# Generated by GNU Autoconf 2.71 for pdns-recursor 5.2.11. +# Generated by GNU Autoconf 2.71 for pdns-recursor 5.2.12. # # # Copyright (C) 1992-1996, 1998-2017, 2020-2021 Free Software Foundation, @@ -618,8 +618,8 @@ # Identity of this package. PACKAGE_NAME='pdns-recursor' PACKAGE_TARNAME='pdns-recursor' -PACKAGE_VERSION='5.2.11' -PACKAGE_STRING='pdns-recursor 5.2.11' +PACKAGE_VERSION='5.2.12' +PACKAGE_STRING='pdns-recursor 5.2.12' PACKAGE_BUGREPORT='' PACKAGE_URL='' @@ -1588,7 +1588,7 @@ # Omit some internal or obsolete options to make the list less imposing. # This message is too long to be a string in the A/UX 3.1 sh. cat <<_ACEOF -\`configure' configures pdns-recursor 5.2.11 to adapt to many kinds of systems. +\`configure' configures pdns-recursor 5.2.12 to adapt to many kinds of systems. Usage: $0 [OPTION]... [VAR=VALUE]... @@ -1659,7 +1659,7 @@ if test -n "$ac_init_help"; then case $ac_init_help in - short | recursive ) echo "Configuration of pdns-recursor 5.2.11:";; + short | recursive ) echo "Configuration of pdns-recursor 5.2.12:";; esac cat <<\_ACEOF @@ -1859,7 +1859,7 @@ test -n "$ac_init_help" && exit $ac_status if $ac_init_version; then cat <<\_ACEOF -pdns-recursor configure 5.2.11 +pdns-recursor configure 5.2.12 generated by GNU Autoconf 2.71 Copyright (C) 2021 Free Software Foundation, Inc. @@ -2348,7 +2348,7 @@ This file contains any messages produced by compilers while running configure, to aid debugging if configure makes a mistake. -It was created by pdns-recursor $as_me 5.2.11, which was +It was created by pdns-recursor $as_me 5.2.12, which was generated by GNU Autoconf 2.71. Invocation command line was $ $0$ac_configure_args_raw @@ -3844,7 +3844,7 @@ # Define the identity of the package. PACKAGE='pdns-recursor' - VERSION='5.2.11' + VERSION='5.2.12' printf "%s\n" "#define PACKAGE \"$PACKAGE\"" >>confdefs.h @@ -31013,7 +31013,7 @@ # report actual input values of CONFIG_FILES etc. instead of their # values after options handling. ac_log=" -This file was extended by pdns-recursor $as_me 5.2.11, which was +This file was extended by pdns-recursor $as_me 5.2.12, which was generated by GNU Autoconf 2.71. Invocation command line was CONFIG_FILES = $CONFIG_FILES @@ -31081,7 +31081,7 @@ cat >>$CONFIG_STATUS <<_ACEOF || ac_write_fail=1 ac_cs_config='$ac_cs_config_escaped' ac_cs_version="\\ -pdns-recursor config.status 5.2.11 +pdns-recursor config.status 5.2.12 configured by $0, generated by GNU Autoconf 2.71, with options \\"\$ac_cs_config\\" diff -Nru pdns-recursor-5.2.11/configure.ac pdns-recursor-5.2.12/configure.ac --- pdns-recursor-5.2.11/configure.ac 2026-06-09 07:36:36.000000000 +0000 +++ pdns-recursor-5.2.12/configure.ac 2026-07-07 09:30:15.000000000 +0000 @@ -1,6 +1,6 @@ AC_PREREQ([2.69]) -AC_INIT([pdns-recursor], [5.2.11]) +AC_INIT([pdns-recursor], [5.2.12]) AC_CONFIG_AUX_DIR([build-aux]) AM_INIT_AUTOMAKE([foreign dist-bzip2 no-dist-gzip tar-ustar -Wno-portability subdir-objects parallel-tests 1.11]) AM_SILENT_RULES([yes]) diff -Nru pdns-recursor-5.2.11/debian/changelog pdns-recursor-5.2.12/debian/changelog --- pdns-recursor-5.2.11/debian/changelog 2026-06-16 22:19:21.000000000 +0000 +++ pdns-recursor-5.2.12/debian/changelog 2026-07-21 13:41:43.000000000 +0000 @@ -1,3 +1,10 @@ +pdns-recursor (5.2.12-0+deb13u1) trixie-security; urgency=medium + + * New upstream version 5.2.12, fixing security issues + CVE-2026-52686, CVE-2026-52688. + + -- Chris Hofstaedtler Tue, 21 Jul 2026 15:41:43 +0200 + pdns-recursor (5.2.11-0+deb13u1) trixie-security; urgency=medium * New upstream version 5.2.11, fixing security issues diff -Nru pdns-recursor-5.2.11/dnssecinfra.cc pdns-recursor-5.2.12/dnssecinfra.cc --- pdns-recursor-5.2.11/dnssecinfra.cc 2026-06-09 07:33:58.000000000 +0000 +++ pdns-recursor-5.2.12/dnssecinfra.cc 2026-07-07 09:28:56.000000000 +0000 @@ -525,6 +525,12 @@ unsigned int fqdn_labels = qname.countLabels(); if (rrsig_labels < fqdn_labels) { + const auto signer_labels = rrc.d_signer.countLabels(); + if (rrsig_labels < signer_labels) { + // the RRSIG labels field is a lie (the wildcard would be + // out of the signer's zone) and thus the RRSIG can never be valid + return {}; + } DNSName choppedQname(qname); while (choppedQname.countLabels() > rrsig_labels) { choppedQname.chopOff(); diff -Nru pdns-recursor-5.2.11/effective_tld_names.dat pdns-recursor-5.2.12/effective_tld_names.dat --- pdns-recursor-5.2.11/effective_tld_names.dat 2026-06-09 07:37:48.000000000 +0000 +++ pdns-recursor-5.2.12/effective_tld_names.dat 2026-07-07 09:31:25.000000000 +0000 @@ -5,8 +5,8 @@ // Please pull this list from, and only from https://publicsuffix.org/list/public_suffix_list.dat, // rather than any other VCS sites. Pulling from any other URL is not guaranteed to be supported. -// VERSION: 2026-05-28_06-25-58_UTC -// COMMIT: e596036bde712ffb073b948eb8b884c72c94c6e1 +// VERSION: 2026-07-06_09-10-11_UTC +// COMMIT: 5ae2220ad86eab1364821329ff58d28828bddb19 // Instructions on pulling and using this list can be found at https://publicsuffix.org/list/. @@ -6822,7 +6822,7 @@ // newGTLDs -// List of new gTLDs imported from https://www.icann.org/resources/registries/gtlds/v2/gtlds.json on 2026-04-30T16:18:08Z +// List of new gTLDs imported from https://www.icann.org/resources/registries/gtlds/v2/gtlds.json on 2026-06-13T16:12:40Z // This list is auto-generated, don't edit it manually. // aaa : American Automobile Association, Inc. // https://www.iana.org/domains/root/db/aaa.html @@ -10876,7 +10876,7 @@ // https://www.iana.org/domains/root/db/xn--5su34j936bgsg.html 香格里拉 -// xn--5tzm5g : Global Website TLD Asia Limited +// xn--5tzm5g : Jolly Host, LLC // https://www.iana.org/domains/root/db/xn--5tzm5g.html 网站 @@ -12976,9 +12976,22 @@ us.ci de5.net -// DNShome : https://www.dnshome.de/ +// dnsHome : https://www.dnshome.de/ // Submitted by Norbert Auler +dnshome.at +resolve.bar +ddns.berlin +dnshome.cloud +ddnssec.de dnshome.de +dyndnssec.de +heimdns.de +srvdns.de +dnshome.eu +dnshome.it +dyn.now +heimdns.online +ddns.wtf // DotArai : https://www.dotarai.com/ // Submitted by Atsadawat Netcharadsang @@ -13894,6 +13907,10 @@ zombie.jp heteml.net +// GNTC, Inc. : https://gntc.com/ +// Submitted by VibeHost Security +vibehost.space + // GoDaddy Registry : https://registry.godaddy // Submitted by Rohan Durrant graphic.design @@ -14133,10 +14150,6 @@ // Submitted by Connor McFarlane oninferno.net -// info.at : http://www.info.at/ -biz.at -info.at - // info.cx : http://info.cx // Submitted by June Slater info.cx @@ -14597,7 +14610,6 @@ // May First - People Link : https://mayfirst.org/ // Submitted by Jamie McClelland mayfirst.info -mayfirst.org // McHost : https://mchost.ru // Submitted by Evgeniy Subbotin @@ -14717,6 +14729,10 @@ typo3server.info project.space +// MKM : https://mkm.fan/ +// Submitted by Kashi Ahmer +mkm.fan + // Mocha : https://getmocha.com // Submitted by Ben Reinhart mocha.app @@ -15052,6 +15068,7 @@ // OpenAI : https://openai.com // Submitted by Thomas Shadwell *.oaiusercontent.com +chatgpt.site // OpenCraft GmbH : http://opencraft.com/ // Submitted by Sven Marnach @@ -15249,6 +15266,12 @@ // Submitted by Kor Nielsen pubtls.org +// Puter : https://puter.com +// Submitted by Puter Security Team +puter.app +puter.site +puter.work + // PythonAnywhere LLP : https://www.pythonanywhere.com // Submitted by Giles Thomas pythonanywhere.com @@ -16301,6 +16324,10 @@ wpenginepowered.com js.wpenginepowered.com +// xAI : https://x.ai/ +// Submitted by Asim Shrestha +grok.me + // XenonCloud GbR : https://xenoncloud.net // Submitted by Julian Uphoff *.xenonconnect.de diff -Nru pdns-recursor-5.2.11/pdns_recursor.1 pdns-recursor-5.2.12/pdns_recursor.1 --- pdns-recursor-5.2.11/pdns_recursor.1 2026-06-09 07:37:48.000000000 +0000 +++ pdns-recursor-5.2.12/pdns_recursor.1 2026-07-07 09:31:25.000000000 +0000 @@ -27,7 +27,7 @@ .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "PDNS_RECURSOR" "1" "Jun 09, 2026" "" "PowerDNS Recursor" +.TH "PDNS_RECURSOR" "1" "Jul 07, 2026" "" "PowerDNS Recursor" .SH NAME pdns_recursor \- The PowerDNS Recursor binary .SH SYNOPSIS diff -Nru pdns-recursor-5.2.11/pubsuffix.cc pdns-recursor-5.2.12/pubsuffix.cc --- pdns-recursor-5.2.11/pubsuffix.cc 2026-06-09 07:37:48.000000000 +0000 +++ pdns-recursor-5.2.12/pubsuffix.cc 2026-07-07 09:31:25.000000000 +0000 @@ -6263,7 +6263,20 @@ "cc.cd", "us.ci", "de5.net", +"dnshome.at", +"resolve.bar", +"ddns.berlin", +"dnshome.cloud", +"ddnssec.de", "dnshome.de", +"dyndnssec.de", +"heimdns.de", +"srvdns.de", +"dnshome.eu", +"dnshome.it", +"dyn.now", +"heimdns.online", +"ddns.wtf", "online.th", "shop.th", "co.scot", @@ -6961,6 +6974,7 @@ "whitesnow.jp", "zombie.jp", "heteml.net", +"vibehost.space", "graphic.design", "goip.de", "web.app", @@ -7056,8 +7070,6 @@ "in-dsl.org", "in-vpn.org", "oninferno.net", -"biz.at", -"info.at", "info.cx", "ac.leg.br", "al.leg.br", @@ -7321,7 +7333,6 @@ "mwcloudnonprod.com", "polyspace.com", "mayfirst.info", -"mayfirst.org", "mcdir.me", "mcdir.ru", "vps.mcdir.ru", @@ -7388,6 +7399,7 @@ "mittwaldserver.info", "typo3server.info", "project.space", +"mkm.fan", "mocha.app", "mochausercontent.com", "mocha-sandbox.dev", @@ -7603,6 +7615,7 @@ "localplayer.dev", "is-local.org", "opensocial.site", +"chatgpt.site", "opencraft.hosting", "16-b.it", "32-b.it", @@ -7661,6 +7674,9 @@ "byen.site", "nyc.mn", "pubtls.org", +"puter.app", +"puter.site", +"puter.work", "pythonanywhere.com", "eu.pythonanywhere.com", "qa2.com", @@ -8152,6 +8168,7 @@ "weeklylottery.org.uk", "wpenginepowered.com", "js.wpenginepowered.com", +"grok.me", "half.host", "xnbay.com", "u2.xnbay.com", diff -Nru pdns-recursor-5.2.11/rec_control.1 pdns-recursor-5.2.12/rec_control.1 --- pdns-recursor-5.2.11/rec_control.1 2026-06-09 07:37:48.000000000 +0000 +++ pdns-recursor-5.2.12/rec_control.1 2026-07-07 09:31:25.000000000 +0000 @@ -27,7 +27,7 @@ .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. -.TH "REC_CONTROL" "1" "Jun 09, 2026" "" "PowerDNS Recursor" +.TH "REC_CONTROL" "1" "Jul 07, 2026" "" "PowerDNS Recursor" .SH NAME rec_control \- Command line tool to control a running Recursor .SH SYNOPSIS diff -Nru pdns-recursor-5.2.11/syncres.cc pdns-recursor-5.2.12/syncres.cc --- pdns-recursor-5.2.11/syncres.cc 2026-06-09 07:33:58.000000000 +0000 +++ pdns-recursor-5.2.12/syncres.cc 2026-07-07 09:28:56.000000000 +0000 @@ -2785,8 +2785,14 @@ { vector records; vector> signatures; + // for wildcard records, we need the real owner name for the aggressive cache + DNSName realOwner; time_t d_ttl_time{0}; uint32_t signaturesTTL{std::numeric_limits::max()}; + vState validationState{vState::Indeterminate}; + bool inserted{false}; + bool isAuth{false}; + bool expectSignature{false}; }; struct CacheKey { @@ -4761,7 +4767,8 @@ } } - bool seenBogusRRSet = false; + std::optional seenBogusRRSet{std::nullopt}; + for (auto tCacheEntry = tcache.begin(); tCacheEntry != tcache.end(); ++tCacheEntry) { if (tCacheEntry->second.records.empty()) { // this happens when we did store signatures, but passed on the records themselves @@ -4780,34 +4787,36 @@ dropping the RRSIG RRs. If this happens, the name server MUST NOT set the TC bit solely because these RRSIG RRs didn't fit." */ - bool isAA = lwr.d_aabit && tCacheEntry->first.place != DNSResourceRecord::ADDITIONAL; + tCacheEntry->second.isAuth = lwr.d_aabit && tCacheEntry->first.place != DNSResourceRecord::ADDITIONAL; /* if we forwarded the query to a recursor, we can expect the answer to be signed, even if the answer is not AA. Of course that's not only true inside a Secure zone, but we check that below. */ - bool expectSignature = tCacheEntry->first.place == DNSResourceRecord::ANSWER || ((lwr.d_aabit || wasForwardRecurse) && tCacheEntry->first.place != DNSResourceRecord::ADDITIONAL); + tCacheEntry->second.expectSignature = tCacheEntry->first.place == DNSResourceRecord::ANSWER || ((lwr.d_aabit || wasForwardRecurse) && tCacheEntry->first.place != DNSResourceRecord::ADDITIONAL); /* in a non authoritative answer, we only care about the DS record (or lack of) */ - if (!isAA && (tCacheEntry->first.type == QType::DS || tCacheEntry->first.type == QType::NSEC || tCacheEntry->first.type == QType::NSEC3) && tCacheEntry->first.place == DNSResourceRecord::AUTHORITY) { - expectSignature = true; + if (!tCacheEntry->second.isAuth && (tCacheEntry->first.type == QType::DS || tCacheEntry->first.type == QType::NSEC || tCacheEntry->first.type == QType::NSEC3) && tCacheEntry->first.place == DNSResourceRecord::AUTHORITY) { + tCacheEntry->second.expectSignature = true; } - if (isCNAMEAnswer && (tCacheEntry->first.place != DNSResourceRecord::ANSWER || tCacheEntry->first.type != QType::CNAME || tCacheEntry->first.name != qname)) { - /* - rfc2181 states: - Note that the answer section of an authoritative answer normally - contains only authoritative data. However when the name sought is an - alias (see section 10.1.1) only the record describing that alias is - necessarily authoritative. Clients should assume that other records - may have come from the server's cache. Where authoritative answers - are required, the client should query again, using the canonical name - associated with the alias. - */ - isAA = false; - expectSignature = false; - } - if (isDNAMEAnswer && (tCacheEntry->first.place != DNSResourceRecord::ANSWER || tCacheEntry->first.type != QType::DNAME || !qname.isPartOf(tCacheEntry->first.name))) { - /* see above */ - isAA = false; - expectSignature = false; + if (tCacheEntry->first.type != QType::NSEC && tCacheEntry->first.type != QType::NSEC3) { + if (isCNAMEAnswer && (tCacheEntry->first.place != DNSResourceRecord::ANSWER || tCacheEntry->first.type != QType::CNAME || tCacheEntry->first.name != qname)) { + /* + rfc2181 states: + Note that the answer section of an authoritative answer normally + contains only authoritative data. However when the name sought is an + alias (see section 10.1.1) only the record describing that alias is + necessarily authoritative. Clients should assume that other records + may have come from the server's cache. Where authoritative answers + are required, the client should query again, using the canonical name + associated with the alias. + */ + tCacheEntry->second.isAuth = false; + tCacheEntry->second.expectSignature = false; + } + if (isDNAMEAnswer && (tCacheEntry->first.place != DNSResourceRecord::ANSWER || tCacheEntry->first.type != QType::DNAME || !qname.isPartOf(tCacheEntry->first.name))) { + /* see above */ + tCacheEntry->second.isAuth = false; + tCacheEntry->second.expectSignature = false; + } } if ((isCNAMEAnswer || isDNAMEAnswer) && tCacheEntry->first.place == DNSResourceRecord::AUTHORITY && tCacheEntry->first.type == QType::NS && auth == tCacheEntry->first.name) { @@ -4835,12 +4844,12 @@ * don't validate the CNAME. */ if (isDNAMEAnswer && tCacheEntry->first.type == QType::CNAME) { - expectSignature = false; + tCacheEntry->second.expectSignature = false; } vState recordState = vState::Indeterminate; - if (expectSignature && shouldValidate()) { + if (tCacheEntry->second.expectSignature && shouldValidate()) { vState initialState = getValidationStatus(tCacheEntry->first.name, !tCacheEntry->second.signatures.empty(), tCacheEntry->first.type == QType::DS, depth, prefix); LOG(prefix << qname << ": Got initial zone status " << initialState << " for record " << tCacheEntry->first.name << "|" << DNSRecordContent::NumberToType(tCacheEntry->first.type) << endl); @@ -4860,13 +4869,14 @@ } LOG(prefix << qname << ": Validation result is " << recordState << ", current state is " << state << endl); + tCacheEntry->second.validationState = recordState; if (state != recordState) { updateValidationState(qname, state, recordState, prefix); } } if (vStateIsBogus(recordState)) { - seenBogusRRSet = true; + seenBogusRRSet = recordState; /* this is a TTD by now, be careful */ for (auto& record : tCacheEntry->second.records) { auto newval = std::min(record.d_ttl, static_cast(s_maxbogusttl + d_now.tv_sec)); @@ -4884,10 +4894,10 @@ - DS (special case) - NS, A and AAAA (used for infra queries) */ - if (tCacheEntry->first.type != QType::NSEC3 && (tCacheEntry->first.type == QType::DS || tCacheEntry->first.type == QType::NS || tCacheEntry->first.type == QType::A || tCacheEntry->first.type == QType::AAAA || isAA || wasForwardRecurse)) { + if (tCacheEntry->first.type != QType::NSEC3 && (tCacheEntry->first.type == QType::DS || tCacheEntry->first.type == QType::NS || tCacheEntry->first.type == QType::A || tCacheEntry->first.type == QType::AAAA || tCacheEntry->second.isAuth || wasForwardRecurse)) { bool doCache = true; - if (!isAA && seenBogusRRSet) { + if (!tCacheEntry->second.isAuth && seenBogusRRSet) { LOG(prefix << qname << ": Not caching non-authoritative rrsets received with Bogus answer" << endl); doCache = false; } @@ -4920,7 +4930,7 @@ if (doCache) { // Check if we are going to replace a non-auth (parent) NS recordset - if (isAA && tCacheEntry->first.type == QType::NS && s_save_parent_ns_set) { + if (tCacheEntry->second.isAuth && tCacheEntry->first.type == QType::NS && s_save_parent_ns_set) { rememberParentSetIfNeeded(tCacheEntry->first.name, tCacheEntry->second.records, depth, prefix); } bool thisRRNeedsWildcardProof = false; @@ -4929,7 +4939,12 @@ thisRRNeedsWildcardProof = true; } } - g_recCache->replace(d_now.tv_sec, tCacheEntry->first.name, tCacheEntry->first.type, tCacheEntry->second.records, tCacheEntry->second.signatures, thisRRNeedsWildcardProof ? authorityRecs : std::vector>(), tCacheEntry->first.type == QType::DS ? true : isAA, auth, tCacheEntry->first.place == DNSResourceRecord::ANSWER ? ednsmask : boost::none, d_routingTag, recordState, remoteIP, d_refresh, tCacheEntry->second.d_ttl_time); + + if (tCacheEntry->first.type == QType::DS) { + tCacheEntry->second.isAuth = true; + } + g_recCache->replace(d_now.tv_sec, tCacheEntry->first.name, tCacheEntry->first.type, tCacheEntry->second.records, tCacheEntry->second.signatures, thisRRNeedsWildcardProof ? authorityRecs : std::vector>(), tCacheEntry->second.isAuth, auth, tCacheEntry->first.place == DNSResourceRecord::ANSWER ? ednsmask : boost::none, d_routingTag, recordState, remoteIP, d_refresh, tCacheEntry->second.d_ttl_time); + tCacheEntry->second.inserted = true; // Delete potential negcache entry. When a record recovers with serve-stale the negcache entry can cause the wrong entry to // be served, as negcache entries are checked before record cache entries @@ -4944,17 +4959,17 @@ const auto labelCount = tCacheEntry->first.name.countLabels(); if (isWildcardExpanded(labelCount, *rrsig) && !isWildcardExpandedOntoItself(tCacheEntry->first.name, labelCount, *rrsig)) { - DNSName realOwner = getNSECOwnerName(tCacheEntry->first.name, tCacheEntry->second.signatures); + tCacheEntry->second.realOwner = getNSECOwnerName(tCacheEntry->first.name, tCacheEntry->second.signatures); std::vector content; content.reserve(tCacheEntry->second.records.size()); for (const auto& record : tCacheEntry->second.records) { DNSRecord nonExpandedRecord(record); - nonExpandedRecord.d_name = realOwner; + nonExpandedRecord.d_name = tCacheEntry->second.realOwner; content.push_back(std::move(nonExpandedRecord)); } - g_recCache->replace(d_now.tv_sec, realOwner, QType(tCacheEntry->first.type), content, tCacheEntry->second.signatures, /* no additional records in that case */ {}, tCacheEntry->first.type == QType::DS ? true : isAA, auth, boost::none, boost::none, recordState, remoteIP, d_refresh, tCacheEntry->second.d_ttl_time); + g_recCache->replace(d_now.tv_sec, tCacheEntry->second.realOwner, QType(tCacheEntry->first.type), content, tCacheEntry->second.signatures, /* no additional records in that case */ {}, tCacheEntry->second.isAuth, auth, boost::none, boost::none, recordState, remoteIP, d_refresh, tCacheEntry->second.d_ttl_time); } } } @@ -4974,6 +4989,25 @@ } } + if (seenBogusRRSet) { + /* We might have inserted RRSets with a Secure validation status then + later encountered a validation error, so let's go back and update + the status and TTD of previous records. + This is not ideal but the correct solution would be to move the validation + (including denial of existence) BEFORE dealing with the cache. + */ + for (const auto& entry : tcache) { + if (!entry.second.inserted || !entry.second.expectSignature || vStateIsBogus(entry.second.validationState)) { + continue; + } + + g_recCache->updateValidationStatus(d_now.tv_sec, entry.first.name, entry.first.type, d_cacheRemote, d_routingTag, entry.second.isAuth, *seenBogusRRSet, s_maxbogusttl + d_now.tv_sec); + if (!entry.second.realOwner.empty()) { + g_recCache->updateValidationStatus(d_now.tv_sec, entry.second.realOwner, entry.first.type, d_cacheRemote, boost::none, entry.second.isAuth, *seenBogusRRSet, s_maxbogusttl + d_now.tv_sec); + } + } + } + if (gatherWildcardProof) { if (auto wcIt = wildcardCandidates.find(qname); wcIt != wildcardCandidates.end() && !wcIt->second) { // the queried name was not expanded from a wildcard, a record in the CNAME chain was, so we don't need to gather wildcard proof now: we will do that when looking up the CNAME chain diff -Nru pdns-recursor-5.2.11/test-syncres_cc3.cc pdns-recursor-5.2.12/test-syncres_cc3.cc --- pdns-recursor-5.2.11/test-syncres_cc3.cc 2026-06-09 07:33:58.000000000 +0000 +++ pdns-recursor-5.2.12/test-syncres_cc3.cc 2026-07-07 09:28:56.000000000 +0000 @@ -1284,13 +1284,12 @@ /* unsigned */ const DNSName target("test."); /* signed */ - const DNSName cnameTarget("cname."); + const DNSName cnameTarget("cname.zone."); testkeysset_t keys; auto luaconfsCopy = g_luaconfs.getCopy(); luaconfsCopy.dsAnchors.clear(); generateKeyMaterial(g_rootdnsname, DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys, luaconfsCopy.dsAnchors); - generateKeyMaterial(cnameTarget, DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys); g_luaconfs.setState(luaconfsCopy); const ComboAddress forwardedNS("192.0.2.42:53"); @@ -1320,10 +1319,10 @@ addRecordToLW(res, target, QType::CNAME, cnameTarget.toString()); addRecordToLW(res, cnameTarget, QType::A, "192.0.2.1"); /* the RRSIG proves that the cnameTarget was expanded from a wildcard */ - addRRSIG(keys, res->d_records, cnameTarget, 300, false, boost::none, DNSName("*")); + addRRSIG(keys, res->d_records, g_rootdnsname, 300, false, boost::none, DNSName("*")); /* we need to add the proof that this name does not exist, so the wildcard may apply */ addNSECRecordToLW(DNSName("cnamd."), DNSName("cnamf."), {QType::A, QType::NSEC, QType::RRSIG}, 60, res->d_records); - addRRSIG(keys, res->d_records, cnameTarget, 300); + addRRSIG(keys, res->d_records, g_rootdnsname, 300); return LWResult::Result::Success; } @@ -1335,7 +1334,7 @@ BOOST_CHECK_EQUAL(res, RCode::NoError); BOOST_CHECK_EQUAL(testSR->getValidationState(), vState::Insecure); BOOST_REQUIRE_EQUAL(ret.size(), 5U); - BOOST_CHECK_EQUAL(queriesCount, 5U); + BOOST_CHECK_EQUAL(queriesCount, 3U); /* again, to test the cache */ ret.clear(); @@ -1343,7 +1342,7 @@ BOOST_CHECK_EQUAL(res, RCode::NoError); BOOST_CHECK_EQUAL(testSR->getValidationState(), vState::Insecure); BOOST_REQUIRE_EQUAL(ret.size(), 5U); - BOOST_CHECK_EQUAL(queriesCount, 5U); + BOOST_CHECK_EQUAL(queriesCount, 3U); } BOOST_AUTO_TEST_CASE(test_auth_zone_oob) diff -Nru pdns-recursor-5.2.11/test-syncres_cc5.cc pdns-recursor-5.2.12/test-syncres_cc5.cc --- pdns-recursor-5.2.11/test-syncres_cc5.cc 2026-06-09 07:33:58.000000000 +0000 +++ pdns-recursor-5.2.12/test-syncres_cc5.cc 2026-07-07 09:28:56.000000000 +0000 @@ -5,6 +5,7 @@ #include #include "test-syncres_cc.hh" +#include "aggressive_nsec.hh" BOOST_AUTO_TEST_SUITE(syncres_cc5) @@ -1180,6 +1181,122 @@ BOOST_CHECK_EQUAL(queriesCount, 7U); } +BOOST_AUTO_TEST_CASE(test_dnssec_validation_nsec_wildcard_proof_cname_missing_rrsig) +{ + /* this tests makes sure that we correctly detect that we need to gather + wildcard proof (since the answer is expanded from a wildcard, we need + to prove that the target name does not exist) even though the answer is + a CNAME AND validate the NSEC / NSEC3! + */ + std::unique_ptr resolver; + initSR(resolver, true); + + setDNSSECValidation(resolver, DNSSECMode::ValidateAll); + + primeHints(); + const DNSName target("www.powerdns.com."); + const DNSName alias("alias.powerdns.com."); + testkeysset_t keys; + + auto luaconfsCopy = g_luaconfs.getCopy(); + luaconfsCopy.dsAnchors.clear(); + generateKeyMaterial(g_rootdnsname, DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys, luaconfsCopy.dsAnchors); + generateKeyMaterial(DNSName("com."), DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys); + generateKeyMaterial(DNSName("powerdns.com."), DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys); + + g_luaconfs.setState(luaconfsCopy); + + size_t queriesCount = 0; + + resolver->setAsyncCallback([&](const ComboAddress& address, const DNSName& domain, int type, bool /* doTCP */, bool /* sendRDQuery */, int /* EDNS0Level */, struct timeval* /* now */, boost::optional& /* srcmask */, const ResolveContext& /* context */, LWResult* res, bool* /* chained */) { + queriesCount++; + + if (type == QType::DS || type == QType::DNSKEY) { + if (type == QType::DS && domain == target) { + setLWResult(res, RCode::NoError, true, false, true); + addRecordToLW(res, DNSName("powerdns.com."), QType::SOA, "pdns-public-ns1.powerdns.com. pieter\\.lexis.powerdns.com. 2017032301 10800 3600 604800 3600", DNSResourceRecord::AUTHORITY, 3600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com."), 300); + addNSECRecordToLW(DNSName("www.powerdns.com."), DNSName("wwz.powerdns.com."), {QType::A, QType::NSEC, QType::RRSIG}, 600, res->d_records); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + return LWResult::Result::Success; + } + return genericDSAndDNSKEYHandler(res, domain, domain, type, keys, domain == g_rootdnsname || domain == DNSName("com.")); + } + if (isRootServer(address)) { + setLWResult(res, 0, false, false, true); + addRecordToLW(res, "com.", QType::NS, "a.gtld-servers.com.", DNSResourceRecord::AUTHORITY, 3600); + addDS(DNSName("com."), 300, res->d_records, keys); + addRRSIG(keys, res->d_records, DNSName("."), 300); + addRecordToLW(res, "a.gtld-servers.com.", QType::A, "192.0.2.1", DNSResourceRecord::ADDITIONAL, 3600); + return LWResult::Result::Success; + } + if (address == ComboAddress("192.0.2.1:53")) { + if (domain == DNSName("com.")) { + setLWResult(res, 0, true, false, true); + addRecordToLW(res, domain, QType::NS, "a.gtld-servers.com."); + addRRSIG(keys, res->d_records, domain, 300); + addRecordToLW(res, "a.gtld-servers.com.", QType::A, "192.0.2.1", DNSResourceRecord::ADDITIONAL, 3600); + addRRSIG(keys, res->d_records, domain, 300); + } + else { + setLWResult(res, 0, false, false, true); + addRecordToLW(res, "powerdns.com.", QType::NS, "ns1.powerdns.com.", DNSResourceRecord::AUTHORITY, 3600); + addDS(DNSName("powerdns.com."), 300, res->d_records, keys); + addRRSIG(keys, res->d_records, DNSName("com."), 300); + addRecordToLW(res, "ns1.powerdns.com.", QType::A, "192.0.2.2", DNSResourceRecord::ADDITIONAL, 3600); + } + return LWResult::Result::Success; + } + if (address == ComboAddress("192.0.2.2:53")) { + setLWResult(res, 0, true, false, true); + if (type == QType::NS) { + if (domain == DNSName("powerdns.com.")) { + addRecordToLW(res, domain, QType::NS, "ns1.powerdns.com."); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + addRecordToLW(res, "ns1.powerdns.com.", QType::A, "192.0.2.2", DNSResourceRecord::ADDITIONAL, 3600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + } + else { + addRecordToLW(res, domain, QType::SOA, "pdns-public-ns1.powerdns.com. pieter\\.lexis.powerdns.com. 2017032301 10800 3600 604800 3600", DNSResourceRecord::AUTHORITY, 3600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + addNSECRecordToLW(DNSName("www.powerdns.com."), DNSName("wwz.powerdns.com."), {QType::A, QType::NSEC, QType::RRSIG}, 600, res->d_records); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + } + } + else if (domain == target) { + addRecordToLW(res, domain, QType::CNAME, "alias.powerdns.com", DNSResourceRecord::ANSWER, 600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300, false, boost::none, DNSName("*.powerdns.com")); + /* proof that this name does not exist, so the wildcard may apply */ + addNSECRecordToLW(DNSName("wwv.powerdns.com."), DNSName("wwx.powerdns.com."), {QType::A, QType::NSEC, QType::RRSIG}, 600, res->d_records); + /* INVALID RRSIG!! */ + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300, true); + } + else if (domain == alias) { + addRecordToLW(res, alias, QType::A, "192.0.2.1", DNSResourceRecord::ANSWER, 600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + } + return LWResult::Result::Success; + } + + return LWResult::Result::Timeout; + }); + + vector ret; + int res = resolver->beginResolve(target, QType(QType::A), QClass::IN, ret); + BOOST_CHECK_EQUAL(res, RCode::NoError); + BOOST_CHECK_EQUAL(resolver->getValidationState(), vState::BogusNoValidRRSIG); + BOOST_REQUIRE_EQUAL(ret.size(), 6U); + BOOST_CHECK_EQUAL(queriesCount, 8U); + + /* again, to test the cache */ + ret.clear(); + res = resolver->beginResolve(target, QType(QType::A), QClass::IN, ret); + BOOST_CHECK_EQUAL(res, RCode::NoError); + BOOST_CHECK_EQUAL(resolver->getValidationState(), vState::BogusNoValidRRSIG); + BOOST_REQUIRE_EQUAL(ret.size(), 6U); + BOOST_CHECK_EQUAL(queriesCount, 8U); +} + BOOST_AUTO_TEST_CASE(test_dnssec_validation_nsec_nodata_nowildcard) { std::unique_ptr sr; @@ -2321,6 +2438,118 @@ BOOST_REQUIRE_EQUAL(ret.size(), 4U); } +BOOST_AUTO_TEST_CASE(test_dnssec_validation_out_of_zone_wildcard) +{ + std::unique_ptr sr; + initSR(sr, true); + + setDNSSECValidation(sr, DNSSECMode::ValidateAll); + + primeHints(); + const DNSName target("www.powerdns.com."); + testkeysset_t keys; + + auto luaconfsCopy = g_luaconfs.getCopy(); + luaconfsCopy.dsAnchors.clear(); + generateKeyMaterial(g_rootdnsname, DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys, luaconfsCopy.dsAnchors); + generateKeyMaterial(DNSName("com."), DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys); + generateKeyMaterial(DNSName("powerdns.com."), DNSSECKeeper::ECDSA256, DNSSECKeeper::DIGEST_SHA256, keys); + + g_luaconfs.setState(luaconfsCopy); + + size_t queriesCount = 0; + + sr->setAsyncCallback([&](const ComboAddress& address, const DNSName& domain, int type, bool /* doTCP */, bool /* sendRDQuery */, int /* EDNS0Level */, struct timeval* /* now */, boost::optional& /* srcmask */, const ResolveContext& /* context */, LWResult* res, bool* /* chained */) { + queriesCount++; + + if (type == QType::DS || type == QType::DNSKEY) { + if (type == QType::DS && domain == target) { + setLWResult(res, RCode::NoError, true, false, true); + addRecordToLW(res, DNSName("powerdns.com."), QType::SOA, "pdns-public-ns1.powerdns.com. pieter\\.lexis.powerdns.com. 2017032301 10800 3600 604800 3600", DNSResourceRecord::AUTHORITY, 3600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com."), 300); + addNSECRecordToLW(DNSName("www.powerdns.com."), DNSName("wwz.powerdns.com."), {QType::A, QType::NSEC, QType::RRSIG}, 600, res->d_records); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + return LWResult::Result::Success; + } + return genericDSAndDNSKEYHandler(res, domain, domain, type, keys); + } + { + if (isRootServer(address)) { + setLWResult(res, 0, false, false, true); + addRecordToLW(res, "com.", QType::NS, "a.gtld-servers.com.", DNSResourceRecord::AUTHORITY, 3600); + addDS(DNSName("com."), 300, res->d_records, keys); + addRRSIG(keys, res->d_records, DNSName("."), 300); + addRecordToLW(res, "a.gtld-servers.com.", QType::A, "192.0.2.1", DNSResourceRecord::ADDITIONAL, 3600); + return LWResult::Result::Success; + } + if (address == ComboAddress("192.0.2.1:53")) { + if (domain == DNSName("com.")) { + setLWResult(res, 0, true, false, true); + addRecordToLW(res, domain, QType::NS, "a.gtld-servers.com."); + addRRSIG(keys, res->d_records, domain, 300); + addRecordToLW(res, "a.gtld-servers.com.", QType::A, "192.0.2.1", DNSResourceRecord::ADDITIONAL, 3600); + addRRSIG(keys, res->d_records, domain, 300); + } + else { + setLWResult(res, 0, false, false, true); + addRecordToLW(res, "powerdns.com.", QType::NS, "ns1.powerdns.com.", DNSResourceRecord::AUTHORITY, 3600); + addDS(DNSName("powerdns.com."), 300, res->d_records, keys); + addRRSIG(keys, res->d_records, DNSName("com."), 300); + addRecordToLW(res, "ns1.powerdns.com.", QType::A, "192.0.2.2", DNSResourceRecord::ADDITIONAL, 3600); + } + return LWResult::Result::Success; + } + if (address == ComboAddress("192.0.2.2:53")) { + setLWResult(res, 0, true, false, true); + if (type == QType::NS) { + if (domain == DNSName("powerdns.com.")) { + addRecordToLW(res, domain, QType::NS, "ns1.powerdns.com."); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + addRecordToLW(res, "ns1.powerdns.com.", QType::A, "192.0.2.2", DNSResourceRecord::ADDITIONAL, 3600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + } + else { + addRecordToLW(res, domain, QType::SOA, "pdns-public-ns1.powerdns.com. pieter\\.lexis.powerdns.com. 2017032301 10800 3600 604800 3600", DNSResourceRecord::AUTHORITY, 3600); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + addNSECRecordToLW(DNSName("www.powerdns.com."), DNSName("wwz.powerdns.com."), {QType::A, QType::NSEC, QType::RRSIG}, 600, res->d_records); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + } + } + else { + addRecordToLW(res, domain, QType::A, "192.0.2.42", DNSResourceRecord::ANSWER, 600); + /* this pretends that the wildcard lives out of the powerdns.com zone, even though it will be signed by the powerdns.com key */ + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300, false, boost::none, DNSName("*.com")); + /* we need to add the proof that this name does not exist, so the wildcard may apply */ + addNSECRecordToLW(DNSName("wwa.powerdns.com."), DNSName("wwz.powerdns.com."), {QType::A, QType::NSEC, QType::RRSIG}, 60, res->d_records); + addRRSIG(keys, res->d_records, DNSName("powerdns.com"), 300); + } + return LWResult::Result::Success; + } + } + + return LWResult::Result::Timeout; + }); + + g_recCache->doWipeCache(DNSName("wwa.powerdns.com."), false); + /* we enable the aggressive NSEC cache to check if the computed wildcard is then inserted + into the record cache */ + g_aggressiveNSECCache = make_unique(10000); + + vector ret; + int res = sr->beginResolve(target, QType(QType::A), QClass::IN, ret); + BOOST_CHECK_EQUAL(res, RCode::NoError); + BOOST_CHECK_EQUAL(sr->getValidationState(), vState::BogusNoValidRRSIG); + BOOST_REQUIRE_EQUAL(ret.size(), 2U); + BOOST_CHECK_EQUAL(queriesCount, 7U); + + /* make sure that the bogus wildcard entry has not been cached! */ + const time_t now = sr->getNow().tv_sec; + const ComboAddress who; + vector cached; + BOOST_CHECK_EQUAL(g_recCache->get(now, DNSName("*.com."), QType(QType::A), MemRecursorCache::RequireAuth, &cached, who), -1); + g_aggressiveNSECCache.reset(); +} + // Tests PR 8648 BOOST_AUTO_TEST_CASE(test_dnssec_incomplete_cache_zonecut_qm) { diff -Nru pdns-recursor-5.2.11/validate.cc pdns-recursor-5.2.12/validate.cc --- pdns-recursor-5.2.11/validate.cc 2026-06-09 07:33:58.000000000 +0000 +++ pdns-recursor-5.2.12/validate.cc 2026-07-07 09:28:56.000000000 +0000 @@ -200,6 +200,12 @@ return false; } +static bool isRRSIGLabelCountValid(const RRSIGRecordContent& sign) +{ + const auto signerLabelCount = sign.d_signer.countLabels(); + return sign.d_labels >= signerLabelCount; +} + /* RFC 4035 section-5.3.4: "If the number of labels in an RRset's owner name is greater than the Labels field of the covering RRSIG RR, then the RRset and its @@ -207,7 +213,7 @@ */ bool isWildcardExpanded(unsigned int labelCount, const RRSIGRecordContent& sign) { - return sign.d_labels < labelCount; + return sign.d_labels < labelCount && isRRSIGLabelCountValid(sign); } static bool isWildcardExpanded(const DNSName& owner, const std::vector >& signatures) @@ -224,7 +230,7 @@ bool isWildcardExpandedOntoItself(const DNSName& owner, unsigned int labelCount, const RRSIGRecordContent& sign) { /* this is a wildcard alright, but it has not been expanded */ - return owner.isWildcard() && (labelCount - 1) == sign.d_labels; + return owner.isWildcard() && (labelCount - 1) == sign.d_labels && isRRSIGLabelCountValid(sign); } static bool isWildcardExpandedOntoItself(const DNSName& owner, const std::vector >& signatures) @@ -250,7 +256,7 @@ const auto& sign = signatures.at(0); unsigned int labelsCount = initialOwner.countLabels(); - if (sign && sign->d_labels < labelsCount) { + if (sign && sign->d_labels < labelsCount && isRRSIGLabelCountValid(*sign)) { do { result.chopOff(); labelsCount--; @@ -1020,9 +1026,15 @@ vState validateWithKeySet(time_t now, const DNSName& name, const sortedRecords_t& toSign, const vector >& signatures, const skeyset_t& keys, const OptLog& log, pdns::validation::ValidationContext& context, bool validateAllSigs) { + // whether we could not find the corresponding key in keys for at least one signature bool missingKey = false; + // whether we were able to validate at least one signature bool isValid = false; + // whether all signatures were expired (will be set to false if at least one signature is not expired) bool allExpired = true; + // whether we discarded all signatures (will be set to false if we accept, but not necessarily validate, at least one signature) + bool allDiscarded = true; + // whether all signatures were not yet incepted (will be set to false if at least one signature is found to be incepted) bool noneIncepted = true; uint16_t signaturesConsidered = 0; @@ -1032,6 +1044,13 @@ VLOG(log, name<<": Discarding invalid RRSIG whose label count is "<d_labels<<" while the RRset owner name has only "<d_labels << ", not enough to match the signer which has " << signature->d_signer.countLabels() << endl); + continue; + } + // we don't have the information in this function at the moment, but it would be nice to validate that the signer is coherent with the zone we are validating + + allDiscarded = false; vState ede = vState::Indeterminate; if (!DNSCryptoKeyEngine::isAlgorithmSupported(signature->d_algorithm)) { @@ -1106,6 +1125,9 @@ if (missingKey) { return vState::BogusNoValidRRSIG; } + if (allDiscarded) { + return vState::BogusNoValidRRSIG; + } if (noneIncepted) { // ede should be vState::BogusSignatureNotYetValid return vState::BogusSignatureNotYetValid;