}
}
+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
{
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
{
return std::tie(d_network, d_bits) == std::tie(rhs.d_network, rhs.d_bits);
}
+
bool operator!=(const Netmask& rhs) const
{
return !operator==(rhs);
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"
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"
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 we sent out ECS, we can also expect to see a return with or without ECS, the absent case
- // is not handled explicitly. In hardening mode, 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
+ // 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) {
- for (const auto& opt : edo.d_options) {
- if (opt.first == EDNSOptionCode::ECS) {
- EDNSSubnetOpts reso;
- if (EDNSSubnetOpts::getFromString(opt.second, &reso)) {
- if (g_ECSHardening && !(reso.getSource() == subnetOpts->getSource())) {
- g_slogout->info(Logr::Notice, "Incoming ECS does not match outgoing",
- "server", Logging::Loggable(address),
- "qname", Logging::Loggable(domain),
- "outgoing", Logging::Loggable(subnetOpts->getSource()),
- "incoming", Logging::Loggable(reso.getSource()));
- return LWResult::Result::Spoofed; // XXXXX OK?
- }
- /* 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.getScopePrefixLength() != 0) {
- uint8_t bits = std::min(reso.getScopePrefixLength(), subnetOpts->getSourcePrefixLength());
- auto outgoingECSAddr = subnetOpts->getSource().getNetwork();
- outgoingECSAddr.truncate(bits);
- srcmask = Netmask(outgoingECSAddr, bits);
- }
+ // 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 (EDNSSubnetOpts::getFromString(opt->second, &reso)) {
+ if (!doTCP && reso.getSource() != subnetOpts->getSource()) {
+ g_slogout->info(Logr::Notice, "Incoming ECS does not match outgoing",
+ "server", Logging::Loggable(address),
+ "qname", Logging::Loggable(domain),
+ "outgoing", Logging::Loggable(subnetOpts->getSource()),
+ "incoming", Logging::Loggable(reso.getSource()));
+ 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.getScopePrefixLength() != 0) {
+ uint8_t bits = std::min(reso.getScopePrefixLength(), subnetOpts->getSourcePrefixLength());
+ auto outgoingECSAddr = subnetOpts->getSource().getNetwork();
+ outgoingECSAddr.truncate(bits);
+ srcmask = Netmask(outgoingECSAddr, bits);
}
}
}
len = packet.size();
- // In ecs hardening mode, we consider a missing 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 && !*pident->ecsReceived) {
+ // In ecs hardening mode, we consider a missing or a mismatched ECS in the reply as a case for
+ // retrying without ECS (matchingECSReceived only gets set if a matching ECS was received). The actual
+ // logic to do that is in Syncres::doResolveAtThisIP()
+ if (g_ECSHardening && pident->ecsSubnet && !*pident->matchingECSReceived) {
t_Counters.at(rec::Counter::ecsMissingCount)++;
return LWResult::Result::ECSMissing;
}
/* 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 (EDNSSubnetOpts::getFromString(iter->second.values.at(0).content, iter->second.values.at(0).size, &eso)) {
}
// resend event to everybody chained onto it
-static void doResends(MT_t::waiters_t::iterator& iter, const std::shared_ptr<PacketID>& resend, const PacketBuffer& content, const std::optional<bool>& ecsReceived)
+static void doResends(MT_t::waiters_t::iterator& iter, const std::shared_ptr<PacketID>& resend, const PacketBuffer& content, const std::optional<bool>& matchingECSReceived)
{
// We close the chain for new entries, since they won't be processed anyway
iter->key->closed = true;
return;
}
- if (ecsReceived) {
- iter->key->ecsReceived = ecsReceived;
+ // Only set if g_ECSHardening
+ if (matchingECSReceived) {
+ iter->key->matchingECSReceived = matchingECSReceived;
}
auto maxWeight = t_Counters.at(rec::Counter::maxChainWeight);
foundMatchingECS = true;
}
}
- break; // only look at first
+ break; // The RFC isn't clear about multiple ECS options. We chose to handle it like cookies
+ // and only look at the first.
}
}
}
if (!pident->domain.empty()) {
auto iter = g_multiTasker->getWaiters().find(pident);
if (iter != g_multiTasker->getWaiters().end()) {
- iter->key->ecsReceived = iter->key->ecsSubnet && checkIncomingECSSource(packet, *iter->key->ecsSubnet);
- doResends(iter, pident, packet, iter->key->ecsReceived);
+ if (g_ECSHardening) {
+ iter->key->matchingECSReceived = iter->key->ecsSubnet && checkIncomingECSSource(packet, *iter->key->ecsSubnet);
+ }
+ doResends(iter, pident, packet, iter->key->matchingECSReceived);
}
}
bool inIncompleteOkay{false};
uint16_t id{0}; // wait for a specific id/remote pair
uint16_t type{0}; // and this is its type
- std::optional<bool> ecsReceived; // only set in ecsHardened mode
+ std::optional<bool> matchingECSReceived; // only set in ecsHardened mode
TCPAction highState{TCPAction::DoingRead};
IOState lowState{IOState::NeedRead};
import dns
import dns.message
import requests
-
+import threading
+from twisted.internet import reactor
from proxyprotocol import ProxyProtocol
from eqdnsmessage import AssertEqualDNSMessageMixin
self.assertEqual(value, expected, key + ": value " + str(value) + " is not expected")
count += 1
self.assertEqual(count, len(map))
+
+ @classmethod
+ def startReactor(cls):
+ if not reactor.running:
+ cls.Responder = threading.Thread(name='Responder', target=reactor.run, args=(False,))
+ cls.Responder.daemon = True
+ cls.Responder.start()
import clientsubnetoption
import unittest
from recursortests import RecursorTest, have_ipv6
+
+from twisted.internet.protocol import Factory
+from twisted.internet.protocol import Protocol
from twisted.internet.protocol import DatagramProtocol
from twisted.internet import reactor
emptyECSText = 'No ECS received'
+mismatchedECSText = 'Mismatched ECS'
nameECS = 'ecs-echo.example.'
nameECSInvalidScope = 'invalid-scope.ecs-echo.example.'
ttlECS = 60
if not ecsReactorRunning:
reactor.listenUDP(port, UDPECSResponder(), interface=address)
+ reactor.listenTCP(port, TCPECSFactory(), interface=address)
ecsReactorRunning = True
if not ecsReactorv6Running and have_ipv6():
reactor.listenUDP(53000, UDPECSResponder(), interface='::1')
+ reactor.listenTCP(53000, TCPECSFactory(), interface='::1')
ecsReactorv6Running = True
- if not reactor.running:
- cls._UDPResponder = threading.Thread(name='UDP Responder', target=reactor.run, args=(False,))
- cls._UDPResponder.daemon = True
- cls._UDPResponder.start()
+ cls.startReactor()
class NoECSTest(ECSTest):
_confdir = 'NoECS'
api-key=%s
""" % (os.environ['PREFIX'], _wsPort, _wsPassword, _apiKey)
- # All test below have ecs-missing count to be 1, as they result is a no ecs scoped answer in the cache
+ # All test below have ecs-missing count to be 1, as they result in a non ecs scoped answer in the cache
def test1SendECS(self):
expected = dns.rrset.from_text('x'+ nameECS, ttlECS, dns.rdataclass.IN, 'TXT', 'X')
ecso = clientsubnetoption.ClientSubnetOption('192.0.2.1', 32)
'ecs-missing': 1
})
+class MismatchedECSInAnswerTest(ECSTest):
+ _confdir = 'MismatchedECSInAnswer'
+
+ _config_template = """edns-subnet-allow-list=mecs-echo.example
+forward-zones=mecs-echo.example=%s.21
+dont-throttle-netmasks=0.0.0.0/0
+ """ % (os.environ['PREFIX'])
+
+ def test1SendECS(self):
+ expected = dns.rrset.from_text('m'+ nameECS, ttlECS, dns.rdataclass.IN, 'TXT', emptyECSText)
+ ecso = clientsubnetoption.ClientSubnetOption('192.0.2.1', 32)
+ query = dns.message.make_query('m' + nameECS, 'TXT', 'IN', use_edns=True, options=[ecso], payload=512)
+ self.sendECSQuery(query, expected)
+
+ def test2NoECS(self):
+ expected = dns.rrset.from_text('m' + nameECS, ttlECS, dns.rdataclass.IN, 'TXT', emptyECSText)
+ query = dns.message.make_query('m' + nameECS, 'TXT')
+ self.sendECSQuery(query, expected)
+
+ def test3RequireNoECS(self):
+ expected = dns.rrset.from_text('m' + nameECS, ttlECS, dns.rdataclass.IN, 'TXT', emptyECSText)
+ ecso = clientsubnetoption.ClientSubnetOption('0.0.0.0', 0)
+ query = dns.message.make_query('m' + nameECS, 'TXT', 'IN', use_edns=True, options=[ecso], payload=512)
+ self.sendECSQuery(query, expected)
+
+class MismatchedECSInAnswerHardenedTest(MismatchedECSInAnswerTest):
+ _confdir = 'MismatchedECSInAnswerHardened'
+
+ _config_template = """
+edns-subnet-harden=yes
+edns-subnet-allow-list=mecs-echo.example
+forward-zones=mecs-echo.example=%s.21
+dont-throttle-netmasks=0.0.0.0/0
+ """ % (os.environ['PREFIX'])
+
+ def test1SendECS(self):
+ expected = dns.rrset.from_text('m'+ nameECS, ttlECS, dns.rdataclass.IN, 'TXT', emptyECSText)
+ ecso = clientsubnetoption.ClientSubnetOption('192.0.2.1', 32)
+ query = dns.message.make_query('m' + nameECS, 'TXT', 'IN', use_edns=True, options=[ecso], payload=512)
+ self.sendECSQuery(query, expected)
+
+ def test2NoECS(self):
+ expected = dns.rrset.from_text('m' + nameECS, ttlECS, dns.rdataclass.IN, 'TXT', emptyECSText)
+ query = dns.message.make_query('m' + nameECS, 'TXT')
+ self.sendECSQuery(query, expected)
+
+ def test3RequireNoECS(self):
+ expected = dns.rrset.from_text('m' + nameECS, ttlECS, dns.rdataclass.IN, 'TXT', emptyECSText)
+ ecso = clientsubnetoption.ClientSubnetOption('0.0.0.0', 0)
+ query = dns.message.make_query('m' + nameECS, 'TXT', 'IN', use_edns=True, options=[ecso], payload=512)
+ self.sendECSQuery(query, expected)
+
class IncomingNoECSTest(ECSTest):
_confdir = 'IncomingNoECS'
option.ip & (2 ** 64 - 1)))
return ip
- def datagramReceived(self, datagram, address):
+ def question(self, datagram, tcp=False):
request = dns.message.from_wire(datagram)
response = dns.message.make_response(request)
elif request.question[0].name == dns.name.from_text('x' + nameECS):
answer = dns.rrset.from_text(request.question[0].name, ttlECS, dns.rdataclass.IN, 'TXT', 'X')
response.answer.append(answer)
+ elif request.question[0].name == dns.name.from_text('m' + nameECS):
+ incomingECS = False
+ for option in request.options:
+ if option.otype == clientsubnetoption.ASSIGNED_OPTION_CODE and isinstance(option, clientsubnetoption.ClientSubnetOption):
+ incomingECS = True
+ # Send mismatched ECS over UDP
+ flag = emptyECSText
+ if not tcp and incomingECS:
+ ecso = clientsubnetoption.ClientSubnetOption("193.0.2.1", 24, 25)
+ flag = mismatchedECSText
+ answer = dns.rrset.from_text(request.question[0].name, ttlECS, dns.rdataclass.IN, 'TXT', flag)
+ response.answer.append(answer)
if ecso:
response.use_edns(options = [ecso])
- self.transport.write(response.to_wire(), address)
+ return response.to_wire()
+
+ def datagramReceived(self, datagram, address):
+ response = self.question(datagram)
+ self.transport.write(response, address)
+
+class TCPECSResponder(Protocol):
+ def dataReceived(self, data):
+ handler = UDPECSResponder()
+ response = handler.question(data[2:], True)
+ length = len(response)
+ header = length.to_bytes(2, 'big')
+ self.transport.write(header + response)
+
+class TCPECSFactory(Factory):
+ def buildProtocol(self, addr):
+ return TCPECSResponder()