Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/docs/api/ProxyAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ dispatcher is accepted, for example with [`request`][] or [`fetch`][], or
installed globally with [`setGlobalDispatcher`][].

For secure (`https:`) endpoints the proxy connection is established through an
HTTP `CONNECT` tunnel. SOCKS5 proxies (`socks5:` or `socks:` URIs) are handled
by delegating to a [`Socks5ProxyAgent`][].
HTTP `CONNECT` tunnel. SOCKS5 proxies (`socks5:`, `socks5h:`, or `socks:`
URIs) are handled by delegating to a [`Socks5ProxyAgent`][].

```mjs
import { ProxyAgent, setGlobalDispatcher } from 'undici'
Expand Down
13 changes: 8 additions & 5 deletions docs/docs/api/Socks5ProxyAgent.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ added: v7.23.0

> Stability: 1 - Experimental

* `proxyUrl` {string|URL} The SOCKS5 proxy server URL. Must use the `socks5:` or
`socks:` protocol. Credentials may be embedded in the URL userinfo (for
example `socks5://user:pass@host:1080`).
* `proxyUrl` {string|URL} The SOCKS5 proxy server URL. Must use the `socks5:`,
`socks5h:`, or `socks:` protocol. All three behave the same: target host
names are always resolved by the proxy, which is what `socks5h:` means.
Credentials may be embedded in the URL userinfo (for example
`socks5://user:pass@host:1080`).
* `options` {Object} (optional) Extends {PoolOptions}; the pool options are
applied to the per-origin pools created behind the tunnel.
* `headers` {Object} Additional headers to send with the proxy connection.
Expand All @@ -74,8 +76,9 @@ added: v7.23.0
host name.

Throws an `InvalidArgumentError` if `proxyUrl` is missing or does not use the
`socks5:` or `socks:` protocol, or if `connectTimeout`, `proxyTls.timeout`, or
`requestTls.timeout` is not a finite, non-negative number.
`socks5:`, `socks5h:`, or `socks:` protocol, or if `connectTimeout`,
`proxyTls.timeout`, or `requestTls.timeout` is not a finite, non-negative
number.

```mjs
import { Socks5ProxyAgent } from 'undici'
Expand Down
16 changes: 16 additions & 0 deletions lib/core/socks5-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ const { Buffer } = require('node:buffer')
const net = require('node:net')
const { InvalidArgumentError } = require('./errors')

/**
* Check whether a proxy URL protocol selects the SOCKS5 proxy agent.
*
* `socks5h:` conventionally means the proxy resolves target host names, while
* `socks5:` resolves them locally. Undici never resolves target host names
* locally: they are always sent to the proxy as domain names (see
* parseAddress), so `socks5:`, `socks5h:` and `socks:` all behave like
* `socks5h:`.
* @param {string} protocol - URL protocol, including the trailing colon
* @returns {boolean}
*/
function isSocks5Protocol (protocol) {
return protocol === 'socks5:' || protocol === 'socks5h:' || protocol === 'socks:'
}

/**
* Parse an address and determine its type
* @param {string} address - The address to parse
Expand Down Expand Up @@ -204,6 +219,7 @@ function createReplyError (replyCode) {
}

module.exports = {
isSocks5Protocol,
parseAddress,
parseIPv6,
buildAddressBuffer,
Expand Down
5 changes: 3 additions & 2 deletions lib/dispatcher/proxy-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const Client = require('./client')
const { channels } = require('../core/diagnostics')
const Socks5ProxyAgent = require('./socks5-proxy-agent')
const { hasSafeIterator } = require('../core/util')
const { isSocks5Protocol } = require('../core/socks5-utils')

const kAgent = Symbol('proxy agent')
const kClient = Symbol('proxy client')
Expand Down Expand Up @@ -154,7 +155,7 @@ class ProxyAgent extends DispatcherBase {
const { protocol } = new URL(origin)

// Handle SOCKS5 proxy
if (this[kProxy].protocol === 'socks5:' || this[kProxy].protocol === 'socks:') {
if (isSocks5Protocol(this[kProxy].protocol)) {
return new Socks5ProxyAgent(this[kProxy].uri, {
headers: this[kProxyHeaders],
connect,
Expand Down Expand Up @@ -191,7 +192,7 @@ class ProxyAgent extends DispatcherBase {

// For SOCKS5 proxies, we don't need a client to the proxy itself
// The SOCKS5 connection is handled within Socks5ProxyAgent
if (protocol === 'socks5:' || protocol === 'socks:') {
if (isSocks5Protocol(protocol)) {
this[kClient] = null
} else {
this[kClient] = clientFactory(url, { connect })
Expand Down
5 changes: 3 additions & 2 deletions lib/dispatcher/socks5-proxy-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { kBusy, kConnected, kDispatch, kClose, kDestroy } = require('../core/symb
const Pool = require('./pool')
const buildConnector = require('../core/connect')
const { setupConnectTimeout } = require('../core/util')
const { isSocks5Protocol } = require('../core/socks5-utils')
const { debuglog } = require('node:util')

const debug = debuglog('undici:socks5-proxy')
Expand Down Expand Up @@ -58,8 +59,8 @@ class Socks5ProxyAgent extends DispatcherBase {
// Parse proxy URL
const url = typeof proxyUrl === 'string' ? new URL(proxyUrl) : proxyUrl

if (url.protocol !== 'socks5:' && url.protocol !== 'socks:') {
throw new InvalidArgumentError('Proxy URL must use socks5:// or socks:// protocol')
if (!isSocks5Protocol(url.protocol)) {
throw new InvalidArgumentError('Proxy URL must use socks5://, socks5h:// or socks:// protocol')
}

this[kProxyUrl] = url
Expand Down
51 changes: 49 additions & 2 deletions test/socks5-proxy-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const https = require('node:https')
const { once } = require('node:events')
const { tspl } = require('@matteo.collina/tspl')
const { test, after } = require('node:test')
const { request, ProxyAgent } = require('..')
const { request, ProxyAgent, EnvHttpProxyAgent } = require('..')
const { ConnectTimeoutError, InvalidArgumentError } = require('../lib/core/errors')
const Socks5ProxyAgent = require('../lib/dispatcher/socks5-proxy-agent')
const { createServer } = require('node:http')
Expand Down Expand Up @@ -101,7 +101,7 @@ const tlsCerts = (() => {
})()

test('Socks5ProxyAgent - constructor validation', async (t) => {
const p = tspl(t, { plan: 4 })
const p = tspl(t, { plan: 5 })

p.throws(() => {
// eslint-disable-next-line no-new
Expand All @@ -123,6 +123,11 @@ test('Socks5ProxyAgent - constructor validation', async (t) => {
new Socks5ProxyAgent('socks://localhost:1080')
}, 'should accept socks:// URLs for compatibility')

p.doesNotThrow(() => {
// eslint-disable-next-line no-new
new Socks5ProxyAgent('socks5h://localhost:1080')
}, 'should accept socks5h:// URLs')

await p.completed
})

Expand Down Expand Up @@ -797,3 +802,45 @@ test('ProxyAgent forwards requestTls to Socks5ProxyAgent (GHSA-vmh5-mc38-953g)',

await p.completed
})

// socks5h: means the proxy resolves target host names. Undici always sends
// host names to the proxy, so socks5h: is accepted and behaves like socks5:.
for (const [name, createDispatcher] of [
['Socks5ProxyAgent', (uri) => new Socks5ProxyAgent(uri)],
['ProxyAgent', (uri) => new ProxyAgent(uri)],
['EnvHttpProxyAgent', (uri) => new EnvHttpProxyAgent({ httpProxy: uri })]
]) {
test(`${name} - socks5h:// proxy sends the target host name to the proxy`, async (t) => {
const p = tspl(t, { plan: 3 })

const server = createServer((req, res) => {
res.end('ok')
})
await new Promise((resolve) => server.listen(0, resolve))

const targets = []
const socksServer = new TestSocks5Server()
const handleConnect = socksServer.handleConnect.bind(socksServer)
socksServer.handleConnect = (socket, buffer, callback) => {
// CONNECT request: VER CMD RSV ATYP LEN DOMAIN PORT, ATYP 0x03 = domain name
if (buffer[3] === 0x03 && buffer.length >= 5 + buffer[4] + 2) {
targets.push(buffer.subarray(5, 5 + buffer[4]).toString())
}
return handleConnect(socket, buffer, callback)
}
const socksAddress = await socksServer.listen()

const dispatcher = createDispatcher(`socks5h://127.0.0.1:${socksAddress.port}`)

after(() => dispatcher.close())
after(() => socksServer.close())
after(() => server.close())

const response = await request(`http://localhost:${server.address().port}/`, { dispatcher })
p.equal(response.statusCode, 200)
p.equal(await response.body.text(), 'ok')
p.deepStrictEqual(targets, ['localhost'])

await p.completed
})
}