diff --git a/docs/docs/api/Socks5ProxyAgent.md b/docs/docs/api/Socks5ProxyAgent.md index ef6fc635a8f..3ef0f852f27 100644 --- a/docs/docs/api/Socks5ProxyAgent.md +++ b/docs/docs/api/Socks5ProxyAgent.md @@ -22,6 +22,7 @@ Extends: [`PoolOptions`](/docs/docs/api/Pool.md#parameter-pooloptions) * **password** `string` (optional) - SOCKS5 proxy password for authentication. Can also be provided in the proxy URL. * **connect** `Function` (optional) - Custom connector function for the proxy connection. * **proxyTls** `BuildOptions` (optional) - TLS options for the proxy connection (when using SOCKS5 over TLS). +* **requestTls** `BuildOptions` (optional) - TLS options applied to the HTTPS connection to the target server through the SOCKS5 tunnel. Use this to configure `ca`, `cert`, `key`, `rejectUnauthorized`, `servername`, etc. for the target HTTPS endpoint. Examples: diff --git a/lib/core/socks5-client.js b/lib/core/socks5-client.js index 9f3ed88b5aa..90f6d0a680f 100644 --- a/lib/core/socks5-client.js +++ b/lib/core/socks5-client.js @@ -52,6 +52,7 @@ const STATES = { INITIAL: 'initial', HANDSHAKING: 'handshaking', AUTHENTICATING: 'authenticating', + AUTHENTICATED: 'authenticated', CONNECTING: 'connecting', CONNECTED: 'connected', ERROR: 'error', @@ -143,6 +144,11 @@ class Socks5Client extends EventEmitter { } } + markAuthenticated () { + this.state = STATES.AUTHENTICATED + this.emit('authenticated') + } + /** * Start the SOCKS5 handshake */ @@ -193,7 +199,7 @@ class Socks5Client extends EventEmitter { debug('server selected auth method', method) if (method === AUTH_METHODS.NO_AUTH) { - this.emit('authenticated') + this.markAuthenticated() } else if (method === AUTH_METHODS.USERNAME_PASSWORD) { this.state = STATES.AUTHENTICATING this.sendAuthRequest() @@ -258,7 +264,7 @@ class Socks5Client extends EventEmitter { this.buffer = this.buffer.subarray(2) debug('authentication successful') - this.emit('authenticated') + this.markAuthenticated() } /** @@ -267,8 +273,12 @@ class Socks5Client extends EventEmitter { * @param {number} port - Target port */ connect (address, port) { - if (this.state === STATES.CONNECTED) { - throw new InvalidArgumentError('Already connected') + if (this.state === STATES.CONNECTING || this.state === STATES.CONNECTED) { + throw new InvalidArgumentError('Connection already in progress') + } + + if (this.state !== STATES.AUTHENTICATED) { + throw new InvalidArgumentError('Client must be authenticated before CONNECT') } debug('connecting to', address, port) diff --git a/lib/core/socks5-utils.js b/lib/core/socks5-utils.js index cff6763c846..e6098bef05f 100644 --- a/lib/core/socks5-utils.js +++ b/lib/core/socks5-utils.js @@ -46,12 +46,26 @@ function parseAddress (address) { */ function parseIPv6 (address) { const buffer = Buffer.alloc(16) + let normalizedAddress = address + + // Expand an embedded IPv4 tail into the last two IPv6 groups. + if (address.includes('.')) { + const lastColonIndex = address.lastIndexOf(':') + const ipv4Part = address.slice(lastColonIndex + 1) + + if (net.isIPv4(ipv4Part)) { + const octets = ipv4Part.split('.').map(Number) + const high = ((octets[0] << 8) | octets[1]).toString(16) + const low = ((octets[2] << 8) | octets[3]).toString(16) + normalizedAddress = `${address.slice(0, lastColonIndex)}:${high}:${low}` + } + } // Handle compressed notation (::) - const doubleColonIndex = address.indexOf('::') + const doubleColonIndex = normalizedAddress.indexOf('::') if (doubleColonIndex !== -1) { - const before = address.slice(0, doubleColonIndex) - const after = address.slice(doubleColonIndex + 2) + const before = normalizedAddress.slice(0, doubleColonIndex) + const after = normalizedAddress.slice(doubleColonIndex + 2) const beforeParts = before === '' ? [] : before.split(':') const afterParts = after === '' ? [] : after.split(':') @@ -66,7 +80,7 @@ function parseIPv6 (address) { bufferIndex += 2 } } else { - const parts = address.split(':') + const parts = normalizedAddress.split(':') for (let i = 0; i < parts.length; i++) { buffer.writeUInt16BE(parseInt(parts[i], 16), i * 2) } diff --git a/lib/dispatcher/proxy-agent.js b/lib/dispatcher/proxy-agent.js index 1cf9cb51f4f..d2a2f23cbff 100644 --- a/lib/dispatcher/proxy-agent.js +++ b/lib/dispatcher/proxy-agent.js @@ -142,7 +142,8 @@ class ProxyAgent extends DispatcherBase { factory: agentFactory, username: opts.username || username, password: opts.password || password, - proxyTls: opts.proxyTls + proxyTls: opts.proxyTls, + requestTls: opts.requestTls }) } diff --git a/lib/dispatcher/socks5-proxy-agent.js b/lib/dispatcher/socks5-proxy-agent.js index a2dd2f428c7..544d3025ea9 100644 --- a/lib/dispatcher/socks5-proxy-agent.js +++ b/lib/dispatcher/socks5-proxy-agent.js @@ -1,12 +1,11 @@ 'use strict' -const net = require('node:net') const { URL } = require('node:url') let tls // include tls conditionally since it is not always available const DispatcherBase = require('./dispatcher-base') const { InvalidArgumentError } = require('../core/errors') -const { Socks5Client } = require('../core/socks5-client') +const { Socks5Client, STATES } = require('../core/socks5-client') const { kDispatch, kClose, kDestroy } = require('../core/symbols') const Pool = require('./pool') const buildConnector = require('../core/connect') @@ -17,8 +16,10 @@ const debug = debuglog('undici:socks5-proxy') const kProxyUrl = Symbol('proxy url') const kProxyHeaders = Symbol('proxy headers') const kProxyAuth = Symbol('proxy auth') -const kPool = Symbol('pool') +const kProxyProtocol = Symbol('proxy protocol') +const kPools = Symbol('pools') const kConnector = Symbol('connector') +const kRequestTls = Symbol('request tls settings') // Static flag to ensure warning is only emitted once per process let experimentalWarningEmitted = false @@ -52,6 +53,8 @@ class Socks5ProxyAgent extends DispatcherBase { this[kProxyUrl] = url this[kProxyHeaders] = options.headers || {} + this[kProxyProtocol] = options.proxyTls ? 'https:' : 'http:' + this[kRequestTls] = options.requestTls // Extract auth from URL or options this[kProxyAuth] = { @@ -65,8 +68,8 @@ class Socks5ProxyAgent extends DispatcherBase { servername: options.proxyTls?.servername || url.hostname }) - // Pool for the actual HTTP connections (with SOCKS5 tunnel connect function) - this[kPool] = null + // Pools for the actual HTTP connections (with SOCKS5 tunnel connect function), keyed by origin + this[kPools] = new Map() } /** @@ -80,23 +83,18 @@ class Socks5ProxyAgent extends DispatcherBase { // Connect to the SOCKS5 proxy const socket = await new Promise((resolve, reject) => { - const onConnect = () => { - socket.removeListener('error', onError) - resolve(socket) - } - - const onError = (err) => { - socket.removeListener('connect', onConnect) - reject(err) - } - - const socket = net.connect({ + this[kConnector]({ + hostname: proxyHost, host: proxyHost, - port: proxyPort + port: proxyPort, + protocol: this[kProxyProtocol] + }, (err, socket) => { + if (err) { + reject(err) + } else { + resolve(socket) + } }) - - socket.once('connect', onConnect) - socket.once('error', onError) }) // Create SOCKS5 client @@ -130,7 +128,7 @@ class Socks5ProxyAgent extends DispatcherBase { } // Check if already authenticated (for NO_AUTH method) - if (socks5Client.state === 'authenticated') { + if (socks5Client.state === STATES.AUTHENTICATED) { clearTimeout(timeout) resolve() } else { @@ -171,15 +169,17 @@ class Socks5ProxyAgent extends DispatcherBase { /** * Dispatch a request through the SOCKS5 proxy */ - async [kDispatch] (opts, handler) { + [kDispatch] (opts, handler) { const { origin } = opts debug('dispatching request to', origin, 'via SOCKS5') try { - // Create Pool with custom connect function if we don't have one yet - if (!this[kPool] || this[kPool].destroyed || this[kPool].closed) { - this[kPool] = new Pool(origin, { + const originKey = String(origin) + let pool = this[kPools].get(originKey) + // Create a Pool per origin so requests are not routed to the wrong host + if (!pool || pool.destroyed || pool.closed) { + pool = new Pool(origin, { pipelining: opts.pipelining, connections: opts.connections, connect: async (connectOpts, callback) => { @@ -201,9 +201,9 @@ class Socks5ProxyAgent extends DispatcherBase { } debug('upgrading to TLS') finalSocket = tls.connect({ + ...this[kRequestTls], socket, - servername: targetHost, - ...connectOpts.tls || {} + servername: this[kRequestTls]?.servername || targetHost }) await new Promise((resolve, reject) => { @@ -219,14 +219,19 @@ class Socks5ProxyAgent extends DispatcherBase { } } }) + this[kPools].set(originKey, pool) } - // Dispatch the request through the pool - return this[kPool][kDispatch](opts, handler) + // Dispatch the request through the per-origin pool + return pool[kDispatch](opts, handler) } catch (err) { debug('dispatch error:', err) - if (typeof handler.onError === 'function') { + if (typeof handler.onResponseError === 'function') { + handler.onResponseError(null, err) + return false + } else if (typeof handler.onError === 'function') { handler.onError(err) + return false } else { throw err } @@ -234,15 +239,21 @@ class Socks5ProxyAgent extends DispatcherBase { } async [kClose] () { - if (this[kPool]) { - await this[kPool].close() + const closePromises = [] + for (const pool of this[kPools].values()) { + closePromises.push(pool.close()) } + this[kPools].clear() + await Promise.all(closePromises) } async [kDestroy] (err) { - if (this[kPool]) { - await this[kPool].destroy(err) + const destroyPromises = [] + for (const pool of this[kPools].values()) { + destroyPromises.push(pool.destroy(err)) } + this[kPools].clear() + await Promise.all(destroyPromises) } } diff --git a/test/socks5-client.js b/test/socks5-client.js index 045eb7aa20a..e321d91135f 100644 --- a/test/socks5-client.js +++ b/test/socks5-client.js @@ -51,7 +51,7 @@ test('Socks5Client - handshake flow', async (t) => { p.equal(client.state, STATES.INITIAL, 'should start in INITIAL state') client.on('authenticated', () => { - p.equal(client.state, STATES.HANDSHAKING, 'should be in HANDSHAKING state after auth') + p.equal(client.state, STATES.AUTHENTICATED, 'should be in AUTHENTICATED state after auth') p.ok(true, 'should emit authenticated event') }) @@ -59,7 +59,7 @@ test('Socks5Client - handshake flow', async (t) => { // Wait for the authenticated event await new Promise((resolve) => { - if (client.state !== STATES.HANDSHAKING) { + if (client.state === STATES.AUTHENTICATED) { resolve() } else { client.once('authenticated', resolve) @@ -72,6 +72,37 @@ test('Socks5Client - handshake flow', async (t) => { await p.completed }) +test('Socks5Client - connect requires authenticated state', async (t) => { + const p = tspl(t, { plan: 2 }) + + class MockSocket { + constructor () { + this.destroyed = false + this.writes = [] + } + + on () {} + + write (chunk) { + this.writes.push(chunk) + } + + destroy () { + this.destroyed = true + } + } + + const socket = new MockSocket() + const client = new Socks5Client(socket) + + p.throws(() => { + client.connect('example.com', 80) + }, InvalidArgumentError, 'should reject connect before authentication') + p.equal(socket.writes.length, 0, 'should not write CONNECT before authentication') + + await p.completed +}) + test('Socks5Client - username/password authentication', async (t) => { const p = tspl(t, { plan: 7 }) diff --git a/test/socks5-proxy-agent.js b/test/socks5-proxy-agent.js index 85185922647..398970ef475 100644 --- a/test/socks5-proxy-agent.js +++ b/test/socks5-proxy-agent.js @@ -1,13 +1,53 @@ 'use strict' +const net = require('node:net') +const https = require('node:https') const { tspl } = require('@matteo.collina/tspl') -const { test } = require('node:test') -const { request } = require('..') +const { test, after } = require('node:test') +const { request, ProxyAgent } = require('..') const { InvalidArgumentError } = require('../lib/core/errors') const Socks5ProxyAgent = require('../lib/dispatcher/socks5-proxy-agent') const { createServer } = require('node:http') const { TestSocks5Server } = require('./fixtures/socks5-test-server') +const tlsCerts = (() => { + const forge = require('node-forge') + const createCert = (cn, issuer, keyLength = 2048) => { + const keys = forge.pki.rsa.generateKeyPair(keyLength) + const cert = forge.pki.createCertificate() + cert.publicKey = keys.publicKey + cert.serialNumber = '' + Date.now() + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10) + const attrs = [{ name: 'commonName', value: cn }] + cert.setSubject(attrs) + const isCa = issuer === undefined + cert.setExtensions([ + { name: 'basicConstraints', cA: isCa }, + { name: 'subjectAltName', altNames: [{ type: 2, value: cn }] } + ]) + const alg = forge.md.sha256.create() + if (issuer !== undefined) { + cert.setIssuer(issuer.certificate.subject.attributes) + cert.sign(issuer.privateKey, alg) + } else { + cert.setIssuer(attrs) + cert.sign(keys.privateKey, alg) + } + return { privateKey: keys.privateKey, publicKey: keys.publicKey, certificate: cert } + } + const root = createCert('socks5-test-ca') + const server = createCert('localhost', root) + return { + root: { crt: forge.pki.certificateToPem(root.certificate) }, + server: { + key: forge.pki.privateKeyToPem(server.privateKey), + crt: forge.pki.certificateToPem(server.certificate) + } + } +})() + test('Socks5ProxyAgent - constructor validation', async (t) => { const p = tspl(t, { plan: 4 }) @@ -34,6 +74,77 @@ test('Socks5ProxyAgent - constructor validation', async (t) => { await p.completed }) +test('Socks5ProxyAgent - uses custom connector for proxy connection', async (t) => { + const p = tspl(t, { plan: 3 }) + + const server = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const serverPort = server.address().port + + const socksServer = new TestSocks5Server() + const socksAddress = await socksServer.listen() + let connectorCalled = false + + const proxyWrapper = new Socks5ProxyAgent(`socks5://localhost:${socksAddress.port}`, { + connect (opts, callback) { + connectorCalled = true + const socket = net.connect({ + host: opts.hostname, + port: opts.port + }) + socket.once('connect', () => callback(null, socket)) + socket.once('error', callback) + return socket + } + }) + + const response = await request(`http://localhost:${serverPort}/test`, { + dispatcher: proxyWrapper + }) + + p.equal(response.statusCode, 200, 'should get 200 status code') + p.deepEqual(await response.body.json(), { ok: true }, 'should get correct response body') + p.equal(connectorCalled, true, 'should use custom connector for proxy connection') + + t.after(async () => { + if (proxyWrapper) { + await proxyWrapper.close() + } + await socksServer.close() + server.close() + }) + + await p.completed +}) + +test('Socks5ProxyAgent - dispatch returns boolean backpressure signal', async (t) => { + const p = tspl(t, { plan: 1 }) + const proxyWrapper = new Socks5ProxyAgent('socks5://localhost:9999') + + const ret = proxyWrapper.dispatch({ + origin: 'http://example.com', + path: '/', + method: 'GET' + }, { + onRequestStart () {}, + onResponseStart () {}, + onResponseData () {}, + onResponseEnd () {}, + onResponseError () {} + }) + + p.equal(typeof ret, 'boolean') + + await proxyWrapper.destroy() + await p.completed +}) + test('Socks5ProxyAgent - basic HTTP connection', async (t) => { const p = tspl(t, { plan: 2 }) @@ -225,6 +336,48 @@ test('Socks5ProxyAgent - multiple requests through same proxy', async (t) => { await p.completed }) +test('Socks5ProxyAgent - requests to different origins are routed correctly', async (t) => { + const p = tspl(t, { plan: 4 }) + + // Create two distinct target servers + const serverA = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ server: 'A', path: req.url })) + }) + const serverB = createServer((req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ server: 'B', path: req.url })) + }) + + await new Promise((resolve) => serverA.listen(0, '127.0.0.1', resolve)) + await new Promise((resolve) => serverB.listen(0, '127.0.0.1', resolve)) + const portA = serverA.address().port + const portB = serverB.address().port + + const socksServer = new TestSocks5Server() + const socksAddress = await socksServer.listen() + + try { + const proxyWrapper = new Socks5ProxyAgent(`socks5://127.0.0.1:${socksAddress.port}`) + + // First request goes to server A — establishes a pool + const respA = await request(`http://127.0.0.1:${portA}/a`, { dispatcher: proxyWrapper }) + p.equal(respA.statusCode, 200) + p.deepEqual(await respA.body.json(), { server: 'A', path: '/a' }) + + // Second request goes to server B — must NOT reuse the pool from origin A + const respB = await request(`http://127.0.0.1:${portB}/b`, { dispatcher: proxyWrapper }) + p.equal(respB.statusCode, 200) + p.deepEqual(await respB.body.json(), { server: 'B', path: '/b' }, 'request to origin B must reach server B, not server A') + } finally { + await socksServer.close() + serverA.close() + serverB.close() + } + + await p.completed +}) + test('Socks5ProxyAgent - connection failure', async (t) => { const p = tspl(t, { plan: 1 }) @@ -318,3 +471,87 @@ test('Socks5ProxyAgent - URL parsing edge cases', async (t) => { await p.completed }) + +test('Socks5ProxyAgent - requestTls is honored for target HTTPS connection (GHSA-vmh5-mc38-953g)', async (t) => { + const p = tspl(t, { plan: 2 }) + + // HTTPS server with a cert signed by tlsCerts.root, NOT in Node's default trust store + const server = https.createServer({ + key: tlsCerts.server.key, + cert: tlsCerts.server.crt + }, (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const serverPort = server.address().port + + // SOCKS5 server that forwards CONNECT to the local HTTPS server + const socksServer = new TestSocks5Server() + const socksAddress = await socksServer.listen() + + // Use Socks5ProxyAgent directly with requestTls + const proxyAgent = new Socks5ProxyAgent(`socks5://127.0.0.1:${socksAddress.port}`, { + requestTls: { + ca: [tlsCerts.root.crt] + } + }) + + after(async () => { + await proxyAgent.close() + server.close() + await socksServer.close() + }) + + // The HTTPS request via SOCKS5 should succeed because requestTls.ca contains the root CA + // that signed the server cert. Without honoring requestTls, Node would default to Mozilla + // CA bundle and reject the cert. + const response = await request(`https://localhost:${serverPort}/`, { + dispatcher: proxyAgent + }) + p.strictEqual(response.statusCode, 200, 'request should succeed when requestTls.ca is honored') + const body = await response.body.json() + p.deepStrictEqual(body, { ok: true }) + + await p.completed +}) + +test('ProxyAgent forwards requestTls to Socks5ProxyAgent (GHSA-vmh5-mc38-953g)', async (t) => { + const p = tspl(t, { plan: 2 }) + + const server = https.createServer({ + key: tlsCerts.server.key, + cert: tlsCerts.server.crt + }, (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify({ ok: true })) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const serverPort = server.address().port + + const socksServer = new TestSocks5Server() + const socksAddress = await socksServer.listen() + + // Use top-level ProxyAgent with socks5 URI + requestTls; the option must be forwarded. + const proxyAgent = new ProxyAgent({ + uri: `socks5://127.0.0.1:${socksAddress.port}`, + requestTls: { + ca: [tlsCerts.root.crt] + } + }) + + after(async () => { + await proxyAgent.close() + server.close() + await socksServer.close() + }) + + const response = await request(`https://localhost:${serverPort}/`, { + dispatcher: proxyAgent + }) + p.strictEqual(response.statusCode, 200, 'request should succeed when ProxyAgent forwards requestTls') + const body = await response.body.json() + p.deepStrictEqual(body, { ok: true }) + + await p.completed +}) diff --git a/test/socks5-utils.js b/test/socks5-utils.js index c01070e573a..c8ef637d722 100644 --- a/test/socks5-utils.js +++ b/test/socks5-utils.js @@ -48,7 +48,7 @@ test('parseAddress - Domain', async (t) => { }) test('parseIPv6', async (t) => { - const p = tspl(t, { plan: 9 }) + const p = tspl(t, { plan: 10 }) // Test full IPv6 const buffer1 = parseIPv6('2001:0db8:0000:0042:0000:8a2e:0370:7334') @@ -74,6 +74,9 @@ test('parseIPv6', async (t) => { // Test trailing :: p.equal(parseIPv6('fe80::').toString('hex'), 'fe800000000000000000000000000000', 'should expand fe80:: correctly') + // Test IPv4-mapped IPv6 + p.equal(parseIPv6('::ffff:192.0.2.128').toString('hex'), '00000000000000000000ffffc0000280', 'should encode embedded IPv4 tail correctly') + await p.completed })