]> git.ipfire.org Git - thirdparty/pdns.git/commitdiff
Basic cookies test: enable cookies in rec and talk to auths with cookies disabled...
authorOtto Moerbeek <otto.moerbeek@open-xchange.com>
Wed, 19 Feb 2025 10:09:05 +0000 (11:09 +0100)
committerOtto Moerbeek <otto.moerbeek@open-xchange.com>
Thu, 4 Sep 2025 09:05:16 +0000 (11:05 +0200)
pdns/recursordist/lwres.cc
pdns/recursordist/lwres.hh
pdns/recursordist/rec_channel_rec.cc
pdns/recursordist/syncres.cc
regression-tests.recursor-dnssec/recursortests.py
regression-tests.recursor-dnssec/requirements.in
regression-tests.recursor-dnssec/test_Cookies.py [new file with mode: 0644]
regression-tests.recursor-dnssec/test_SimpleCookies.py [new file with mode: 0644]

index 62750e766176cf50bc2b4ef0f3c138ddf562eccd..b6786fede063b1e6539cbf3f2ffdf1ee5107c747 100644 (file)
@@ -70,6 +70,13 @@ bool g_ECSHardening;
 
 static LockGuarded<CookieStore> s_cookiestore;
 
+std::string clearCookies()
+{
+  auto lock = s_cookiestore.lock();
+  lock->clear();
+  return "";
+}
+
 void pruneCookies(time_t cutoff)
 {
   auto lock = s_cookiestore.lock();
@@ -737,16 +744,23 @@ static LWResult::Result asyncresolve(const ComboAddress& address, const DNSName&
                 }
                 else {
                   // Server responded with a wrong client cookie, fall back to TCP
+                  cerr << "Wrong cookie" << endl;
                   lwr->d_validpacket = true;
-                  return LWResult::Result::BadCookie;
+                  return LWResult::Result::Spoofed;
                 }
               }
               else {
                 // We sent a cookie out but forgot it?
+                cerr << "Cookie not found back"<< endl;
                 lwr->d_validpacket = true;
-                return LWResult::Result::BadCookie;
+                return LWResult::Result::BadCookie; // XXX
               }
             }
+            else {
+              cerr << "Malformed cookie in reply"<< endl;
+              lwr->d_validpacket = true;
+              return LWResult::Result::BadCookie; // XXX
+            }
           }
         }
       }
@@ -754,8 +768,9 @@ static LWResult::Result asyncresolve(const ComboAddress& address, const DNSName&
 
     // Case: we sent out a cookie but did not get one back
     if (cookieSentOut && !cookieFoundInReply && !*chained) {
+      cerr << "No cookie in reply"<< endl;
       lwr->d_validpacket = true;
-      return LWResult::Result::BadCookie;
+      return LWResult::Result::BadCookie; // XXX
     }
 
     if (outgoingLoggers) {
index d091e4c3094c24b8514d908f6a160cb2793cccd6..053a3515123bb542ecd5961330d7f19cf9bfccd8 100644 (file)
@@ -100,5 +100,6 @@ LWResult::Result arecvfrom(PacketBuffer& packet, int flags, const ComboAddress&
 
 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);
 uint64_t dumpCookies(int fileDesc);
+std::string clearCookies();
 void pruneCookies(time_t cutoff);
 void setAuthCookies(bool flag);
index 9e25768b5f85b40e0f93ecd5c71dfa18d17379c8..371f2e8b7f5d601b210e0a901badb24e22629ac4 100644 (file)
@@ -1888,6 +1888,7 @@ static RecursorControlChannel::Answer help()
           "add-nta DOMAIN [REASON]          add a Negative Trust Anchor for DOMAIN with the comment REASON\n"
           "add-ta DOMAIN DSRECORD           add a Trust Anchor for DOMAIN with data DSRECORD\n"
           "current-queries                  show currently active queries\n"
+          // "clear-cookies                    clear cookie table\n" XXX undocumented for now
           "clear-dont-throttle-names [N...] remove names that are not allowed to be throttled. If N is '*', remove all\n"
           "clear-dont-throttle-netmasks [N...]\n"
           "                                 remove netmasks that are not allowed to be throttled. If N is '*', remove all\n"
@@ -2106,6 +2107,9 @@ RecursorControlChannel::Answer RecursorControlParser::getAnswer(int socket, cons
   if (cmd == "dump-cache") {
     return doDumpCache(socket, begin, end);
   }
+  if (cmd == "clear-cookies") {
+    return {0, clearCookies()};
+  }
   if (cmd == "dump-cookies") {
     return doDumpToFile(socket, pleaseDumpCookiesMap, cmd, false);
   }
index f18546176ddaf69507ce4c0d71c2e53186702eb5..b4f4d04bed66bcc62bdd54d3de6543a723fa87ce 100644 (file)
@@ -5471,6 +5471,7 @@ bool SyncRes::doResolveAtThisIP(const std::string& prefix, const DNSName& qname,
     }
     auto match = d_eventTrace.add(RecEventTrace::AuthRequest, qname.toLogString() + '/' + qtype.toString(), true, 0);
     updateQueryCounts(prefix, qname, remoteIP, doTCP, doDoT);
+    cerr << "doTCP " << doTCP << endl;
     resolveret = asyncresolveWrapper(remoteIP, d_doDNSSEC, qname, auth, qtype.getCode(),
                                      doTCP, sendRDQuery, &d_now, ednsmask, &lwr, &chained, nsName); // <- we go out on the wire!
     d_eventTrace.add(RecEventTrace::AuthRequest, static_cast<int64_t>(lwr.d_rcode), false, match);
@@ -5996,6 +5997,7 @@ int SyncRes::doResolveAt(NsSet& nameservers, DNSName auth, bool flawedNSSet, con
             gotAnswer = doResolveAtThisIP(prefix, qname, qtype, lwr, ednsmask, auth, sendRDQuery, wasForwarded,
                                           tns->first, *remoteIP, false, false, truncated, spoofed, context.extendedError);
           }
+          cerr << "Got spoofed?!" << spoofed << endl;
           if (forceTCP || (spoofed || (gotAnswer && truncated))) {
             /* retry, over TCP this time */
             gotAnswer = doResolveAtThisIP(prefix, qname, qtype, lwr, ednsmask, auth, sendRDQuery, wasForwarded,
index ef2b265eaecc5201a783be50959e7421aa00e990..3b4d3c71e065645e0e9153333668a007ff93b569 100644 (file)
@@ -733,6 +733,16 @@ distributor-threads={threads}
         except subprocess.CalledProcessError as e:
             raise AssertionError('%s failed (%d): %s' % (rec_controlCmd, e.returncode, e.output))
 
+    @classmethod
+    def recControl(cls, confdir, *command):
+        rec_controlCmd = [os.environ['RECCONTROL'],
+                          '--config-dir=%s' % confdir
+                          ] + list(command)
+        try:
+            return subprocess.check_output(rec_controlCmd, text=True, stderr=subprocess.STDOUT)
+        except subprocess.CalledProcessError as e:
+            raise AssertionError('%s failed (%d): %s' % (rec_controlCmd, e.returncode, e.output))
+
     @classmethod
     def setUpSockets(cls):
         print("Setting up UDP socket..")
index 9f4dab033bd3780ffecd3f6c374aa1db64337e04..fcff612dea2b6327e916d178c8d757031dabcfe1 100644 (file)
@@ -9,3 +9,4 @@ pysnmp>=5,<6
 requests>=2.1.0
 Twisted>0.15.0
 pyyaml==6.0.1
+siphash
diff --git a/regression-tests.recursor-dnssec/test_Cookies.py b/regression-tests.recursor-dnssec/test_Cookies.py
new file mode 100644 (file)
index 0000000..857557a
--- /dev/null
@@ -0,0 +1,212 @@
+import dns
+import socket
+import os
+import time
+import threading
+from twisted.internet.protocol import Factory
+from twisted.internet.protocol import Protocol
+from twisted.internet.protocol import DatagramProtocol
+from twisted.internet import reactor
+
+from recursortests import RecursorTest
+
+class CookiesTest(RecursorTest):
+    _confdir = 'Cookies'
+
+    _config_template = """
+recursor:
+  forward_zones:
+  - zone: cookies.example
+    forwarders: [%s.25]
+outgoing:
+  cookies: true""" %  (os.environ['PREFIX'])
+
+    _expectedCookies = 'no'
+    @classmethod
+    def generateRecursorConfig(cls, confdir):
+        super(CookiesTest, cls).generateRecursorYamlConfig(confdir)
+
+    @classmethod
+    def setUpClass(cls):
+        cls.setUpSockets()
+
+        cls.startResponders()
+
+        confdir = os.path.join('configs', cls._confdir)
+        cls.createConfigDir(confdir)
+
+        cls.generateRecursorConfig(confdir)
+        cls.startRecursor(confdir, cls._recursorPort)
+
+        print("Launching tests..")
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.tearDownRecursor()
+
+    @classmethod
+    def startResponders(cls):
+        print("Launching responders..")
+
+        address = cls._PREFIX + '.25'
+        port = 53
+
+        reactor.listenUDP(port, UDPResponder(), interface=address)
+        reactor.listenTCP(port, TCPFactory(), interface=address)
+
+        if not reactor.running:
+            cls.Responder = threading.Thread(name='Responder', target=reactor.run, args=(False,))
+            cls.Responder.daemon = True
+            cls.Responder.start()
+            #cls._TCPResponder = threading.Thread(name='TCP Responder', target=reactor.run, args=(False,))
+            #cls._TCPResponder.daemon = True
+            #cls._TCPResponder.start()
+
+    def checkCookies(self, support):
+        confdir = os.path.join('configs', self._confdir)
+        output = self.recControl(confdir, 'dump-cookies', '-')
+        for line in output.splitlines():
+            tokens = line.split()
+            if tokens[0] != '127.0.0.25':
+                continue
+            print(tokens)
+            self.assertEqual(len(tokens), 5)
+            self.assertEqual(tokens[3], support)
+
+    def testAuthDoesnotSendCookies(self):
+        confdir = os.path.join('configs', self._confdir)
+        # Case: rec does not get a cookie back
+        expected = dns.rrset.from_text('a.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+        query = dns.message.make_query('a.cookies.example.', 'A')
+        res = self.sendUDPQuery(query)
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expected)
+        self.checkCookies('no')
+
+    def testAuthRepliesWithCookies(self):
+        confdir = os.path.join('configs', self._confdir)
+        # Case: rec gets a proper client and server cookie back
+        self.recControl(confdir, 'clear-cookies')
+        query = dns.message.make_query('b.cookies.example.', 'A')
+        expected = dns.rrset.from_text('b.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+        res = self.sendUDPQuery(query)
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expected)
+        self.checkCookies('yes')
+
+        # Case: we get a an correct client and server cookie back
+        # We do not clear the cookie tables, so the old server cookie gets re-used
+        query = dns.message.make_query('c.cookies.example.', 'A')
+        expected = dns.rrset.from_text('c.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+        res = self.sendUDPQuery(query)
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expected)
+        self.checkCookies('yes')
+
+    def testAuthSendsIncorrectClientCookie(self):
+        confdir = os.path.join('configs', self._confdir)
+        # Case: rec gets a an incorrect client cookie back
+        # Fails at the moment, as we do not do the right thing yet server side XXXX
+        self.recControl(confdir, 'clear-cookies')
+        query = dns.message.make_query('d.cookies.example.', 'A')
+        expected = dns.rrset.from_text('d.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+        res = self.sendUDPQuery(query)
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expected)
+        self.checkCookies('yes')
+
+    def testAuthSendsBADCOOKIEOverUDP(self):
+        confdir = os.path.join('configs', self._confdir)
+        # Case: rec gets a BADCOOKIE, even on retry and should fall back to TCP
+        self.recControl(confdir, 'clear-cookies')
+        query = dns.message.make_query('e.cookies.example.', 'A')
+        expected = dns.rrset.from_text('e.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+        res = self.sendUDPQuery(query)
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expected)
+        self.checkCookies('yes')
+
+
+class UDPResponder(DatagramProtocol):
+    def getCookie(self, message):
+        for option in message.options:
+            if option.otype == dns.edns.COOKIE: #and isinstance(option, dns.edns.CookieOption):
+                return option.data
+        return None
+
+    def createCookie(self, clientcookie):
+        clientcookie = clientcookie[0:8]
+        timestamp = int(time.time())
+        server = clientcookie + b'\x01\x00\x00\x00' + timestamp.to_bytes(4, 'big')
+        h = hash(server +  b'\x01\x00\x00\x7f' + b'secret') % pow(2, 64)
+        full = dns.edns.GenericOption(dns.edns.COOKIE, server + h.to_bytes(8, 'big'))
+        return full
+
+    def question(self, datagram, tcp=False):
+        request = dns.message.from_wire(datagram)
+
+        response = dns.message.make_response(request)
+        response.flags = dns.flags.AA + dns.flags.QR
+
+        question = request.question[0]
+
+        # Case: do not send cookie back
+        if question.name == dns.name.from_text('a.cookies.example.') and question.rdtype == dns.rdatatype.A:
+            answer = dns.rrset.from_text('a.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+            response.answer.append(answer)
+
+        # Case: do send cookie back
+        elif question.name == dns.name.from_text('b.cookies.example.') and question.rdtype == dns.rdatatype.A:
+            answer = dns.rrset.from_text('b.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+            clientcookie = self.getCookie(request)
+            if clientcookie is not None:
+                response.use_edns(options = [self.createCookie(clientcookie)])
+            response.answer.append(answer)
+
+        # We get a good client and server cookie
+        elif question.name == dns.name.from_text('c.cookies.example.') and question.rdtype == dns.rdatatype.A:
+            answer = dns.rrset.from_text('c.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+            clientcookie = self.getCookie(request)
+            if len(clientcookie) != 24:
+                raise AssertionError("expected full cookie, got len " + str(len(clientcookie)))
+            if clientcookie is not None:
+                response.use_edns(options = [self.createCookie(clientcookie)])
+            response.answer.append(answer)
+
+        # Case: do send incorrect client cookie back
+        elif question.name == dns.name.from_text('d.cookies.example.') and question.rdtype == dns.rdatatype.A:
+            answer = dns.rrset.from_text('d.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+            clientcookie = self.getCookie(request)
+            if clientcookie is not None:
+                mod = bytearray(clientcookie)
+                mod[0] = 1
+                response.use_edns(options = [self.createCookie(bytes(mod))])
+            response.answer.append(answer)
+
+        # Case: do send BADCOOKIE cookie back if UDP
+        elif question.name == dns.name.from_text('e.cookies.example.') and question.rdtype == dns.rdatatype.A:
+            answer = dns.rrset.from_text('e.cookies.example.', 15, dns.rdataclass.IN, 'A', '127.0.0.1')
+            clientcookie = self.getCookie(request)
+            if clientcookie is not None:
+                response.use_edns(options = [self.createCookie(clientcookie)])
+                if not tcp:
+                    response.set_rcode(23) # BADCOOKIE
+            response.answer.append(answer)
+
+        return response.to_wire()
+
+    def datagramReceived(self, datagram, address):
+        response = self.question(datagram)
+        self.transport.write(response, address)
+
+class TCPResponder(Protocol):
+    def dataReceived(self, data):
+        handler = UDPResponder()
+        response = handler.question(data[2:], True)
+        length = len(response)
+        header = length.to_bytes(2, 'big')
+        self.transport.write(header + response)
+
+class TCPFactory(Factory):
+    def buildProtocol(self, addr):
+        return TCPResponder()
diff --git a/regression-tests.recursor-dnssec/test_SimpleCookies.py b/regression-tests.recursor-dnssec/test_SimpleCookies.py
new file mode 100644 (file)
index 0000000..d6a7ccb
--- /dev/null
@@ -0,0 +1,142 @@
+import dns
+import os
+from recursortests import RecursorTest
+
+class SimpleCookiesTest(RecursorTest):
+    _confdir = 'SimpleCookies'
+
+    _config_template = """
+recursor:
+  auth_zones:
+  - zone: authzone.example
+    file: configs/%s/authzone.zone
+dnssec:
+  validation: validate
+outgoing:
+  cookies: true""" % _confdir
+
+    _expectedCookies = 'no'
+    @classmethod
+    def generateRecursorConfig(cls, confdir):
+        authzonepath = os.path.join(confdir, 'authzone.zone')
+        with open(authzonepath, 'w') as authzone:
+            authzone.write("""$ORIGIN authzone.example.
+@ 3600 IN SOA {soa}
+@ 3600 IN A 192.0.2.88
+""".format(soa=cls._SOA))
+        super(SimpleCookiesTest, cls).generateRecursorYamlConfig(confdir)
+
+    def checkCookies(self):
+        confdir = os.path.join('configs', self._confdir)
+        output = self.recControl(confdir, 'dump-cookies', '-')
+        for line in output.splitlines():
+            tokens = line.split()
+            if tokens[0] == ';' or tokens[0] == 'dump-cookies:':
+                continue
+            print(tokens)
+            self.assertEqual(len(tokens), 5)
+            self.assertEqual(tokens[3], self._expectedCookies)
+
+    def testSOAs(self):
+        for zone in ['.', 'example.', 'secure.example.']:
+            expected = dns.rrset.from_text(zone, 0, dns.rdataclass.IN, 'SOA', self._SOA)
+            query = dns.message.make_query(zone, 'SOA', want_dnssec=True)
+            query.flags |= dns.flags.AD
+
+            res = self.sendUDPQuery(query)
+
+            self.assertMessageIsAuthenticated(res)
+            self.assertRRsetInAnswer(res, expected)
+            self.assertMatchingRRSIGInAnswer(res, expected)
+        self.checkCookies()
+
+    def testA(self):
+        expected = dns.rrset.from_text('ns.secure.example.', 0, dns.rdataclass.IN, 'A', '{prefix}.9'.format(prefix=self._PREFIX))
+        query = dns.message.make_query('ns.secure.example', 'A', want_dnssec=True)
+        query.flags |= dns.flags.AD
+
+        res = self.sendUDPQuery(query)
+
+        self.assertMessageIsAuthenticated(res)
+        self.assertRRsetInAnswer(res, expected)
+        self.assertMatchingRRSIGInAnswer(res, expected)
+        self.checkCookies()
+
+    def testDelegation(self):
+        query = dns.message.make_query('example', 'NS', want_dnssec=True)
+        query.flags |= dns.flags.AD
+
+        expectedNS = dns.rrset.from_text('example.', 0, 'IN', 'NS', 'ns1.example.', 'ns2.example.')
+
+        res = self.sendUDPQuery(query)
+
+        self.assertMessageIsAuthenticated(res)
+        self.assertRRsetInAnswer(res, expectedNS)
+        self.checkCookies()
+
+    def testBogus(self):
+        query = dns.message.make_query('ted.bogus.example', 'A', want_dnssec=True)
+
+        res = self.sendUDPQuery(query)
+
+        self.assertRcodeEqual(res, dns.rcode.SERVFAIL)
+        self.checkCookies()
+
+    def testAuthZone(self):
+        query = dns.message.make_query('authzone.example', 'A', want_dnssec=True)
+
+        expectedA = dns.rrset.from_text('authzone.example.', 0, 'IN', 'A', '192.0.2.88')
+
+        res = self.sendUDPQuery(query)
+
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expectedA)
+        self.checkCookies()
+
+    def testLocalhost(self):
+        queryA = dns.message.make_query('localhost', 'A', want_dnssec=True)
+        expectedA = dns.rrset.from_text('localhost.', 0, 'IN', 'A', '127.0.0.1')
+
+        queryPTR = dns.message.make_query('1.0.0.127.in-addr.arpa', 'PTR', want_dnssec=True)
+        expectedPTR = dns.rrset.from_text('1.0.0.127.in-addr.arpa.', 0, 'IN', 'PTR', 'localhost.')
+
+        resA = self.sendUDPQuery(queryA)
+        resPTR = self.sendUDPQuery(queryPTR)
+
+        self.assertRcodeEqual(resA, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(resA, expectedA)
+
+        self.assertRcodeEqual(resPTR, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(resPTR, expectedPTR)
+        self.checkCookies()
+
+    def testLocalhostSubdomain(self):
+        queryA = dns.message.make_query('foo.localhost', 'A', want_dnssec=True)
+        expectedA = dns.rrset.from_text('foo.localhost.', 0, 'IN', 'A', '127.0.0.1')
+
+        resA = self.sendUDPQuery(queryA)
+
+        self.assertRcodeEqual(resA, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(resA, expectedA)
+        self.checkCookies()
+
+    def testIslandOfSecurity(self):
+        query = dns.message.make_query('cname-to-islandofsecurity.secure.example.', 'A', want_dnssec=True)
+
+        expectedCNAME = dns.rrset.from_text('cname-to-islandofsecurity.secure.example.', 0, 'IN', 'CNAME', 'node1.islandofsecurity.example.')
+        expectedA = dns.rrset.from_text('node1.islandofsecurity.example.', 0, 'IN', 'A', '192.0.2.20')
+
+        res = self.sendUDPQuery(query)
+
+        self.assertRcodeEqual(res, dns.rcode.NOERROR)
+        self.assertRRsetInAnswer(res, expectedA)
+        self.checkCookies()
+
+class SimpleCookiesAuthEnabledTest(SimpleCookiesTest):
+    _confdir = 'SimpleCookiesAuthEnabled'
+    _expectedCookies = 'yes'
+
+    @classmethod
+    def generateAuthConfig(cls, confdir, threads):
+        super(SimpleCookiesAuthEnabledTest, cls).generateAuthConfig(confdir, threads, "edns-cookie-secret=01234567890123456789012345678901")
+