Skip to content

Socks5ProxyAgent: SOCKS5 handshake/auth timeout leaks the underlying socket #5704

Description

@andrewmbenton

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions