Package: release.debian.org
Severity: normal
X-Debbugs-Cc: [email protected]
Control: affects -1 + src:pdns-recursor
User: [email protected]
Usertags: unblock

Please unblock package pdns-recursor

[ Reason ]
Upstream security fix for CVE-2025-30192, Debian bug #1109808

[ 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 <[email protected]>  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 <[email protected]>  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)

Reply via email to