Bug#1109845: unblock: pdns-recursor/5.2.4-2 [pre-approval]
Control: tags -1 moreinfo confirmed
On 2025-07-25 03:08:11 +0200, Chris Hofstädtler wrote:
> Package: release.debian.org
> Severity: normal
> X-Debbugs-Cc: pdns-recursor@packages.debian.org
> Control: affects -1 + src:pdns-recursor
> User: release.debian.org@packages.debian.org
> Usertags: unblock
>
> Please unblock package pdns-recursor
>
> [ Reason ]
> Upstream security fix for CVE-2025-30192, Debian bug #1109808
Please go ahead but keep in mind that the upload needs to happen very
soon. Remove the moreinfo tag once the upload is available in unstable.
Cheers
>
> [ Impact ]
> Upstream classified the security issue as Severity: High under
> non-default configuration
>
> [ Tests ]
> For the specific fix I don't know how to verify it, but upstream has
> spent a lot of time on it (and skipped 5.2.3 because of the effort).
>
> I've done a basic test of the general functionality. autopkgtests in
> experimental have passed on amd64, arm64, riscv64 but were still in
> progress on ppc64el, s390x.
>
> [ Risks ]
> While this is a new upstream version compared to testing, the only
> change is the security fix. I've looked at the diff in upstreams git
> and it is approximately the same size, minus some release noise.
>
> [ Checklist ]
> [x] all changes are documented in the d/changelog
> [x] I reviewed all changes and I approve them
> [x] attach debdiff against the package in testing
>
> [ Other info ]
> The debdiff is filtered to strip some noise:
> - autoconf-generated ./configure
> - effective_tld_names.dat and pubsuffix.cc are data, which the Debian
> build ignores and instead uses from the publicsufffix package.
>
> unblock pdns-recursor/5.2.4-2
> diff -Nru pdns-recursor-5.2.2/configure.ac pdns-recursor-5.2.4/configure.ac
> --- pdns-recursor-5.2.2/configure.ac 2025-04-08 12:41:43.000000000 +0200
> +++ pdns-recursor-5.2.4/configure.ac 2025-07-17 14:21:38.000000000 +0200
> @@ -1,6 +1,6 @@
> AC_PREREQ([2.69])
>
> -AC_INIT([pdns-recursor], [5.2.2])
> +AC_INIT([pdns-recursor], [5.2.4])
> 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.2/debian/changelog pdns-recursor-5.2.4/debian/changelog
> --- pdns-recursor-5.2.2/debian/changelog 2025-07-20 12:57:46.000000000 +0200
> +++ pdns-recursor-5.2.4/debian/changelog 2025-07-25 03:03:18.000000000 +0200
> @@ -1,3 +1,17 @@
> +pdns-recursor (5.2.4-2) unstable; urgency=medium
> +
> + * Upload to unstable.
> +
> + -- Chris Hofstaedtler <zeha@debian.org> Fri, 25 Jul 2025 03:03:18 +0200
> +
> +pdns-recursor (5.2.4-1) experimental; urgency=medium
> +
> + * New upstream version 5.2.4, fixing CVE-2025-30192.
> + (Closes: #1109808)
> + * Upload to experimental.
> +
> + -- Chris Hofstaedtler <zeha@debian.org> Thu, 24 Jul 2025 10:18:06 +0200
> +
> pdns-recursor (5.2.2-2) unstable; urgency=medium
>
> * Really emit (X-Cargo-|Static-)Built-Using fields (Closes: #1109579)
> diff -Nru pdns-recursor-5.2.2/dnsrecords.cc pdns-recursor-5.2.4/dnsrecords.cc
> --- pdns-recursor-5.2.2/dnsrecords.cc 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/dnsrecords.cc 2025-07-17 14:20:08.000000000 +0200
> @@ -1034,6 +1034,16 @@
> }
> }
>
> +vector<pair<uint16_t, string>>::const_iterator EDNSOpts::getFirstOption(uint16_t optionCode) const
> +{
> + for (auto iter = d_options.cbegin(); iter != d_options.cend(); ++iter) {
> + if (iter->first == optionCode) {
> + return iter;
> + }
> + }
> + return d_options.cend();
> +}
> +
> #if 0
> static struct Reporter
> {
> diff -Nru pdns-recursor-5.2.2/dnsrecords.hh pdns-recursor-5.2.4/dnsrecords.hh
> --- pdns-recursor-5.2.2/dnsrecords.hh 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/dnsrecords.hh 2025-07-17 14:20:08.000000000 +0200
> @@ -1057,6 +1057,8 @@
> uint16_t d_packetsize{0};
> uint16_t d_extFlags{0};
> uint8_t d_extRCode, d_version;
> +
> + [[nodiscard]] vector<pair<uint16_t, string>>::const_iterator getFirstOption(uint16_t optionCode) const;
> };
> //! Convenience function that fills out EDNS0 options, and returns true if there are any
>
> diff -Nru pdns-recursor-5.2.2/ednsoptions.cc pdns-recursor-5.2.4/ednsoptions.cc
> --- pdns-recursor-5.2.2/ednsoptions.cc 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/ednsoptions.cc 2025-07-17 14:20:08.000000000 +0200
> @@ -22,6 +22,7 @@
> #include "dns.hh"
> #include "ednsoptions.hh"
> #include "iputils.hh"
> +#include "dnsparser.hh"
>
> bool getNextEDNSOption(const char* data, size_t dataLen, uint16_t& optionCode, uint16_t& optionLen)
> {
> @@ -93,6 +94,61 @@
> return ENOENT;
> }
>
> +bool slowParseEDNSOptions(const PacketBuffer& packet, EDNSOptionViewMap& options)
> +{
> + if (packet.size() < sizeof(dnsheader)) {
> + return false;
> + }
> +
> + const dnsheader_aligned dnsHeader(packet.data());
> +
> + if (ntohs(dnsHeader->qdcount) == 0) {
> + return false;
> + }
> +
> + if (ntohs(dnsHeader->arcount) == 0) {
> + return false;
> + }
> +
> + try {
> + uint64_t numrecords = ntohs(dnsHeader->ancount) + ntohs(dnsHeader->nscount) + ntohs(dnsHeader->arcount);
> + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast,cppcoreguidelines-pro-type-const-cast)
> + DNSPacketMangler dpm(const_cast<char*>(reinterpret_cast<const char*>(packet.data())), packet.size());
> + uint64_t index{};
> + for (index = 0; index < ntohs(dnsHeader->qdcount); ++index) {
> + dpm.skipDomainName();
> + /* type and class */
> + dpm.skipBytes(4);
> + }
> +
> + for (index = 0; index < numrecords; ++index) {
> + dpm.skipDomainName();
> +
> + uint8_t section = index < ntohs(dnsHeader->ancount) ? 1 : (index < (ntohs(dnsHeader->ancount) + ntohs(dnsHeader->nscount)) ? 2 : 3);
> + uint16_t dnstype = dpm.get16BitInt();
> + dpm.get16BitInt();
> + dpm.skipBytes(4); /* TTL */
> +
> + if (section == 3 && dnstype == QType::OPT) {
> + uint32_t offset = dpm.getOffset();
> + if (offset >= packet.size()) {
> + return false;
> + }
> + /* if we survive this call, we can parse it safely */
> + dpm.skipRData();
> + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
> + return getEDNSOptions(reinterpret_cast<const char*>(&packet.at(offset)), packet.size() - offset, options) == 0;
> + }
> + dpm.skipRData();
> + }
> + }
> + catch (...) {
> + return false;
> + }
> +
> + return true;
> +}
> +
> /* extract all EDNS0 options from a pointer on the beginning rdLen of the OPT RR */
> int getEDNSOptions(const char* optRR, const size_t len, EDNSOptionViewMap& options)
> {
> diff -Nru pdns-recursor-5.2.2/ednsoptions.hh pdns-recursor-5.2.4/ednsoptions.hh
> --- pdns-recursor-5.2.2/ednsoptions.hh 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/ednsoptions.hh 2025-07-17 14:20:08.000000000 +0200
> @@ -22,6 +22,8 @@
> #pragma once
> #include "namespaces.hh"
>
> +#include "noinitvector.hh"
> +
> struct EDNSOptionCode
> {
> enum EDNSOptionCodeEnum {NSID=3, DAU=5, DHU=6, N3U=7, ECS=8, EXPIRE=9, COOKIE=10, TCPKEEPALIVE=11, PADDING=12, CHAIN=13, KEYTAG=14, EXTENDEDERROR=15};
> @@ -54,3 +56,4 @@
> bool getNextEDNSOption(const char* data, size_t dataLen, uint16_t& optionCode, uint16_t& optionLen);
>
> void generateEDNSOption(uint16_t optionCode, const std::string& payload, std::string& res);
> +bool slowParseEDNSOptions(const PacketBuffer& packet, EDNSOptionViewMap& options);
> diff -Nru pdns-recursor-5.2.2/iputils.hh pdns-recursor-5.2.4/iputils.hh
> --- pdns-recursor-5.2.2/iputils.hh 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/iputils.hh 2025-07-17 14:20:08.000000000 +0200
> @@ -733,6 +733,11 @@
> return std::tie(d_network, d_bits) == std::tie(rhs.d_network, rhs.d_bits);
> }
>
> + bool operator!=(const Netmask& rhs) const
> + {
> + return !(*this == rhs);
> + }
> +
> [[nodiscard]] bool empty() const
> {
> return d_network.sin4.sin_family == 0;
> diff -Nru pdns-recursor-5.2.2/lwres.cc pdns-recursor-5.2.4/lwres.cc
> --- pdns-recursor-5.2.2/lwres.cc 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/lwres.cc 2025-07-17 14:20:08.000000000 +0200
> @@ -58,6 +58,7 @@
> thread_local TCPOutConnectionManager t_tcp_manager;
> std::shared_ptr<Logr::Logger> g_slogout;
> bool g_paddingOutgoing;
> +bool g_ECSHardening;
>
> void remoteLoggerQueueData(RemoteLoggerInterface& rli, const std::string& data)
> {
> @@ -422,18 +423,13 @@
> pw.getHeader()->cd = (sendRDQuery && g_dnssecmode != DNSSECMode::Off);
>
> string ping;
> - bool weWantEDNSSubnet = false;
> - uint8_t outgoingECSBits = 0;
> - ComboAddress outgoingECSAddr;
> + std::optional<EDNSSubnetOpts> subnetOpts = std::nullopt;
> if (EDNS0Level > 0) {
> DNSPacketWriter::optvect_t opts;
> if (srcmask) {
> - EDNSSubnetOpts eo;
> - eo.source = *srcmask;
> - outgoingECSBits = srcmask->getBits();
> - outgoingECSAddr = srcmask->getNetwork();
> - opts.emplace_back(EDNSOptionCode::ECS, makeEDNSSubnetOptsString(eo));
> - weWantEDNSSubnet = true;
> + subnetOpts = EDNSSubnetOpts{};
> + subnetOpts->source = *srcmask;
> + opts.emplace_back(EDNSOptionCode::ECS, makeEDNSSubnetOptsString(*subnetOpts));
> }
>
> if (dnsOverTLS && g_paddingOutgoing) {
> @@ -478,7 +474,7 @@
> if (!doTCP) {
> int queryfd;
>
> - ret = asendto(vpacket.data(), vpacket.size(), 0, address, qid, domain, type, weWantEDNSSubnet, &queryfd, *now);
> + ret = asendto(vpacket.data(), vpacket.size(), 0, address, qid, domain, type, subnetOpts, &queryfd, *now);
>
> if (ret != LWResult::Result::Success) {
> return ret;
> @@ -502,7 +498,7 @@
> #endif /* HAVE_FSTRM */
>
> // sleep until we see an answer to this, interface to mtasker
> - ret = arecvfrom(buf, 0, address, len, qid, domain, type, queryfd, *now);
> + ret = arecvfrom(buf, 0, address, len, qid, domain, type, queryfd, subnetOpts, *now);
> }
> else {
> bool isNew;
> @@ -599,24 +595,37 @@
> lwr->d_records.push_back(answer);
> }
>
> - EDNSOpts edo;
> - if (EDNS0Level > 0 && getEDNSOpts(mdp, &edo)) {
> + if (EDNSOpts edo; EDNS0Level > 0 && getEDNSOpts(mdp, &edo)) {
> lwr->d_haveEDNS = true;
>
> - if (weWantEDNSSubnet) {
> - for (const auto& opt : edo.d_options) {
> - if (opt.first == EDNSOptionCode::ECS) {
> - EDNSSubnetOpts reso;
> - if (getEDNSSubnetOptsFromString(opt.second, &reso)) {
> - /* rfc7871 states that 0 "indicate[s] that the answer is suitable for all addresses in FAMILY",
> - so we might want to still pass the information along to be able to differentiate between
> - IPv4 and IPv6. Still I'm pretty sure it doesn't matter in real life, so let's not duplicate
> - entries in our cache. */
> - if (reso.scope.getBits()) {
> - uint8_t bits = std::min(reso.scope.getBits(), outgoingECSBits);
> - outgoingECSAddr.truncate(bits);
> - srcmask = Netmask(outgoingECSAddr, bits);
> - }
> + // If we sent out ECS, we can also expect to see a return with or without ECS, the absent case
> + // is not handled explicitly. If we do see a ECS in the reply, the source part *must* match
> + // with what we sent out. See https://www.rfc-editor.org/rfc/rfc7871#section-7.3. and section
> + // 11.2.
> + // For ECS hardening mode, the case where we sent out an ECS but did not receive a matching
> + // one is handled in arecvfrom().
> + if (subnetOpts) {
> + // THE RFC is not clear about the case of having multiple ECS options. We only look at the first.
> + if (const auto opt = edo.getFirstOption(EDNSOptionCode::ECS); opt != edo.d_options.end()) {
> + EDNSSubnetOpts reso;
> + if (getEDNSSubnetOptsFromString(opt->second, &reso)) {
> + if (!doTCP && reso.source != subnetOpts->source) {
> + g_slogout->info(Logr::Notice, "Incoming ECS does not match outgoing",
> + "server", Logging::Loggable(address),
> + "qname", Logging::Loggable(domain),
> + "outgoing", Logging::Loggable(subnetOpts->source),
> + "incoming", Logging::Loggable(reso.source));
> + return LWResult::Result::Spoofed;
> + }
> + /* rfc7871 states that 0 "indicate[s] that the answer is suitable for all addresses in FAMILY",
> + so we might want to still pass the information along to be able to differentiate between
> + IPv4 and IPv6. Still I'm pretty sure it doesn't matter in real life, so let's not duplicate
> + entries in our cache. */
> + if (reso.scope.getBits() != 0) {
> + uint8_t bits = std::min(reso.scope.getBits(), subnetOpts->source.getBits());
> + auto outgoingECSAddr = subnetOpts->source.getNetwork();
> + outgoingECSAddr.truncate(bits);
> + srcmask = Netmask(outgoingECSAddr, bits);
> }
> }
> }
> diff -Nru pdns-recursor-5.2.2/lwres.hh pdns-recursor-5.2.4/lwres.hh
> --- pdns-recursor-5.2.2/lwres.hh 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/lwres.hh 2025-07-17 14:20:08.000000000 +0200
> @@ -51,6 +51,7 @@
>
> extern std::shared_ptr<Logr::Logger> g_slogout;
> extern bool g_paddingOutgoing;
> +extern bool g_ECSHardening;
>
> class LWResException : public PDNSException
> {
> @@ -71,6 +72,7 @@
> OSLimitError = 3,
> Spoofed = 4, /* Spoofing attempt (too many near-misses) */
> ChainLimitError = 5,
> + ECSMissing = 6,
> };
>
> [[nodiscard]] static bool isLimitError(Result res)
> @@ -86,9 +88,11 @@
> bool d_haveEDNS{false};
> };
>
> +struct EDNSSubnetOpts;
> +
> LWResult::Result asendto(const void* data, size_t len, int flags, const ComboAddress& toAddress, uint16_t qid,
> - const DNSName& domain, uint16_t qtype, bool ecs, int* fileDesc, timeval& now);
> + const DNSName& domain, uint16_t qtype, const std::optional<EDNSSubnetOpts>& ecs, int* fileDesc, timeval& now);
> LWResult::Result arecvfrom(PacketBuffer& packet, int flags, const ComboAddress& fromAddr, size_t& len, uint16_t qid,
> - const DNSName& domain, uint16_t qtype, int fileDesc, const struct timeval& now);
> + const DNSName& domain, uint16_t qtype, int fileDesc, const std::optional<EDNSSubnetOpts>& ecs, const struct timeval& now);
>
> LWResult::Result asyncresolve(const ComboAddress& address, const DNSName& domain, int type, bool doTCP, bool sendRDQuery, int EDNS0Level, struct timeval* now, boost::optional<Netmask>& srcmask, const ResolveContext& context, const std::shared_ptr<std::vector<std::unique_ptr<RemoteLogger>>>& outgoingLoggers, const std::shared_ptr<std::vector<std::unique_ptr<FrameStreamLogger>>>& fstrmLoggers, const std::set<uint16_t>& exportTypes, LWResult* lwr, bool* chained);
> diff -Nru pdns-recursor-5.2.2/metrics_table.py pdns-recursor-5.2.4/metrics_table.py
> --- pdns-recursor-5.2.2/metrics_table.py 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/metrics_table.py 2025-07-17 14:20:08.000000000 +0200
> @@ -1401,4 +1401,10 @@
> 'pname': 'proxy-mapping-total-n-0', # For multicounters, state the first
> # No SNMP
> },
> + {
> + 'name': 'ecs-missing',
> + 'lambda': '[] { return g_Counters.sum(rec::Counter::ecsMissingCount); }',
> + 'desc': 'Number of answers where ECS info was missing',
> + 'snmp': 153,
> + },
> ]
> diff -Nru pdns-recursor-5.2.2/pdns_recursor.1 pdns-recursor-5.2.4/pdns_recursor.1
> --- pdns-recursor-5.2.2/pdns_recursor.1 2025-04-08 12:42:41.000000000 +0200
> +++ pdns-recursor-5.2.4/pdns_recursor.1 2025-07-17 14:22:43.000000000 +0200
> @@ -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" "Apr 08, 2025" "" "PowerDNS Recursor"
> +.TH "PDNS_RECURSOR" "1" "Jul 17, 2025" "" "PowerDNS Recursor"
> .SH NAME
> pdns_recursor \- The PowerDNS Recursor binary
> .SH SYNOPSIS
> diff -Nru pdns-recursor-5.2.2/pdns_recursor.cc pdns-recursor-5.2.4/pdns_recursor.cc
> --- pdns-recursor-5.2.2/pdns_recursor.cc 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/pdns_recursor.cc 2025-07-17 14:20:08.000000000 +0200
> @@ -30,8 +30,8 @@
> #include "rec-taskqueue.hh"
> #include "shuffle.hh"
> #include "validate-recursor.hh"
> -
> #include "ratelimitedlog.hh"
> +#include "ednsoptions.hh"
>
> #ifdef HAVE_SYSTEMD
> #include <systemd/sd-daemon.h>
> @@ -224,7 +224,6 @@
> else {
> PacketBuffer empty;
> g_multiTasker->sendEvent(pident, &empty);
> - // cerr<<"Had some kind of error: "<<ret<<", "<<stringerror()<<endl;
> }
> }
>
> @@ -277,45 +276,43 @@
>
> /* these two functions are used by LWRes */
> LWResult::Result asendto(const void* data, size_t len, int /* flags */,
> - const ComboAddress& toAddress, uint16_t qid, const DNSName& domain, uint16_t qtype, bool ecs, int* fileDesc, timeval& now)
> + const ComboAddress& toAddress, uint16_t qid, const DNSName& domain, uint16_t qtype, const std::optional<EDNSSubnetOpts>& ecs, int* fileDesc, timeval& now)
> {
>
> auto pident = std::make_shared<PacketID>();
> pident->domain = domain;
> pident->remote = toAddress;
> pident->type = qtype;
> + if (ecs) {
> + pident->ecsSubnet = ecs->source;
> + }
>
> - // We cannot merge ECS-enabled queries based on the ECS source only, as the scope
> - // of the response might be narrower, so instead we do not chain ECS-enabled queries
> - // at all.
> - if (!ecs) {
> - // See if there is an existing outstanding request we can chain on to, using partial equivalence
> - // function looking for the same query (qname and qtype) to the same host, but with a different
> - // message ID.
> - auto chain = g_multiTasker->getWaiters().equal_range(pident, PacketIDBirthdayCompare());
> -
> - for (; chain.first != chain.second; chain.first++) {
> - // Line below detected an issue with the two ways of ordering PacketIDs (birthday and non-birthday)
> - assert(chain.first->key->domain == pident->domain); // NOLINT
> - // don't chain onto existing chained waiter or a chain already processed
> - if (chain.first->key->fd > -1 && !chain.first->key->closed) {
> - auto currentChainSize = chain.first->key->authReqChain.size();
> - *fileDesc = -static_cast<int>(currentChainSize + 1); // value <= -1, gets used in waitEvent / sendEvent later on
> - if (g_maxChainLength > 0 && currentChainSize >= g_maxChainLength) {
> - return LWResult::Result::ChainLimitError;
> - }
> - assert(uSec(chain.first->key->creationTime) != 0); // NOLINT
> - auto age = now - chain.first->key->creationTime;
> - if (uSec(age) > static_cast<uint64_t>(1000) * authWaitTimeMSec(g_multiTasker) * 2 / 3) {
> - return LWResult::Result::ChainLimitError;
> - }
> - chain.first->key->authReqChain.emplace(*fileDesc, qid); // we can chain
> - auto maxLength = t_Counters.at(rec::Counter::maxChainLength);
> - if (currentChainSize + 1 > maxLength) {
> - t_Counters.at(rec::Counter::maxChainLength) = currentChainSize + 1;
> - }
> - return LWResult::Result::Success;
> + // See if there is an existing outstanding request we can chain on to, using partial equivalence
> + // function looking for the same query (qname, qtype and ecs if applicable) to the same host, but
> + // with a different message ID.
> + auto chain = g_multiTasker->getWaiters().equal_range(pident, PacketIDBirthdayCompare());
> +
> + for (; chain.first != chain.second; chain.first++) {
> + // Line below detected an issue with the two ways of ordering PacketIDs (birthday and non-birthday)
> + assert(chain.first->key->domain == pident->domain); // NOLINT
> + // don't chain onto existing chained waiter or a chain already processed
> + if (chain.first->key->fd > -1 && !chain.first->key->closed && pident->ecsSubnet == chain.first->key->ecsSubnet) {
> + auto currentChainSize = chain.first->key->authReqChain.size();
> + *fileDesc = -static_cast<int>(currentChainSize + 1); // value <= -1, gets used in waitEvent / sendEvent later on
> + if (g_maxChainLength > 0 && currentChainSize >= g_maxChainLength) {
> + return LWResult::Result::ChainLimitError;
> + }
> + assert(uSec(chain.first->key->creationTime) != 0); // NOLINT
> + auto age = now - chain.first->key->creationTime;
> + if (uSec(age) > static_cast<uint64_t>(1000) * authWaitTimeMSec(g_multiTasker) * 2 / 3) {
> + return LWResult::Result::ChainLimitError;
> + }
> + chain.first->key->authReqChain.emplace(*fileDesc, qid); // we can chain
> + auto maxLength = t_Counters.at(rec::Counter::maxChainLength);
> + if (currentChainSize + 1 > maxLength) {
> + t_Counters.at(rec::Counter::maxChainLength) = currentChainSize + 1;
> }
> + return LWResult::Result::Success;
> }
> }
>
> @@ -341,8 +338,10 @@
> return LWResult::Result::Success;
> }
>
> +static bool checkIncomingECSSource(const PacketBuffer& packet, const Netmask& subnet);
> +
> LWResult::Result arecvfrom(PacketBuffer& packet, int /* flags */, const ComboAddress& fromAddr, size_t& len,
> - uint16_t qid, const DNSName& domain, uint16_t qtype, int fileDesc, const struct timeval& now)
> + uint16_t qid, const DNSName& domain, uint16_t qtype, int fileDesc, const std::optional<EDNSSubnetOpts>& ecs, const struct timeval& now)
> {
> static const unsigned int nearMissLimit = ::arg().asNum("spoof-nearmiss-max");
>
> @@ -353,7 +352,13 @@
> pident->type = qtype;
> pident->remote = fromAddr;
> pident->creationTime = now;
> -
> + if (ecs) {
> + // We sent out the query using ecs
> + // We expect incoming source ECS to match, see https://www.rfc-editor.org/rfc/rfc7871#section-7.3
> + // But there's also section 11-2, which says we should treat absent incoming ecs as scope zero
> + // We fill in the search key with the ecs we sent out, so both cases are covered and accepted here.
> + pident->ecsSubnet = ecs->source;
> + }
> int ret = g_multiTasker->waitEvent(pident, &packet, authWaitTimeMSec(g_multiTasker), &now);
> len = 0;
>
> @@ -366,6 +371,12 @@
>
> len = packet.size();
>
> + // In ecs hardening mode, we consider a missing or a mismatched ECS in the reply as a case for
> + // retrying without ECS. The actual logic to do that is in Syncres::doResolveAtThisIP()
> + if (g_ECSHardening && pident->ecsSubnet && !checkIncomingECSSource(packet, *pident->ecsSubnet)) {
> + t_Counters.at(rec::Counter::ecsMissingCount)++;
> + return LWResult::Result::ECSMissing;
> + }
> if (nearMissLimit > 0 && pident->nearMisses > nearMissLimit) {
> /* we have received more than nearMissLimit answers on the right IP and port, from the right source (we are using connected sockets),
> for the correct qname and qtype, but with an unexpected message ID. That looks like a spoofing attempt. */
> @@ -2064,7 +2075,7 @@
> /* we need to pass the record len */
> int res = getEDNSOptions(reinterpret_cast<const char*>(&question.at(pos - sizeof(drh->d_clen))), questionLen - pos + (sizeof(drh->d_clen)), *options); // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast)
> if (res == 0) {
> - const auto& iter = options->find(EDNSOptionCode::ECS);
> + const auto iter = options->find(EDNSOptionCode::ECS);
> if (iter != options->end() && !iter->second.values.empty() && iter->second.values.at(0).content != nullptr && iter->second.values.at(0).size > 0) {
> EDNSSubnetOpts eso;
> if (getEDNSSubnetOptsFromString(iter->second.values.at(0).content, iter->second.values.at(0).size, &eso)) {
> @@ -2671,7 +2682,6 @@
> }
> }
> else {
> - // cerr<<t_id<<" had error: "<<stringerror()<<endl;
> if (firstQuery && errno == EAGAIN) {
> t_Counters.at(rec::Counter::noPacketError)++;
> }
> @@ -2923,6 +2933,32 @@
> assert(g_multiTasker->waitEvent(neverHappens, nullptr, jitterMsec) != -1); // NOLINT
> }
>
> +static bool checkIncomingECSSource(const PacketBuffer& packet, const Netmask& subnet)
> +{
> + bool foundMatchingECS = false;
> +
> + // We sent out ECS, check if the response has the expected ECS info
> + EDNSOptionViewMap ednsOptions;
> + if (slowParseEDNSOptions(packet, ednsOptions)) {
> + // check content
> + auto option = ednsOptions.find(EDNSOptionCode::ECS);
> + if (option != ednsOptions.end()) {
> + // found an ECS option
> + EDNSSubnetOpts ecs;
> + for (const auto& value : option->second.values) {
> + if (getEDNSSubnetOptsFromString(value.content, value.size, &ecs)) {
> + if (ecs.source == subnet) {
> + foundMatchingECS = true;
> + }
> + }
> + break; // The RFC isn't clear about multiple ECS options. We chose to handle it like cookies
> + // and only look at the first.
> + }
> + }
> + }
> + return foundMatchingECS;
> +}
> +
> static void handleUDPServerResponse(int fileDesc, FDMultiplexer::funcparam_t& var)
> {
> auto pid = boost::any_cast<std::shared_ptr<PacketID>>(var);
> @@ -3023,7 +3059,6 @@
>
> // be a bit paranoid here since we're weakening our matching
> if (pident->domain.empty() && !d_waiter.key->domain.empty() && pident->type == 0 && d_waiter.key->type != 0 && pident->id == d_waiter.key->id && d_waiter.key->remote == pident->remote) {
> - // cerr<<"Empty response, rest matches though, sending to a waiter"<<endl;
> pident->domain = d_waiter.key->domain;
> pident->type = d_waiter.key->type;
> goto retryWithName; // note that this only passes on an error, lwres will still reject the packet NOLINT(cppcoreguidelines-avoid-goto)
> diff -Nru pdns-recursor-5.2.2/rec_control.1 pdns-recursor-5.2.4/rec_control.1
> --- pdns-recursor-5.2.2/rec_control.1 2025-04-08 12:42:41.000000000 +0200
> +++ pdns-recursor-5.2.4/rec_control.1 2025-07-17 14:22:43.000000000 +0200
> @@ -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" "Apr 08, 2025" "" "PowerDNS Recursor"
> +.TH "REC_CONTROL" "1" "Jul 17, 2025" "" "PowerDNS Recursor"
> .SH NAME
> rec_control \- Command line tool to control a running Recursor
> .SH SYNOPSIS
> diff -Nru pdns-recursor-5.2.2/rec-main.cc pdns-recursor-5.2.4/rec-main.cc
> --- pdns-recursor-5.2.2/rec-main.cc 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/rec-main.cc 2025-07-17 14:20:08.000000000 +0200
> @@ -2254,6 +2254,7 @@
> }
> g_paddingTag = ::arg().asNum("edns-padding-tag");
> g_paddingOutgoing = ::arg().mustDo("edns-padding-out");
> + g_ECSHardening = ::arg().mustDo("edns-subnet-harden");
>
> RecThreadInfo::setNumDistributorThreads(::arg().asNum("distributor-threads"));
> RecThreadInfo::setNumUDPWorkerThreads(::arg().asNum("threads"));
> diff -Nru pdns-recursor-5.2.2/rec-metrics-gen.h pdns-recursor-5.2.4/rec-metrics-gen.h
> --- pdns-recursor-5.2.2/rec-metrics-gen.h 2025-04-08 12:42:19.000000000 +0200
> +++ pdns-recursor-5.2.4/rec-metrics-gen.h 2025-07-17 14:22:18.000000000 +0200
> @@ -279,3 +279,4 @@
> addGetStat("remote-logger-count", []() {
> return toRemoteLoggerStatsMap("remote-logger-count");
> });
> +addGetStat("ecs-missing", [] { return g_Counters.sum(rec::Counter::ecsMissingCount); });
> diff -Nru pdns-recursor-5.2.2/rec-oids-gen.h pdns-recursor-5.2.4/rec-oids-gen.h
> --- pdns-recursor-5.2.2/rec-oids-gen.h 2025-04-08 12:42:19.000000000 +0200
> +++ pdns-recursor-5.2.4/rec-oids-gen.h 2025-07-17 14:22:18.000000000 +0200
> @@ -171,3 +171,4 @@
> static const oid10 maxChainWeightOID = {RECURSOR_STATS_OID, 150};
> static const oid10 chainLimitsOID = {RECURSOR_STATS_OID, 151};
> static const oid10 tcpOverflowOID = {RECURSOR_STATS_OID, 152};
> +static const oid10 ecsMissingOID = {RECURSOR_STATS_OID, 153};
> diff -Nru pdns-recursor-5.2.2/rec-prometheus-gen.h pdns-recursor-5.2.4/rec-prometheus-gen.h
> --- pdns-recursor-5.2.2/rec-prometheus-gen.h 2025-04-08 12:42:19.000000000 +0200
> +++ pdns-recursor-5.2.4/rec-prometheus-gen.h 2025-07-17 14:22:18.000000000 +0200
> @@ -197,3 +197,4 @@
> {"cumul-authanswers-count4", MetricDefinition(PrometheusMetricType::histogram, "Cumulative counts of answer times to clients in buckets less than x microseconds.")},
> {"policy-hits", MetricDefinition(PrometheusMetricType::multicounter, "Number of policy decisions based on Lua")},
> {"proxy-mapping-total-n-0", MetricDefinition(PrometheusMetricType::multicounter, "Proxy mappings done")},
> +{"ecs-missing", MetricDefinition(PrometheusMetricType::counter, "Number of answers where ECS info was missing")},
> diff -Nru pdns-recursor-5.2.2/rec-snmp-gen.h pdns-recursor-5.2.4/rec-snmp-gen.h
> --- pdns-recursor-5.2.2/rec-snmp-gen.h 2025-04-08 12:42:19.000000000 +0200
> +++ pdns-recursor-5.2.4/rec-snmp-gen.h 2025-07-17 14:22:18.000000000 +0200
> @@ -171,3 +171,4 @@
> registerCounter64Stat("max-chain-weight", maxChainWeightOID);
> registerCounter64Stat("chain-limits", chainLimitsOID);
> registerCounter64Stat("tcp-overflow", tcpOverflowOID);
> +registerCounter64Stat("ecs-missing", ecsMissingOID);
> diff -Nru pdns-recursor-5.2.2/rec-tcounters.hh pdns-recursor-5.2.4/rec-tcounters.hh
> --- pdns-recursor-5.2.2/rec-tcounters.hh 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/rec-tcounters.hh 2025-07-17 14:20:08.000000000 +0200
> @@ -98,6 +98,7 @@
> maxChainLength,
> maxChainWeight,
> chainLimits,
> + ecsMissingCount,
>
> numberOfCounters
> };
> diff -Nru pdns-recursor-5.2.2/RECURSOR-MIB.in pdns-recursor-5.2.4/RECURSOR-MIB.in
> --- pdns-recursor-5.2.2/RECURSOR-MIB.in 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/RECURSOR-MIB.in 2025-07-17 14:20:08.000000000 +0200
> @@ -21,6 +21,9 @@
> DESCRIPTION
> "This MIB module describes information gathered through PowerDNS Recursor."
>
> + REVISION "202505270000Z"
> + DESCRIPTION "Added metric for missing ECS in reply"
> +
> REVISION "202408280000Z"
> DESCRIPTION "Added metric for too many incoming TCP connections"
>
> diff -Nru pdns-recursor-5.2.2/RECURSOR-MIB.txt pdns-recursor-5.2.4/RECURSOR-MIB.txt
> --- pdns-recursor-5.2.2/RECURSOR-MIB.txt 2025-04-08 12:42:19.000000000 +0200
> +++ pdns-recursor-5.2.4/RECURSOR-MIB.txt 2025-07-17 14:22:18.000000000 +0200
> @@ -21,6 +21,9 @@
> DESCRIPTION
> "This MIB module describes information gathered through PowerDNS Recursor."
>
> + REVISION "202505270000Z"
> + DESCRIPTION "Added metric for missing ECS in reply"
> +
> REVISION "202408280000Z"
> DESCRIPTION "Added metric for too many incoming TCP connections"
>
> @@ -1291,6 +1294,14 @@
> "Incoming TCP limits reached"
> ::= { stats 152 }
>
> +ecsMissing OBJECT-TYPE
> + SYNTAX Counter64
> + MAX-ACCESS read-only
> + STATUS current
> + DESCRIPTION
> + "Number of answers where ECS info was missing"
> + ::= { stats 153 }
> +
> ---
> --- Traps / Notifications
> ---
> @@ -1489,7 +1500,8 @@
> maxChainLength,
> maxChainWeight,
> chainLimits,
> - tcpOverflow
> + tcpOverflow,
> + ecsMissing
> }
> STATUS current
> DESCRIPTION "Objects conformance group for PowerDNS Recursor"
> diff -Nru pdns-recursor-5.2.2/settings/cxxsettings-generated.cc pdns-recursor-5.2.4/settings/cxxsettings-generated.cc
> --- pdns-recursor-5.2.2/settings/cxxsettings-generated.cc 2025-04-08 12:42:42.000000000 +0200
> +++ pdns-recursor-5.2.4/settings/cxxsettings-generated.cc 2025-07-17 14:22:44.000000000 +0200
> @@ -64,6 +64,7 @@
> ::arg().set("edns-padding-tag", "Packetcache tag associated to responses sent with EDNS padding, to prevent sending these to clients for which padding is not enabled.") = "7830";
> ::arg().set("edns-subnet-whitelist", "List of netmasks and domains that we should enable EDNS subnet for (deprecated)") = "";
> ::arg().set("edns-subnet-allow-list", "List of netmasks and domains that we should enable EDNS subnet for") = "";
> + ::arg().setSwitch("edns-subnet-harden", "Do more strict checking or EDNS Client Subnet information returned by authoritative servers") = "no";
> ::arg().setSwitch("enable-old-settings", "Enable (deprecated) parsing of old-style settings") = "no";
> ::arg().set("entropy-source", "If set, read entropy from this file") = "/dev/urandom";
> ::arg().set("etc-hosts-file", "Path to 'hosts' file") = "/etc/hosts";
> @@ -310,6 +311,7 @@
> settings.outgoing.edns_padding = arg().mustDo("edns-padding-out");
> settings.incoming.edns_padding_tag = static_cast<uint64_t>(arg().asNum("edns-padding-tag"));
> settings.outgoing.edns_subnet_allow_list = getStrings("edns-subnet-allow-list");
> + settings.outgoing.edns_subnet_harden = arg().mustDo("edns-subnet-harden");
> settings.recursor.etc_hosts_file = arg()["etc-hosts-file"];
> settings.recursor.event_trace_enabled = static_cast<uint64_t>(arg().asNum("event-trace-enabled"));
> settings.recursor.export_etc_hosts = arg().mustDo("export-etc-hosts");
> @@ -864,6 +866,13 @@
> to_yaml(rustvalue.vec_string_val, value);
> return true;
> }
> + if (key == "edns-subnet-harden") {
> + section = "outgoing";
> + fieldname = "edns_subnet_harden";
> + type_name = "bool";
> + to_yaml(rustvalue.bool_val, value);
> + return true;
> + }
> if (key == "etc-hosts-file") {
> section = "recursor";
> fieldname = "etc_hosts_file";
> @@ -2010,6 +2019,7 @@
> ::arg().set("edns-padding-out") = to_arg(settings.outgoing.edns_padding);
> ::arg().set("edns-padding-tag") = to_arg(settings.incoming.edns_padding_tag);
> ::arg().set("edns-subnet-allow-list") = to_arg(settings.outgoing.edns_subnet_allow_list);
> + ::arg().set("edns-subnet-harden") = to_arg(settings.outgoing.edns_subnet_harden);
> ::arg().set("etc-hosts-file") = to_arg(settings.recursor.etc_hosts_file);
> ::arg().set("event-trace-enabled") = to_arg(settings.recursor.event_trace_enabled);
> ::arg().set("export-etc-hosts") = to_arg(settings.recursor.export_etc_hosts);
> diff -Nru pdns-recursor-5.2.2/settings/rust/src/lib.rs pdns-recursor-5.2.4/settings/rust/src/lib.rs
> --- pdns-recursor-5.2.2/settings/rust/src/lib.rs 2025-04-08 12:42:42.000000000 +0200
> +++ pdns-recursor-5.2.4/settings/rust/src/lib.rs 2025-07-17 14:22:44.000000000 +0200
> @@ -925,6 +925,9 @@
> edns_subnet_allow_list: Vec<String>,
>
> #[serde(default, skip_serializing_if = "crate::is_default")]
> + edns_subnet_harden: bool,
> +
> + #[serde(default, skip_serializing_if = "crate::is_default")]
> lowercase: bool,
>
> #[serde(default, skip_serializing_if = "crate::is_default")]
> @@ -2048,6 +2051,9 @@
> }
> merge_vec(&mut self.edns_subnet_allow_list, &mut rhs.edns_subnet_allow_list);
> }
> + if m.contains_key("edns_subnet_harden") {
> + rhs.edns_subnet_harden.clone_into(&mut self.edns_subnet_harden);
> + }
> if m.contains_key("lowercase") {
> rhs.lowercase.clone_into(&mut self.lowercase);
> }
> diff -Nru pdns-recursor-5.2.2/settings/table.py pdns-recursor-5.2.4/settings/table.py
> --- pdns-recursor-5.2.2/settings/table.py 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/settings/table.py 2025-07-17 14:20:08.000000000 +0200
> @@ -952,6 +952,18 @@
> 'versionadded': '4.5.0'
> },
> {
> + 'name' : 'edns_subnet_harden',
> + 'section' : 'outgoing',
> + 'type' : LType.Bool,
> + 'default' : 'false',
> + 'help' : 'Do more strict checking or EDNS Client Subnet information returned by authoritative servers',
> + 'doc' : '''
> +Do more strict checking or EDNS Client Subnet information returned by authoritative servers.
> +Answers missing ECS information will be ignored and followed up by an ECS-less query.
> + ''',
> + 'versionadded': ['5.2.x', '5.1.x', '5.0.x']
> + },
> + {
> 'name' : 'enable_old_settings',
> 'section' : 'recursor',
> 'type' : LType.Bool,
> diff -Nru pdns-recursor-5.2.2/syncres.cc pdns-recursor-5.2.4/syncres.cc
> --- pdns-recursor-5.2.2/syncres.cc 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/syncres.cc 2025-07-17 14:20:08.000000000 +0200
> @@ -5490,19 +5490,24 @@
> }
> }
>
> -bool SyncRes::doResolveAtThisIP(const std::string& prefix, const DNSName& qname, const QType qtype, LWResult& lwr, boost::optional<Netmask>& ednsmask, const DNSName& auth, bool const sendRDQuery, const bool wasForwarded, const DNSName& nsName, const ComboAddress& remoteIP, bool doTCP, bool doDoT, bool& truncated, bool& spoofed, boost::optional<EDNSExtendedError>& extendedError, bool dontThrottle)
> +void SyncRes::checkTotalTime(const DNSName& qname, QType qtype, boost::optional<EDNSExtendedError>& extendedError) const
> {
> - bool chained = false;
> - LWResult::Result resolveret = LWResult::Result::Success;
> -
> if (s_maxtotusec != 0 && d_totUsec > s_maxtotusec) {
> if (s_addExtendedResolutionDNSErrors) {
> extendedError = EDNSExtendedError{static_cast<uint16_t>(EDNSExtendedError::code::NoReachableAuthority), "Timeout waiting for answer(s)"};
> }
> throw ImmediateServFailException("Too much time waiting for " + qname.toLogString() + "|" + qtype.toString() + ", timeouts: " + std::to_string(d_timeouts) + ", throttles: " + std::to_string(d_throttledqueries) + ", queries: " + std::to_string(d_outqueries) + ", " + std::to_string(d_totUsec / 1000) + " ms");
> }
> +}
> +
> +bool SyncRes::doResolveAtThisIP(const std::string& prefix, const DNSName& qname, const QType qtype, LWResult& lwr, boost::optional<Netmask>& ednsmask, const DNSName& auth, bool const sendRDQuery, const bool wasForwarded, const DNSName& nsName, const ComboAddress& remoteIP, bool doTCP, bool doDoT, bool& truncated, bool& spoofed, boost::optional<EDNSExtendedError>& extendedError, bool dontThrottle)
> +{
> + checkTotalTime(qname, qtype, extendedError);
>
> + bool chained = false;
> + LWResult::Result resolveret = LWResult::Result::Success;
> int preOutQueryRet = RCode::NoError;
> +
> if (d_pdl && d_pdl->preoutquery(remoteIP, d_requestor, qname, qtype, doTCP, lwr.d_records, preOutQueryRet, d_eventTrace, timeval{0, 0})) {
> LOG(prefix << qname << ": Query handled by Lua" << endl);
> }
> @@ -5516,6 +5521,13 @@
> resolveret = asyncresolveWrapper(remoteIP, d_doDNSSEC, qname, auth, qtype.getCode(),
> doTCP, sendRDQuery, &d_now, ednsmask, &lwr, &chained, nsName); // <- we go out on the wire!
> ednsStats(ednsmask, qname, prefix);
> + if (resolveret == LWResult::Result::ECSMissing) {
> + ednsmask = boost::none;
> + LOG(prefix << qname << ": Answer has no ECS, trying again without EDNS Client Subnet Mask" << endl);
> + updateQueryCounts(prefix, qname, remoteIP, doTCP, doDoT);
> + resolveret = asyncresolveWrapper(remoteIP, d_doDNSSEC, qname, auth, qtype.getCode(),
> + doTCP, sendRDQuery, &d_now, ednsmask, &lwr, &chained, nsName);
> + }
> }
>
> /* preoutquery killed the query by setting dq.rcode to -3 */
> diff -Nru pdns-recursor-5.2.2/syncres.hh pdns-recursor-5.2.4/syncres.hh
> --- pdns-recursor-5.2.2/syncres.hh 2025-04-08 12:40:39.000000000 +0200
> +++ pdns-recursor-5.2.4/syncres.hh 2025-07-17 14:20:08.000000000 +0200
> @@ -634,6 +634,7 @@
> std::map<DNSName, std::vector<ComboAddress>>* fallback);
> void ednsStats(boost::optional<Netmask>& ednsmask, const DNSName& qname, const string& prefix);
> void incTimeoutStats(const ComboAddress& remoteIP);
> + void checkTotalTime(const DNSName& qname, QType qtype, boost::optional<EDNSExtendedError>& extendedError) const;
> bool doResolveAtThisIP(const std::string& prefix, const DNSName& qname, QType qtype, LWResult& lwr, boost::optional<Netmask>& ednsmask, const DNSName& auth, bool sendRDQuery, bool wasForwarded, const DNSName& nsName, const ComboAddress& remoteIP, bool doTCP, bool doDoT, bool& truncated, bool& spoofed, boost::optional<EDNSExtendedError>& extendedError, bool dontThrottle = false);
> bool processAnswer(unsigned int depth, const string& prefix, LWResult& lwr, const DNSName& qname, QType qtype, DNSName& auth, bool wasForwarded, const boost::optional<Netmask>& ednsmask, bool sendRDQuery, NsSet& nameservers, std::vector<DNSRecord>& ret, const DNSFilterEngine& dfe, bool* gotNewServers, int* rcode, vState& state, const ComboAddress& remoteIP);
>
> @@ -781,6 +782,7 @@
> mutable chain_t authReqChain;
> shared_ptr<TCPIOHandler> tcphandler{nullptr};
> timeval creationTime{};
> + std::optional<Netmask> ecsSubnet;
> string::size_type inPos{0}; // how far are we along in the inMSG
> size_t inWanted{0}; // if this is set, we'll read until inWanted bytes are read
> string::size_type outPos{0}; // how far we are along in the outMSG
> @@ -803,7 +805,7 @@
>
> inline ostream& operator<<(ostream& ostr, const PacketID& pid)
> {
> - return ostr << "PacketID(id=" << pid.id << ",remote=" << pid.remote.toString() << ",type=" << pid.type << ",tcpsock=" << pid.tcpsock << ",fd=" << pid.fd << ',' << pid.domain << ')';
> + return ostr << "PacketID(id=" << pid.id << ",remote=" << pid.remote.toString() << ",type=" << pid.type << ",tcpsock=" << pid.tcpsock << ",fd=" << pid.fd << ",name=" << pid.domain << ",ecs=" << (pid.ecsSubnet ? pid.ecsSubnet->toString() : "") << ')';
> }
>
> inline ostream& operator<<(ostream& ostr, const shared_ptr<PacketID>& pid)
--
Sebastian Ramacher
Reply to: