From 8aef9873fccbd99e66ec891ad8da0890ccfc5a05 Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Tue, 20 Oct 2026 15:18:02 +0530 Subject: [PATCH 1/3] bouncedcc: refuse DCC requests from IRC aimed at loopback or link-local The address in an incoming DCC CHAT/SEND is chosen by the remote IRC user, and ZNC is the side that connects to it, relaying traffic to whoever connects to the bounce listener. Refuse requests that point at 0/8, 127/8, 169.254/16, 224/3 or values above 32 bits (which CUtils::GetIP would otherwise mask down to one of those). --- modules/bouncedcc.cpp | 18 ++++++++++++++++++ test/integration/tests/modules.cpp | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/modules/bouncedcc.cpp b/modules/bouncedcc.cpp index cf0b5093..72a647df 100644 --- a/modules/bouncedcc.cpp +++ b/modules/bouncedcc.cpp @@ -163,6 +163,17 @@ class CBounceDCCMod : public CModule { bool UseClientIP() { return GetNV("UseClientIP").ToBool(); } + // The address in a DCC request from IRC is chosen by the remote peer, and + // ZNC is the one connecting to it, so don't let it point at ZNC's own + // host or at link-local services. + static bool IsAllowedPeerIP(unsigned long uLongIP) { + if (uLongIP > 0xFFFFFFFFUL) return false; + const unsigned long uFirst = uLongIP >> 24; + if (uFirst == 0 || uFirst == 127 || uFirst >= 224) return false; + if ((uLongIP >> 16) == 0xA9FE) return false; // 169.254.0.0/16 + return true; + } + EModRet OnUserCTCPMessage(CCTCPMessage& Message) override { CString sTarget = Message.GetTarget(); CString sMessage = Message.GetText(); @@ -255,6 +266,13 @@ class CBounceDCCMod : public CModule { unsigned long uFileSize = sMessage.Token(5, false, " ", false, "\"", "\"", true).ToULong(); + if ((sType.Equals("CHAT") || sType.Equals("SEND")) && + !IsAllowedPeerIP(uLongIP)) { + PutModule(t_f("DCC {1} from {2} refused: bad address {3}")( + sType, Nick.GetNick(), CString(uLongIP))); + return HALTCORE; + } + if (sType.Equals("CHAT")) { CNick FromNick(Nick.GetNickMask()); unsigned short uBNCPort = CDCCBounce::DCCRequest( diff --git a/test/integration/tests/modules.cpp b/test/integration/tests/modules.cpp index fe315901..c90efd86 100644 --- a/test/integration/tests/modules.cpp +++ b/test/integration/tests/modules.cpp @@ -765,6 +765,14 @@ TEST_F(ZNCTest, BounceDCCModule) { QByteArray line2; client.ReadUntilAndGet(":friend!user@host PRIVMSG nick :\001DCC CHAT chat", line2); EXPECT_THAT(line2.toStdString(), Not(HasSubstr("3232235521 54321"))); + + // Peer must not be able to make ZNC connect to its own loopback + ircd.Write(":friend!user@host PRIVMSG nick :\001DCC CHAT chat 2130706433 6379\001"); + client.ReadUntil("refused: bad address 2130706433"); + ircd.Write(":friend!user@host PRIVMSG nick :\001DCC SEND f 2852039166 80 1\001"); + client.ReadUntil("refused: bad address 2852039166"); + ircd.Write(":friend!user@host PRIVMSG nick :\001DCC CHAT chat 6425673729 22\001"); + client.ReadUntil("refused: bad address 6425673729"); } TEST_F(ZNCTest, ChanSaverModule) { From d5375e58f80d2ebc0d76f2de9f0271c3a3161c34 Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Fri, 23 Oct 2026 13:00:06 +0530 Subject: [PATCH 2/3] bouncedcc: only let the user's own clients connect to an IRC-originated bounce When the DCC request came from IRC, ZNC connects out to the remote peer and the thing connecting to the bounce listener should be one of the user's clients. In GetSockObj, compare the connecting address with the remote IP of each currently connected client and refuse anyone else, closing the accepted socket straight away while leaving the listener up for the real client. The user-originated path is unchanged, since there it is the remote IRC peer that is expected to connect. --- modules/bouncedcc.cpp | 25 +++++++++++++++++++++++++ test/integration/tests/modules.cpp | 9 +++++++++ 2 files changed, 34 insertions(+) diff --git a/modules/bouncedcc.cpp b/modules/bouncedcc.cpp index 72a647df..b11bdfce 100644 --- a/modules/bouncedcc.cpp +++ b/modules/bouncedcc.cpp @@ -163,6 +163,13 @@ class CBounceDCCMod : public CModule { bool UseClientIP() { return GetNV("UseClientIP").ToBool(); } + bool IsClientIP(const CString& sIP) { + for (CClient* pClient : GetUser()->GetAllClients()) { + if (pClient->GetRemoteIP().Equals(sIP)) return true; + } + return false; + } + // The address in a DCC request from IRC is chosen by the remote peer, and // ZNC is the one connecting to it, so don't let it point at ZNC's own // host or at link-local services. @@ -506,6 +513,24 @@ void CDCCBounce::Shutdown() { } Csock* CDCCBounce::GetSockObj(const CString& sHost, unsigned short uPort) { + // m_sRemoteIP is only known up front when the request came from IRC. In + // that case ZNC connects out to the remote peer itself, so the only thing + // that should be connecting here is one of the user's own clients. Hand + // Csocket a throwaway socket that is closed straight away and keep + // listening for the real client. + if (!m_sRemoteIP.empty() && !m_pModule->IsClientIP(sHost)) { + DEBUG(GetSockName() << " == GetSockObj(" << sHost << ") refused"); + CString sType = m_bIsChat ? t_s("Chat", "type") : t_s("Xfer", "type"); + m_pModule->PutModule( + t_f("DCC {1} Bounce ({2}): Refused connection from {3}, which is " + "not one of your clients")(sType, m_sRemoteNick, sHost)); + CDCCBounce* pRefused = + new CDCCBounce(m_pModule, sHost, uPort, m_sRemoteNick, m_sRemoteIP, + m_sFileName, m_bIsChat); + pRefused->Close(); + return pRefused; + } + Close(); if (m_sRemoteIP.empty()) { diff --git a/test/integration/tests/modules.cpp b/test/integration/tests/modules.cpp index c90efd86..c09b97b3 100644 --- a/test/integration/tests/modules.cpp +++ b/test/integration/tests/modules.cpp @@ -766,6 +766,15 @@ TEST_F(ZNCTest, BounceDCCModule) { client.ReadUntilAndGet(":friend!user@host PRIVMSG nick :\001DCC CHAT chat", line2); EXPECT_THAT(line2.toStdString(), Not(HasSubstr("3232235521 54321"))); + // Only the user's own clients may connect to that bounce port. The test + // client is on a unix socket, so a TCP connection from anywhere is refused. + QByteArray bouncePort = line2.mid(line2.lastIndexOf(' ') + 1); + bouncePort.chop(1); // trailing \001 + QTcpSocket outsider; + outsider.connectToHost("127.0.0.1", bouncePort.toUShort()); + outsider.waitForConnected(); + client.ReadUntil("Refused connection from 127.0.0.1"); + // Peer must not be able to make ZNC connect to its own loopback ircd.Write(":friend!user@host PRIVMSG nick :\001DCC CHAT chat 2130706433 6379\001"); client.ReadUntil("refused: bad address 2130706433"); From a9ec2fb9a4d924e52199e52a3a08e186cc25b94c Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Sat, 24 Oct 2026 11:57:02 +0530 Subject: [PATCH 3/3] bouncedcc: connect to the bounce port via localhost in the test The bounce listener binds to the IRC socket's local address. With the inttest unix sockets that is "localhost" on Linux, which Csocket resolves to ::1, so connecting to 127.0.0.1 never reached it. --- test/integration/tests/modules.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/integration/tests/modules.cpp b/test/integration/tests/modules.cpp index c09b97b3..8984f203 100644 --- a/test/integration/tests/modules.cpp +++ b/test/integration/tests/modules.cpp @@ -768,12 +768,14 @@ TEST_F(ZNCTest, BounceDCCModule) { // Only the user's own clients may connect to that bounce port. The test // client is on a unix socket, so a TCP connection from anywhere is refused. + // The listener binds to the IRC socket's local address, which for a unix + // socket is "localhost" on Linux and may resolve to ::1 only. QByteArray bouncePort = line2.mid(line2.lastIndexOf(' ') + 1); bouncePort.chop(1); // trailing \001 QTcpSocket outsider; - outsider.connectToHost("127.0.0.1", bouncePort.toUShort()); - outsider.waitForConnected(); - client.ReadUntil("Refused connection from 127.0.0.1"); + outsider.connectToHost("localhost", bouncePort.toUShort()); + ASSERT_TRUE(outsider.waitForConnected()); + client.ReadUntil("Refused connection from"); // Peer must not be able to make ZNC connect to its own loopback ircd.Write(":friend!user@host PRIVMSG nick :\001DCC CHAT chat 2130706433 6379\001");