Bug description
Socks5ProxyAgent's internal SOCKS5 negotiation has two 5-second timeouts (waiting for the auth response and waiting for the CONNECT reply, in createSocks5Connection). When either fires, the code rejects the connection promise but never destroys the und
erlying socket. Because the socket is also never handed to the per-origin Pool (the connect callback that would register it with the pool is never reached), it's completely untracked afterward — agent.close() and agent.destroy() only iterate this[ kPools], so neither can reach it.
The result: a SOCKS5 proxy that accepts the TCP connection but stalls during negotiation (down/overloaded/misbehaving upstream, or a proxy that's simply slow) causes Socks5ProxyAgent to leak one open socket per attempt, for the lifetime of the process —
the socket is bounded by nothing on the client side (no idle timeout, no keepalive) and stays open until the remote peer closes it or the process exits.
Reproduction
const net = require('node:net')
const { Socks5ProxyAgent, request } = require('undici')
const sockets = []
const proxy = net.createServer((socket) => {
sockets.push(socket)
// Accept the TCP connection but never reply to the SOCKS5 greeting.
}).listen(0, '127.0.0.1', main)
async function main() {
const { port } = proxy.address()
const agent = new Socks5ProxyAgent(`socks5://127.0.0.1:${port}`)
await request('http://example.invalid/', { dispatcher: agent }).catch((err) => {
console.log('request rejected as expected:', err.message) // SOCKS5 authentication timeout
})
await agent.close()
console.log('proxy-side socket destroyed after close()?', sockets[0].destroyed) // false
proxy.close()
}
Expected behavior
After the request rejects with the timeout error and agent.close()/agent.destroy() resolves, the socket opened for the stalled negotiation should be destroyed.
Actual behavior
sockets[0].destroyed is false. The socket stays open and connected to the proxy indefinitely — nothing in Socks5ProxyAgent will ever close it.
Root cause
lib/dispatcher/socks5-proxy-agent.js, createSocks5Connection(). Both timeout branches:
const authenticationTimeout = setTimeout(() => {
authenticationReady.reject(new Error('SOCKS5 authentication timeout'))
}, 5000)
const connectionTimeout = setTimeout(() => {
connectionReady.reject(new Error('SOCKS5 connection timeout'))
}, 5000)
reject without calling socket.destroy() or socks5Client.destroy(). Compare to the success path, which does explicit listener cleanup — the timeout path was seemingly just missed. The error event path (socks5Client.on('error', ...)) does call socke t.destroy(), but a timer firing is not a socket error event, so that doesn't help here.
Downstream, [kDispatch]'s pool connect function catches the rejection from createSocks5Connection and calls callback(err) — but the socket was never passed to callback(null, socket), so it was never registered with the Pool either. Socks5Proxy Agent.close()/destroy() only iterate this[kPools], so there's no path back to this socket at all.
Suggested fix
In both timeout callbacks, destroy the socket (or call socks5Client.destroy(), which destroys the socket and marks it in the error state) before rejecting.
Versions
- undici: 7.28.0 (confirmed still present on
main as of writing)
- Node.js: v22.23.1
Bug description
Socks5ProxyAgent's internal SOCKS5 negotiation has two 5-second timeouts (waiting for the auth response and waiting for the CONNECT reply, increateSocks5Connection). When either fires, the code rejects the connection promise but never destroys the underlying socket. Because the socket is also never handed to the per-origin
Pool(theconnectcallback that would register it with the pool is never reached), it's completely untracked afterward —agent.close()andagent.destroy()only iteratethis[ kPools], so neither can reach it.The result: a SOCKS5 proxy that accepts the TCP connection but stalls during negotiation (down/overloaded/misbehaving upstream, or a proxy that's simply slow) causes
Socks5ProxyAgentto leak one open socket per attempt, for the lifetime of the process —the socket is bounded by nothing on the client side (no idle timeout, no keepalive) and stays open until the remote peer closes it or the process exits.
Reproduction
Expected behavior
After the request rejects with the timeout error and
agent.close()/agent.destroy()resolves, the socket opened for the stalled negotiation should be destroyed.Actual behavior
sockets[0].destroyedisfalse. The socket stays open and connected to the proxy indefinitely — nothing inSocks5ProxyAgentwill ever close it.Root cause
lib/dispatcher/socks5-proxy-agent.js,createSocks5Connection(). Both timeout branches:reject without calling
socket.destroy()orsocks5Client.destroy(). Compare to the success path, which does explicit listener cleanup — the timeout path was seemingly just missed. Theerrorevent path (socks5Client.on('error', ...)) does callsocke t.destroy(), but a timer firing is not a socketerrorevent, so that doesn't help here.Downstream,
[kDispatch]'s poolconnectfunction catches the rejection fromcreateSocks5Connectionand callscallback(err)— but the socket was never passed tocallback(null, socket), so it was never registered with thePooleither.Socks5Proxy Agent.close()/destroy()only iteratethis[kPools], so there's no path back to this socket at all.Suggested fix
In both timeout callbacks, destroy the socket (or call
socks5Client.destroy(), which destroys the socket and marks it in the error state) before rejecting.Versions
mainas of writing)