Socks5ProxyAgent: SOCKS5 handshake/auth timeout leaks the underlying socket
Nobody has claimed this yet.
- Dominant language
- JavaScript
- Stars
- 7.7k
- Forks
- 879
- Avg merge
- 2d 16h
- Merged PRs (30d)
- 68
Description
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
mainas of writing) - Node.js: v22.23.1
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read lib/dispatcher/socks5-proxy-agent.js and inspect createSocks5Connection(), focusing on both timeout callbacks and the existing error-handling cleanup. Run the reproduction against a proxy that accepts the connection but does not reply, then verify that the rejected request leaves no open socket and that agent.close() or agent.destroy() completes with the socket destroyed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, node.js
- Domain
- networking
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100