From 69d574320fa6e5d8574235282b18241a4035c97f Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Sun, 8 Mar 2026 15:37:17 -0700 Subject: [PATCH 1/2] Update version to 3.4.0 and enhance README with compatAuth options - Bumped package version to 3.4.0. - Added `compatAuth` options to README, detailing behavior for empty credentials and strict method negotiation. - Included example code for `compatAuth` usage in the documentation. - Added tests for handling empty username and password scenarios with `compatAuth` functionality. --- package.json | 2 +- readme.md | 37 ++ src/socks5.js | 1076 ++++++++++++++++++++++++++----------------------- test/index.js | 101 +++++ 4 files changed, 701 insertions(+), 515 deletions(-) diff --git a/package.json b/package.json index e29b429..d72358c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simple-socks", - "version": "3.3.1", + "version": "3.4.0", "description": "proxies requests ", "type": "module", "main": "src/socks5.js", diff --git a/readme.md b/readme.md index af4d8c2..9fdf3c7 100644 --- a/readme.md +++ b/readme.md @@ -180,6 +180,7 @@ This method accepts an optional `options` argument: - `options.authentication` - A callback for authentication - `options.connectionFilter` - A callback for connection filtering - `options.idleTimeout` - Milliseconds of inactivity before destroying client/destination sockets (0 is disabled, default 0) +- `options.compatAuth` - Non-default RFC 1929 compatibility controls for empty credentials (defaults are strict) #### authentication callback @@ -211,6 +212,40 @@ The `authenticate` callback accepts three arguments: - socket - the socket for the client connection - callback - callback for authentication... if authentication is successful, the callback should be called with no arguments +#### compatAuth options + +`compatAuth` is optional and disabled by default. It only affects RFC 1929 username/password payload validation. + +```javascript +const server = socks5.createServer({ + authenticate(username, password, socket, callback) { + if (username === "foo" && password === "") { + return setImmediate(callback); + } + + return setImmediate(callback, new Error("bad credentials")); + }, + compatAuth: { + allowEmptyUsername: false, // default false + allowEmptyPassword: true, // default false + strictMethodNegotiation: true, // must remain true + }, +}); +``` + +Behavior: + +- `allowEmptyUsername` (default `false`): if true, allows `ULEN=0` and passes `""` to `authenticate`. +- `allowEmptyPassword` (default `false`): if true, allows `PLEN=0` and passes `""` to `authenticate`. +- `strictMethodNegotiation` (default `true`): keeps RFC 1928 method selection behavior. This library does not support forcing BASIC when a client did not advertise BASIC. + +`compatAuth` is not a server equivalent of client flags such as `curl --proxy-user`; proxy credentials are still sent (or not sent) by the client. + +- Compatibility fallback only, opt-in, private/trusted environments. +- Prefer proper RFC1929-capable clients instead. +- Treat username as a single token, avoid delimiter parsing when possible. +- Disable or tightly audit username logging. + #### connectionFilter callback Allows you to filter incoming connections, based on either origin and/or destination, return `false` to disallow: @@ -523,5 +558,7 @@ Some versions of the macOS built‑in SOCKS client (used when enabling a SOCKS p - The server selects BASIC only when the client advertises support for it. If `authenticate` is configured but the client does not offer BASIC, the server responds with “no acceptable methods” and closes. - If a client sends a zero‑length username or password during RFC 1929 authentication, the server rejects the authentication. +- `compatAuth.allowEmptyPassword` and/or `compatAuth.allowEmptyUsername` can relax that validation, but only after BASIC has already been selected. +- `compatAuth` cannot fix clients that never offer BASIC during method negotiation. If you require username/password auth from macOS clients, use a client that supports RFC 1929 (for example, `curl --socks5 --proxy-user`, or browsers/extensions that implement SOCKS5 BASIC). Alternatively, consider a different method such as GSSAPI/Negotiate on both client and server; the built‑in macOS client may favor that, but it is not implemented by this library. diff --git a/src/socks5.js b/src/socks5.js index 3673f50..b8929de 100644 --- a/src/socks5.js +++ b/src/socks5.js @@ -1,30 +1,30 @@ import { - RFC_1928_ATYP, - RFC_1928_COMMANDS, - RFC_1928_METHODS, - RFC_1928_REPLIES, - RFC_1928_VERSION, - RFC_1929_REPLIES, - RFC_1929_VERSION, -} from './constants.js'; - -import binary from 'binary'; -import domain from 'domain'; -import net from 'net'; + RFC_1928_ATYP, + RFC_1928_COMMANDS, + RFC_1928_METHODS, + RFC_1928_REPLIES, + RFC_1928_VERSION, + RFC_1929_REPLIES, + RFC_1929_VERSION, +} from "./constants.js"; + +import binary from "binary"; +import domain from "domain"; +import net from "net"; // module specific events const EVENTS = { - AUTHENTICATION: 'authenticate', - AUTHENTICATION_ERROR: 'authenticateError', - CONNECTION_FILTER: 'connectionFilter', - HANDSHAKE: 'handshake', - PROXY_CONNECT: 'proxyConnect', - PROXY_DATA: 'proxyData', - PROXY_DISCONNECT: 'proxyDisconnect', - PROXY_END: 'proxyEnd', - PROXY_ERROR: 'proxyError', - }, - LENGTH_RFC_1928_ATYP = 4; + AUTHENTICATION: "authenticate", + AUTHENTICATION_ERROR: "authenticateError", + CONNECTION_FILTER: "connectionFilter", + HANDSHAKE: "handshake", + PROXY_CONNECT: "proxyConnect", + PROXY_DATA: "proxyData", + PROXY_DISCONNECT: "proxyDisconnect", + PROXY_END: "proxyEnd", + PROXY_ERROR: "proxyError", + }, + LENGTH_RFC_1928_ATYP = 4; /** * The following RFCs may be useful as background: @@ -33,498 +33,546 @@ const EVENTS = { * https://www.ietf.org/rfc/rfc1929.txt - USERNAME/PASSWORD SOCKS5 */ class SocksServer { - constructor(options) { - const self = this; - - this.activeSessions = []; - this.options = options || {}; - this.idleTimeout = this.options.idleTimeout || 0; - this.server = net.createServer((socket) => { - socket.on('error', (err) => { - self.server.emit(EVENTS.PROXY_ERROR, err); - }); - - // configure idle timeout for client socket - if (self.idleTimeout && typeof socket.setTimeout === 'function') { - socket.setTimeout(self.idleTimeout, () => { - try { - socket.destroy(new Error('socket idle timeout')); - } catch { - // ignore socket destroy errors - } - }); - } - - // helper to safely remove from active sessions - function removeActiveSession() { - const idx = self.activeSessions.indexOf(socket); - if (idx !== -1) { - self.activeSessions.splice(idx, 1); - } - } - - /** - * +----+------+----------+------+----------+ - * |VER | ULEN | UNAME | PLEN | PASSWD | - * +----+------+----------+------+----------+ - * | 1 | 1 | 1 to 255 | 1 | 1 to 255 | - * +----+------+----------+------+----------+ - * - * @param {Buffer} buffer - a buffer - * @returns {undefined} - */ - function authenticate(buffer) { - const authDomain = domain.create(); - - binary - .stream(buffer) - .word8('ver') - .word8('ulen') - .buffer('uname', 'ulen') - .word8('plen') - .buffer('passwd', 'plen') - .tap((args) => { - // capture the raw buffer - args.requestBuffer = buffer; - - // verify version is appropriate - if (args.ver !== RFC_1929_VERSION) { - return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); - } - - // per RFC 1929, username and password lengths must be 1..255 - if (!args.ulen || !args.plen) { - return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); - } - - authDomain.on('error', (err) => { - // emit failed authentication event - self.server.emit( - EVENTS.AUTHENTICATION_ERROR, - args.uname.toString(), - err, - ); - - // respond with auth failure - return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); - }); - - // perform authentication - self.options.authenticate( - args.uname.toString(), - args.passwd.toString(), - socket, - authDomain.intercept(() => { - // emit successful authentication event - self.server.emit(EVENTS.AUTHENTICATION, args.uname.toString()); - - // respond with success... - const responseBuffer = Buffer.allocUnsafe(2); - responseBuffer[0] = RFC_1929_VERSION; - responseBuffer[1] = RFC_1929_REPLIES.SUCCEEDED; - - // respond then listen for cmd and dst info - socket.write(responseBuffer, () => { - // now listen for more details - socket.once('data', connect); - }); - }), - ); - }); - } - - /** - * RFC 1961 - GSSAPI authentication sub-negotiation. - * Delegates the negotiation to a user-provided provider. - * - * Provider contract: - * authenticate(socket, firstChunk, callback) - * - socket: client socket; provider may read/write further frames - * - firstChunk: Buffer after method selection - * - callback(err, principal): invoke with error or authenticated principal - * - * @param {Buffer} buffer - first data chunk for GSS-API negotiation - * @returns {undefined} - */ - function gssapi(buffer) { - const provider = self.options.gssapi && self.options.gssapi.provider; - - if (!provider || typeof provider.authenticate !== 'function') { - return socket.destroy(new Error('GSSAPI requested but no provider configured')); - } - - try { - provider.authenticate(socket, buffer, (err, principal) => { - if (err) { - self.server.emit(EVENTS.AUTHENTICATION_ERROR, '', err); - return socket.destroy(err); - } - - self.server.emit(EVENTS.AUTHENTICATION, principal || ''); - socket.once('data', connect); - }); - } catch (ex) { - return socket.destroy(ex); - } - } - - /** - * +----+-----+-------+------+----------+----------+ - * |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | - * +----+-----+-------+------+----------+----------+ - * | 1 | 1 | X'00' | 1 | Variable | 2 | - * +----+-----+-------+------+----------+----------+ - * - * @param {Buffer} buffer - a buffer - * @returns {undefined} - */ - function connect(buffer) { - const binaryStream = binary.stream(buffer); - - binaryStream - .word8('ver') - .word8('cmd') - .word8('rsv') - .word8('atyp') - .tap((args) => { - // capture the raw buffer - args.requestBuffer = buffer; - - // verify version is appropriate - if (args.ver !== RFC_1928_VERSION) { - return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); - } - - // append socket to active sessions - self.activeSessions.push(socket); - - // create dst - args.dst = {}; - - // ipv4 - if (args.atyp === RFC_1928_ATYP.IPV4) { - binaryStream - .buffer('addr.buf', LENGTH_RFC_1928_ATYP) - .tap((args) => { - args.dst.addr = [].slice.call(args.addr.buf).join('.'); - }); - - // domain name - } else if (args.atyp === RFC_1928_ATYP.DOMAINNAME) { - binaryStream - .word8('addr.size') - .buffer('addr.buf', 'addr.size') - .tap((args) => { - args.dst.addr = args.addr.buf.toString(); - }); - - // ipv6 - } else if (args.atyp === RFC_1928_ATYP.IPV6) { - binaryStream - .word32be('addr.a') - .word32be('addr.b') - .word32be('addr.c') - .word32be('addr.d') - .tap((args) => { - args.dst.addr = []; - - // extract the parts of the ipv6 address - ['a', 'b', 'c', 'd'].forEach((x) => { - x = args.addr[x]; - - // convert DWORD to two WORD values and append - /* eslint no-magic-numbers : 0 */ - args.dst.addr.push((x >>> 16).toString(16)); - args.dst.addr.push((x & 0xffff).toString(16)); - }); - - // format ipv6 address as string - args.dst.addr = args.dst.addr.join(':'); - }); - - // unsupported address type - } else { - return end(RFC_1928_REPLIES.ADDRESS_TYPE_NOT_SUPPORTED, args); - } - }) - .word16bu('dst.port') - .tap((args) => { - if (args.cmd === RFC_1928_COMMANDS.CONNECT) { - let connectionFilter = self.options.connectionFilter; - const connectionFilterDomain = domain.create(); - - // if no connection filter is provided, stub one - if (!connectionFilter || typeof connectionFilter !== 'function') { - connectionFilter = (destination, origin, callback) => setImmediate(callback); - } - - // capture connection filter errors - connectionFilterDomain.on('error', (err) => { - // emit failed destination connection event - self.server.emit( - EVENTS.CONNECTION_FILTER, - // destination - { - address: args.dst.addr, - port: args.dst.port, - }, - // origin - { - address: socket.remoteAddress, - port: socket.remotePort, - }, - err, - ); - - // respond with failure - return end(RFC_1928_REPLIES.CONNECTION_NOT_ALLOWED, args); - }); - - // perform connection - return connectionFilter( - // destination - { - address: args.dst.addr, - port: args.dst.port, - }, - // origin - { - address: socket.remoteAddress, - port: socket.remotePort, - }, - connectionFilterDomain.intercept(() => { - const destination = net.createConnection( - args.dst.port, - args.dst.addr, - () => { - // prepare a success response - const responseBuffer = Buffer.alloc(args.requestBuffer.length); - args.requestBuffer.copy(responseBuffer); - responseBuffer[1] = RFC_1928_REPLIES.SUCCEEDED; - - // write acknowledgement to client... - socket.write(responseBuffer, () => { - // listen for data bi-directionally - destination.pipe(socket); - socket.pipe(destination); - - // configure idle timeout for destination socket - if (self.idleTimeout && typeof destination.setTimeout === 'function') { - destination.setTimeout(self.idleTimeout, () => { - try { - destination.destroy(new Error('destination idle timeout')); - } catch { - // ignore errors - } - }); - } - - // ensure proper teardown when either side ends/closes/errors - const teardownDestination = () => { - try { - destination.destroy(); - } catch { - // ignore socket destroy errors - } - }; - const teardownSocket = () => { - try { - socket.destroy(); - } catch { - // ignore socket destroy errors - } - }; - - socket.once('close', teardownDestination); - socket.once('end', teardownDestination); - socket.once('error', teardownDestination); - destination.once('error', teardownSocket); - }); - }, - ); - const destinationInfo = { - address: args.dst.addr, - port: args.dst.port, - }; - const originInfo = { - address: socket.remoteAddress, - port: socket.remotePort, - }; - - // capture successful connection - destination.on('connect', () => { - // emit connection event - self.server.emit(EVENTS.PROXY_CONNECT, destinationInfo, destination); - - // capture and emit proxied connection data - destination.on('data', (data) => { - self.server.emit(EVENTS.PROXY_DATA, data); - }); - - // capture close of destination and emit pending disconnect - // note: this event is only emitted once the destination socket is fully closed - destination.on('close', (hadError) => { - // indicate client connection end - self.server.emit(EVENTS.PROXY_DISCONNECT, originInfo, destinationInfo, hadError); - }); - - connectionFilterDomain.exit(); - }); - - // capture connection errors and response appropriately - destination.on('error', (err) => { - // exit the connection filter domain - connectionFilterDomain.exit(); - - // notify of connection error - err.addr = args.dst.addr; - err.atyp = args.atyp; - err.port = args.dst.port; - - self.server.emit(EVENTS.PROXY_ERROR, err); - - if (err.code && err.code === 'EADDRNOTAVAIL') { - return end(RFC_1928_REPLIES.HOST_UNREACHABLE, args); - } - - if (err.code && err.code === 'ECONNREFUSED') { - return end(RFC_1928_REPLIES.CONNECTION_REFUSED, args); - } - - return end(RFC_1928_REPLIES.NETWORK_UNREACHABLE, args); - }); - }), - ); - } else { - // bind and udp associate commands - return end(RFC_1928_REPLIES.SUCCEEDED, args); - } - }); - } - - /** - * +----+-----+-------+------+----------+----------+ - * |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | - * +----+-----+-------+------+----------+----------+ - * | 1 | 1 | X'00' | 1 | Variable | 2 | - * +----+-----+-------+------+----------+----------+ - * - * @param {Buffer} response - a buffer representing the response - * @param {object} args - arguments to supply to the proxy end event - * @returns {undefined} - */ - function end(response, args) { - // either use the raw buffer (if available) or create a new one - const responseBuffer = args.requestBuffer || Buffer.allocUnsafe(2); - - if (!args.requestBuffer) { - responseBuffer[0] = RFC_1928_VERSION; - } - - responseBuffer[1] = response; - - // respond then end the connection - try { - socket.end(responseBuffer); - } catch { - socket.destroy(); - } - - // indicate end of connection - self.server.emit(EVENTS.PROXY_END, response, args); - } - - /** - * +----+----------+----------+ - * |VER | NMETHODS | METHODS | - * +----+----------+----------+ - * | 1 | 1 | 1 to 255 | - * +----+----------+----------+ - * - * @param {Buffer} buffer - a buffer - * @returns {undefined} - */ - function handshake(buffer) { - binary - .stream(buffer) - .word8('ver') - .word8('nmethods') - .buffer('methods', 'nmethods') - .tap((args) => { - // verify version is appropriate - if (args.ver !== RFC_1928_VERSION) { - return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); - } - - // convert methods buffer to an array - const acceptedMethods = [].slice.call(args.methods).reduce((methods, method) => { - methods[method] = true; - return methods; - }, {}); - const basicAuth = typeof self.options.authenticate === 'function'; - const clientSupportsBasic = typeof acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION] !== 'undefined' - && acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION]; - const clientSupportsGss = typeof acceptedMethods[RFC_1928_METHODS.GSSAPI] !== 'undefined' - && acceptedMethods[RFC_1928_METHODS.GSSAPI]; - const clientSupportsNoAuth = - typeof acceptedMethods[RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED] !== 'undefined' - && acceptedMethods[RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED]; - let next = connect; - const responseBuffer = Buffer.allocUnsafe(2); - const serverSupportsGss = Boolean( - self.options.gssapi && self.options.gssapi.enabled && self.options.gssapi.provider, - ); - - // form response Buffer - responseBuffer[0] = RFC_1928_VERSION; - responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; - - // prefer GSSAPI when enabled and mutually supported - if (serverSupportsGss && clientSupportsGss) { - responseBuffer[1] = RFC_1928_METHODS.GSSAPI; - next = gssapi; - - // check for basic auth configuration and mutual support - } else if (basicAuth && clientSupportsBasic) { - responseBuffer[1] = RFC_1928_METHODS.BASIC_AUTHENTICATION; - next = authenticate; - - // if NO AUTHENTICATION REQUIRED and client supports it - } else if (!basicAuth && clientSupportsNoAuth) { - responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; - next = connect; - - // basic auth callback not provided and no auth is not supported - } else { - return end(RFC_1928_METHODS.NO_ACCEPTABLE_METHODS, args); - } - - // respond then listen for cmd and dst info - socket.write(responseBuffer, () => { - // emit handshake event - self.server.emit(EVENTS.HANDSHAKE, socket); - - // now listen for more details - socket.once('data', next); - }); - }); - } - - // capture the client handshake - socket.once('data', handshake); - - // capture socket closure - socket.once('end', removeActiveSession); - socket.once('close', removeActiveSession); - }); - } + constructor(options) { + const self = this; + + this.activeSessions = []; + this.options = options || {}; + + // compatAuth options + this.options.compatAuth = this.options.compatAuth || {}; + if (this.options.compatAuth.strictMethodNegotiation === false) { + throw new Error( + "compatAuth.strictMethodNegotiation=false is not supported", + ); + } + + this.idleTimeout = this.options.idleTimeout || 0; + this.server = net.createServer((socket) => { + socket.on("error", (err) => { + self.server.emit(EVENTS.PROXY_ERROR, err); + }); + + // configure idle timeout for client socket + if (self.idleTimeout && typeof socket.setTimeout === "function") { + socket.setTimeout(self.idleTimeout, () => { + try { + socket.destroy(new Error("socket idle timeout")); + } catch { + // ignore socket destroy errors + } + }); + } + + // helper to safely remove from active sessions + function removeActiveSession() { + const idx = self.activeSessions.indexOf(socket); + if (idx !== -1) { + self.activeSessions.splice(idx, 1); + } + } + + /** + * +----+------+----------+------+----------+ + * |VER | ULEN | UNAME | PLEN | PASSWD | + * +----+------+----------+------+----------+ + * | 1 | 1 | 1 to 255 | 1 | 1 to 255 | + * +----+------+----------+------+----------+ + * + * @param {Buffer} buffer - a buffer + * @returns {undefined} + */ + function authenticate(buffer) { + const authDomain = domain.create(); + + const allowEmptyUsername = Boolean( + self.options.compatAuth.allowEmptyUsername, + ); + + const allowEmptyPassword = Boolean( + self.options.compatAuth.allowEmptyPassword, + ); + + binary + .stream(buffer) + .word8("ver") + .word8("ulen") + .buffer("uname", "ulen") + .word8("plen") + .buffer("passwd", "plen") + .tap((args) => { + // capture the raw buffer + args.requestBuffer = buffer; + + // verify version is appropriate + if (args.ver !== RFC_1929_VERSION) { + return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); + } + + // per RFC 1929, username and password lengths must be 1..255 + if ( + (!allowEmptyUsername && !args.ulen) || + (!allowEmptyPassword && !args.plen) + ) { + return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); + } + + authDomain.on("error", (err) => { + // emit failed authentication event + self.server.emit( + EVENTS.AUTHENTICATION_ERROR, + args.uname.toString(), + err, + ); + + // respond with auth failure + return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); + }); + + // perform authentication + self.options.authenticate( + args.uname.toString(), + args.passwd.toString(), + socket, + authDomain.intercept(() => { + // emit successful authentication event + self.server.emit(EVENTS.AUTHENTICATION, args.uname.toString()); + + // respond with success... + const responseBuffer = Buffer.allocUnsafe(2); + responseBuffer[0] = RFC_1929_VERSION; + responseBuffer[1] = RFC_1929_REPLIES.SUCCEEDED; + + // respond then listen for cmd and dst info + socket.write(responseBuffer, () => { + // now listen for more details + socket.once("data", connect); + }); + }), + ); + }); + } + + /** + * RFC 1961 - GSSAPI authentication sub-negotiation. + * Delegates the negotiation to a user-provided provider. + * + * Provider contract: + * authenticate(socket, firstChunk, callback) + * - socket: client socket; provider may read/write further frames + * - firstChunk: Buffer after method selection + * - callback(err, principal): invoke with error or authenticated principal + * + * @param {Buffer} buffer - first data chunk for GSS-API negotiation + * @returns {undefined} + */ + function gssapi(buffer) { + const provider = self.options.gssapi && self.options.gssapi.provider; + + if (!provider || typeof provider.authenticate !== "function") { + return socket.destroy( + new Error("GSSAPI requested but no provider configured"), + ); + } + + try { + provider.authenticate(socket, buffer, (err, principal) => { + if (err) { + self.server.emit(EVENTS.AUTHENTICATION_ERROR, "", err); + return socket.destroy(err); + } + + self.server.emit(EVENTS.AUTHENTICATION, principal || ""); + socket.once("data", connect); + }); + } catch (ex) { + return socket.destroy(ex); + } + } + + /** + * +----+-----+-------+------+----------+----------+ + * |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + * +----+-----+-------+------+----------+----------+ + * | 1 | 1 | X'00' | 1 | Variable | 2 | + * +----+-----+-------+------+----------+----------+ + * + * @param {Buffer} buffer - a buffer + * @returns {undefined} + */ + function connect(buffer) { + const binaryStream = binary.stream(buffer); + + binaryStream + .word8("ver") + .word8("cmd") + .word8("rsv") + .word8("atyp") + .tap((args) => { + // capture the raw buffer + args.requestBuffer = buffer; + + // verify version is appropriate + if (args.ver !== RFC_1928_VERSION) { + return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); + } + + // append socket to active sessions + self.activeSessions.push(socket); + + // create dst + args.dst = {}; + + // ipv4 + if (args.atyp === RFC_1928_ATYP.IPV4) { + binaryStream + .buffer("addr.buf", LENGTH_RFC_1928_ATYP) + .tap((args) => { + args.dst.addr = [].slice.call(args.addr.buf).join("."); + }); + + // domain name + } else if (args.atyp === RFC_1928_ATYP.DOMAINNAME) { + binaryStream + .word8("addr.size") + .buffer("addr.buf", "addr.size") + .tap((args) => { + args.dst.addr = args.addr.buf.toString(); + }); + + // ipv6 + } else if (args.atyp === RFC_1928_ATYP.IPV6) { + binaryStream + .word32be("addr.a") + .word32be("addr.b") + .word32be("addr.c") + .word32be("addr.d") + .tap((args) => { + args.dst.addr = []; + + // extract the parts of the ipv6 address + ["a", "b", "c", "d"].forEach((x) => { + x = args.addr[x]; + + // convert DWORD to two WORD values and append + /* eslint no-magic-numbers : 0 */ + args.dst.addr.push((x >>> 16).toString(16)); + args.dst.addr.push((x & 0xffff).toString(16)); + }); + + // format ipv6 address as string + args.dst.addr = args.dst.addr.join(":"); + }); + + // unsupported address type + } else { + return end(RFC_1928_REPLIES.ADDRESS_TYPE_NOT_SUPPORTED, args); + } + }) + .word16bu("dst.port") + .tap((args) => { + if (args.cmd === RFC_1928_COMMANDS.CONNECT) { + let connectionFilter = self.options.connectionFilter; + const connectionFilterDomain = domain.create(); + + // if no connection filter is provided, stub one + if (!connectionFilter || typeof connectionFilter !== "function") { + connectionFilter = (destination, origin, callback) => + setImmediate(callback); + } + + // capture connection filter errors + connectionFilterDomain.on("error", (err) => { + // emit failed destination connection event + self.server.emit( + EVENTS.CONNECTION_FILTER, + // destination + { + address: args.dst.addr, + port: args.dst.port, + }, + // origin + { + address: socket.remoteAddress, + port: socket.remotePort, + }, + err, + ); + + // respond with failure + return end(RFC_1928_REPLIES.CONNECTION_NOT_ALLOWED, args); + }); + + // perform connection + return connectionFilter( + // destination + { + address: args.dst.addr, + port: args.dst.port, + }, + // origin + { + address: socket.remoteAddress, + port: socket.remotePort, + }, + connectionFilterDomain.intercept(() => { + const destination = net.createConnection( + args.dst.port, + args.dst.addr, + () => { + // prepare a success response + const responseBuffer = Buffer.alloc( + args.requestBuffer.length, + ); + args.requestBuffer.copy(responseBuffer); + responseBuffer[1] = RFC_1928_REPLIES.SUCCEEDED; + + // write acknowledgement to client... + socket.write(responseBuffer, () => { + // listen for data bi-directionally + destination.pipe(socket); + socket.pipe(destination); + + // configure idle timeout for destination socket + if ( + self.idleTimeout && + typeof destination.setTimeout === "function" + ) { + destination.setTimeout(self.idleTimeout, () => { + try { + destination.destroy( + new Error("destination idle timeout"), + ); + } catch { + // ignore errors + } + }); + } + + // ensure proper teardown when either side ends/closes/errors + const teardownDestination = () => { + try { + destination.destroy(); + } catch { + // ignore socket destroy errors + } + }; + const teardownSocket = () => { + try { + socket.destroy(); + } catch { + // ignore socket destroy errors + } + }; + + socket.once("close", teardownDestination); + socket.once("end", teardownDestination); + socket.once("error", teardownDestination); + destination.once("error", teardownSocket); + }); + }, + ); + const destinationInfo = { + address: args.dst.addr, + port: args.dst.port, + }; + const originInfo = { + address: socket.remoteAddress, + port: socket.remotePort, + }; + + // capture successful connection + destination.on("connect", () => { + // emit connection event + self.server.emit( + EVENTS.PROXY_CONNECT, + destinationInfo, + destination, + ); + + // capture and emit proxied connection data + destination.on("data", (data) => { + self.server.emit(EVENTS.PROXY_DATA, data); + }); + + // capture close of destination and emit pending disconnect + // note: this event is only emitted once the destination socket is fully closed + destination.on("close", (hadError) => { + // indicate client connection end + self.server.emit( + EVENTS.PROXY_DISCONNECT, + originInfo, + destinationInfo, + hadError, + ); + }); + + connectionFilterDomain.exit(); + }); + + // capture connection errors and response appropriately + destination.on("error", (err) => { + // exit the connection filter domain + connectionFilterDomain.exit(); + + // notify of connection error + err.addr = args.dst.addr; + err.atyp = args.atyp; + err.port = args.dst.port; + + self.server.emit(EVENTS.PROXY_ERROR, err); + + if (err.code && err.code === "EADDRNOTAVAIL") { + return end(RFC_1928_REPLIES.HOST_UNREACHABLE, args); + } + + if (err.code && err.code === "ECONNREFUSED") { + return end(RFC_1928_REPLIES.CONNECTION_REFUSED, args); + } + + return end(RFC_1928_REPLIES.NETWORK_UNREACHABLE, args); + }); + }), + ); + } else { + // bind and udp associate commands + return end(RFC_1928_REPLIES.SUCCEEDED, args); + } + }); + } + + /** + * +----+-----+-------+------+----------+----------+ + * |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | + * +----+-----+-------+------+----------+----------+ + * | 1 | 1 | X'00' | 1 | Variable | 2 | + * +----+-----+-------+------+----------+----------+ + * + * @param {Buffer} response - a buffer representing the response + * @param {object} args - arguments to supply to the proxy end event + * @returns {undefined} + */ + function end(response, args) { + // either use the raw buffer (if available) or create a new one + const responseBuffer = args.requestBuffer || Buffer.allocUnsafe(2); + + if (!args.requestBuffer) { + responseBuffer[0] = RFC_1928_VERSION; + } + + responseBuffer[1] = response; + + // respond then end the connection + try { + socket.end(responseBuffer); + } catch { + socket.destroy(); + } + + // indicate end of connection + self.server.emit(EVENTS.PROXY_END, response, args); + } + + /** + * +----+----------+----------+ + * |VER | NMETHODS | METHODS | + * +----+----------+----------+ + * | 1 | 1 | 1 to 255 | + * +----+----------+----------+ + * + * @param {Buffer} buffer - a buffer + * @returns {undefined} + */ + function handshake(buffer) { + binary + .stream(buffer) + .word8("ver") + .word8("nmethods") + .buffer("methods", "nmethods") + .tap((args) => { + // verify version is appropriate + if (args.ver !== RFC_1928_VERSION) { + return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); + } + + // convert methods buffer to an array + const acceptedMethods = [].slice + .call(args.methods) + .reduce((methods, method) => { + methods[method] = true; + return methods; + }, {}); + const basicAuth = typeof self.options.authenticate === "function"; + const clientSupportsBasic = + typeof acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION] !== + "undefined" && + acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION]; + const clientSupportsGss = + typeof acceptedMethods[RFC_1928_METHODS.GSSAPI] !== "undefined" && + acceptedMethods[RFC_1928_METHODS.GSSAPI]; + const clientSupportsNoAuth = + typeof acceptedMethods[ + RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED + ] !== "undefined" && + acceptedMethods[RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED]; + let next = connect; + const responseBuffer = Buffer.allocUnsafe(2); + const serverSupportsGss = Boolean( + self.options.gssapi && + self.options.gssapi.enabled && + self.options.gssapi.provider, + ); + + // form response Buffer + responseBuffer[0] = RFC_1928_VERSION; + responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; + + // prefer GSSAPI when enabled and mutually supported + if (serverSupportsGss && clientSupportsGss) { + responseBuffer[1] = RFC_1928_METHODS.GSSAPI; + next = gssapi; + + // check for basic auth configuration and mutual support + } else if (basicAuth && clientSupportsBasic) { + responseBuffer[1] = RFC_1928_METHODS.BASIC_AUTHENTICATION; + next = authenticate; + + // if NO AUTHENTICATION REQUIRED and client supports it + } else if (!basicAuth && clientSupportsNoAuth) { + responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; + next = connect; + + // basic auth callback not provided and no auth is not supported + } else { + return end(RFC_1928_METHODS.NO_ACCEPTABLE_METHODS, args); + } + + // respond then listen for cmd and dst info + socket.write(responseBuffer, () => { + // emit handshake event + self.server.emit(EVENTS.HANDSHAKE, socket); + + // now listen for more details + socket.once("data", next); + }); + }); + } + + // capture the client handshake + socket.once("data", handshake); + + // capture socket closure + socket.once("end", removeActiveSession); + socket.once("close", removeActiveSession); + }); + } } export default { - SocksServer, - createServer: (options) => { - const socksServer = new SocksServer(options); - return socksServer.server; - }, - events: EVENTS, + SocksServer, + createServer: (options) => { + const socksServer = new SocksServer(options); + return socksServer.server; + }, + events: EVENTS, }; diff --git a/test/index.js b/test/index.js index 2adbd10..4d41930 100644 --- a/test/index.js +++ b/test/index.js @@ -220,6 +220,107 @@ await test('basic-auth server returns no acceptable methods for no-auth-only cli await closeServer(app); }); +await test('basic-auth rejects empty password by default', async () => { + const app = socks5.createServer({ + authenticate(_username, _password, _socket, cb) { + return setImmediate(cb); + }, + }); + await listenServer(app); + const addr = app.address(); + + const client = await connectTo(addr.port, addr.address); + client.write(buildSocks5Handshake([0x00, 0x02])); + const selection = await readExactly(client, 2); + assert.strictEqual(selection[0], 0x05); + assert.strictEqual(selection[1], 0x02); + + client.write(buildSocks5BasicAuth('foo', '')); + const authResponse = await readExactly(client, 2); + assert.strictEqual(authResponse[0], 0x01); + assert.strictEqual(authResponse[1], 0xff); + + client.destroy(); + await closeServer(app); +}); + +await test('basic-auth rejects empty username by default', async () => { + const app = socks5.createServer({ + authenticate(_username, _password, _socket, cb) { + return setImmediate(cb); + }, + }); + await listenServer(app); + const addr = app.address(); + + const client = await connectTo(addr.port, addr.address); + client.write(buildSocks5Handshake([0x00, 0x02])); + const selection = await readExactly(client, 2); + assert.strictEqual(selection[0], 0x05); + assert.strictEqual(selection[1], 0x02); + + client.write(buildSocks5BasicAuth('', 'bar')); + const authResponse = await readExactly(client, 2); + assert.strictEqual(authResponse[0], 0x01); + assert.strictEqual(authResponse[1], 0xff); + + client.destroy(); + await closeServer(app); +}); + +await test('compatAuth allowEmptyPassword lets callback decide authentication', async () => { + const app = socks5.createServer({ + authenticate(username, password, _socket, cb) { + if (username === 'foo' && password === '') return setImmediate(cb); + return setImmediate(cb, new Error('bad creds')); + }, + compatAuth: { allowEmptyPassword: true }, + }); + await listenServer(app); + const addr = app.address(); + + const client = await connectTo(addr.port, addr.address); + client.write(buildSocks5Handshake([0x00, 0x02])); + const selection = await readExactly(client, 2); + assert.strictEqual(selection[0], 0x05); + assert.strictEqual(selection[1], 0x02); + + client.write(buildSocks5BasicAuth('foo', '')); + const authResponse = await readExactly(client, 2); + assert.strictEqual(authResponse[0], 0x01); + assert.strictEqual(authResponse[1], 0x00); + + client.destroy(); + await closeServer(app); +}); + +await test('compatAuth does not bypass method negotiation when BASIC is absent', async () => { + const app = socks5.createServer({ + authenticate(_username, _password, _socket, cb) { + return setImmediate(cb); + }, + compatAuth: { allowEmptyPassword: true }, + }); + await listenServer(app); + const addr = app.address(); + + const client = await connectTo(addr.port, addr.address); + client.write(buildSocks5Handshake(0x00)); + const res = await readExactly(client, 2); + assert.strictEqual(res[0], 0x05); + assert.strictEqual(res[1], 0xff); + + client.destroy(); + await closeServer(app); +}); + +await test('compatAuth strictMethodNegotiation=false is rejected', async () => { + assert.throws( + () => socks5.createServer({ compatAuth: { strictMethodNegotiation: false } }), + /strictMethodNegotiation=false is not supported/, + ); +}); + await test('bind command receives success response and closes', async () => { const app = socks5.createServer(); await listenServer(app); From e3c3ef8a3d6f9b949d6cc3eec9bd47c2281642de Mon Sep 17 00:00:00 2001 From: Joshua Thomas Date: Sun, 8 Mar 2026 15:39:57 -0700 Subject: [PATCH 2/2] Enhance documentation and improve code formatting - Added a note in AGENTS.md to emphasize the importance of running formatting checks after code edits. - Introduced a Quality Gate Checklist in AGENTS.md to outline the required steps before handoff. - Reformatted import statements and event definitions in src/socks5.js for improved readability and consistency. --- AGENTS.md | 12 + src/socks5.js | 1120 ++++++++++++++++++++++++------------------------- 2 files changed, 570 insertions(+), 562 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9c86bff..d9da4b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ This repository is a Node.js SOCKS5 server library (`simple-socks`) with example - `npm run format` to apply formatting (`dprint fmt`) - `npm run format:check` to verify formatting (`dprint check`) +- After any code edits, do not assume formatting is correct from visual inspection; run `npm run format` and then `npm run format:check` before considering work complete. **Linting:** @@ -41,6 +42,17 @@ All changes should pass: 3. `npm run test` 4. `npm run test:coverage` (when changing CI/coverage behavior) +### Quality Gate Checklist (required before handoff) + +Use this exact sequence to avoid CI regressions in the `quality` job: + +1. `npm run format` +2. `npm run format:check` +3. `npm run lint` +4. `npm run test` + +If any file is reformatted in step 1, rerun steps 2-4 before handoff. + ## Coding Conventions - Keep imports alphabetized when practical. diff --git a/src/socks5.js b/src/socks5.js index b8929de..0401f1c 100644 --- a/src/socks5.js +++ b/src/socks5.js @@ -1,30 +1,30 @@ import { - RFC_1928_ATYP, - RFC_1928_COMMANDS, - RFC_1928_METHODS, - RFC_1928_REPLIES, - RFC_1928_VERSION, - RFC_1929_REPLIES, - RFC_1929_VERSION, -} from "./constants.js"; - -import binary from "binary"; -import domain from "domain"; -import net from "net"; + RFC_1928_ATYP, + RFC_1928_COMMANDS, + RFC_1928_METHODS, + RFC_1928_REPLIES, + RFC_1928_VERSION, + RFC_1929_REPLIES, + RFC_1929_VERSION, +} from './constants.js'; + +import binary from 'binary'; +import domain from 'domain'; +import net from 'net'; // module specific events const EVENTS = { - AUTHENTICATION: "authenticate", - AUTHENTICATION_ERROR: "authenticateError", - CONNECTION_FILTER: "connectionFilter", - HANDSHAKE: "handshake", - PROXY_CONNECT: "proxyConnect", - PROXY_DATA: "proxyData", - PROXY_DISCONNECT: "proxyDisconnect", - PROXY_END: "proxyEnd", - PROXY_ERROR: "proxyError", - }, - LENGTH_RFC_1928_ATYP = 4; + AUTHENTICATION: 'authenticate', + AUTHENTICATION_ERROR: 'authenticateError', + CONNECTION_FILTER: 'connectionFilter', + HANDSHAKE: 'handshake', + PROXY_CONNECT: 'proxyConnect', + PROXY_DATA: 'proxyData', + PROXY_DISCONNECT: 'proxyDisconnect', + PROXY_END: 'proxyEnd', + PROXY_ERROR: 'proxyError', + }, + LENGTH_RFC_1928_ATYP = 4; /** * The following RFCs may be useful as background: @@ -33,546 +33,542 @@ const EVENTS = { * https://www.ietf.org/rfc/rfc1929.txt - USERNAME/PASSWORD SOCKS5 */ class SocksServer { - constructor(options) { - const self = this; - - this.activeSessions = []; - this.options = options || {}; - - // compatAuth options - this.options.compatAuth = this.options.compatAuth || {}; - if (this.options.compatAuth.strictMethodNegotiation === false) { - throw new Error( - "compatAuth.strictMethodNegotiation=false is not supported", - ); - } - - this.idleTimeout = this.options.idleTimeout || 0; - this.server = net.createServer((socket) => { - socket.on("error", (err) => { - self.server.emit(EVENTS.PROXY_ERROR, err); - }); - - // configure idle timeout for client socket - if (self.idleTimeout && typeof socket.setTimeout === "function") { - socket.setTimeout(self.idleTimeout, () => { - try { - socket.destroy(new Error("socket idle timeout")); - } catch { - // ignore socket destroy errors - } - }); - } - - // helper to safely remove from active sessions - function removeActiveSession() { - const idx = self.activeSessions.indexOf(socket); - if (idx !== -1) { - self.activeSessions.splice(idx, 1); - } - } - - /** - * +----+------+----------+------+----------+ - * |VER | ULEN | UNAME | PLEN | PASSWD | - * +----+------+----------+------+----------+ - * | 1 | 1 | 1 to 255 | 1 | 1 to 255 | - * +----+------+----------+------+----------+ - * - * @param {Buffer} buffer - a buffer - * @returns {undefined} - */ - function authenticate(buffer) { - const authDomain = domain.create(); - - const allowEmptyUsername = Boolean( - self.options.compatAuth.allowEmptyUsername, - ); - - const allowEmptyPassword = Boolean( - self.options.compatAuth.allowEmptyPassword, - ); - - binary - .stream(buffer) - .word8("ver") - .word8("ulen") - .buffer("uname", "ulen") - .word8("plen") - .buffer("passwd", "plen") - .tap((args) => { - // capture the raw buffer - args.requestBuffer = buffer; - - // verify version is appropriate - if (args.ver !== RFC_1929_VERSION) { - return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); - } - - // per RFC 1929, username and password lengths must be 1..255 - if ( - (!allowEmptyUsername && !args.ulen) || - (!allowEmptyPassword && !args.plen) - ) { - return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); - } - - authDomain.on("error", (err) => { - // emit failed authentication event - self.server.emit( - EVENTS.AUTHENTICATION_ERROR, - args.uname.toString(), - err, - ); - - // respond with auth failure - return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); - }); - - // perform authentication - self.options.authenticate( - args.uname.toString(), - args.passwd.toString(), - socket, - authDomain.intercept(() => { - // emit successful authentication event - self.server.emit(EVENTS.AUTHENTICATION, args.uname.toString()); - - // respond with success... - const responseBuffer = Buffer.allocUnsafe(2); - responseBuffer[0] = RFC_1929_VERSION; - responseBuffer[1] = RFC_1929_REPLIES.SUCCEEDED; - - // respond then listen for cmd and dst info - socket.write(responseBuffer, () => { - // now listen for more details - socket.once("data", connect); - }); - }), - ); - }); - } - - /** - * RFC 1961 - GSSAPI authentication sub-negotiation. - * Delegates the negotiation to a user-provided provider. - * - * Provider contract: - * authenticate(socket, firstChunk, callback) - * - socket: client socket; provider may read/write further frames - * - firstChunk: Buffer after method selection - * - callback(err, principal): invoke with error or authenticated principal - * - * @param {Buffer} buffer - first data chunk for GSS-API negotiation - * @returns {undefined} - */ - function gssapi(buffer) { - const provider = self.options.gssapi && self.options.gssapi.provider; - - if (!provider || typeof provider.authenticate !== "function") { - return socket.destroy( - new Error("GSSAPI requested but no provider configured"), - ); - } - - try { - provider.authenticate(socket, buffer, (err, principal) => { - if (err) { - self.server.emit(EVENTS.AUTHENTICATION_ERROR, "", err); - return socket.destroy(err); - } - - self.server.emit(EVENTS.AUTHENTICATION, principal || ""); - socket.once("data", connect); - }); - } catch (ex) { - return socket.destroy(ex); - } - } - - /** - * +----+-----+-------+------+----------+----------+ - * |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | - * +----+-----+-------+------+----------+----------+ - * | 1 | 1 | X'00' | 1 | Variable | 2 | - * +----+-----+-------+------+----------+----------+ - * - * @param {Buffer} buffer - a buffer - * @returns {undefined} - */ - function connect(buffer) { - const binaryStream = binary.stream(buffer); - - binaryStream - .word8("ver") - .word8("cmd") - .word8("rsv") - .word8("atyp") - .tap((args) => { - // capture the raw buffer - args.requestBuffer = buffer; - - // verify version is appropriate - if (args.ver !== RFC_1928_VERSION) { - return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); - } - - // append socket to active sessions - self.activeSessions.push(socket); - - // create dst - args.dst = {}; - - // ipv4 - if (args.atyp === RFC_1928_ATYP.IPV4) { - binaryStream - .buffer("addr.buf", LENGTH_RFC_1928_ATYP) - .tap((args) => { - args.dst.addr = [].slice.call(args.addr.buf).join("."); - }); - - // domain name - } else if (args.atyp === RFC_1928_ATYP.DOMAINNAME) { - binaryStream - .word8("addr.size") - .buffer("addr.buf", "addr.size") - .tap((args) => { - args.dst.addr = args.addr.buf.toString(); - }); - - // ipv6 - } else if (args.atyp === RFC_1928_ATYP.IPV6) { - binaryStream - .word32be("addr.a") - .word32be("addr.b") - .word32be("addr.c") - .word32be("addr.d") - .tap((args) => { - args.dst.addr = []; - - // extract the parts of the ipv6 address - ["a", "b", "c", "d"].forEach((x) => { - x = args.addr[x]; - - // convert DWORD to two WORD values and append - /* eslint no-magic-numbers : 0 */ - args.dst.addr.push((x >>> 16).toString(16)); - args.dst.addr.push((x & 0xffff).toString(16)); - }); - - // format ipv6 address as string - args.dst.addr = args.dst.addr.join(":"); - }); - - // unsupported address type - } else { - return end(RFC_1928_REPLIES.ADDRESS_TYPE_NOT_SUPPORTED, args); - } - }) - .word16bu("dst.port") - .tap((args) => { - if (args.cmd === RFC_1928_COMMANDS.CONNECT) { - let connectionFilter = self.options.connectionFilter; - const connectionFilterDomain = domain.create(); - - // if no connection filter is provided, stub one - if (!connectionFilter || typeof connectionFilter !== "function") { - connectionFilter = (destination, origin, callback) => - setImmediate(callback); - } - - // capture connection filter errors - connectionFilterDomain.on("error", (err) => { - // emit failed destination connection event - self.server.emit( - EVENTS.CONNECTION_FILTER, - // destination - { - address: args.dst.addr, - port: args.dst.port, - }, - // origin - { - address: socket.remoteAddress, - port: socket.remotePort, - }, - err, - ); - - // respond with failure - return end(RFC_1928_REPLIES.CONNECTION_NOT_ALLOWED, args); - }); - - // perform connection - return connectionFilter( - // destination - { - address: args.dst.addr, - port: args.dst.port, - }, - // origin - { - address: socket.remoteAddress, - port: socket.remotePort, - }, - connectionFilterDomain.intercept(() => { - const destination = net.createConnection( - args.dst.port, - args.dst.addr, - () => { - // prepare a success response - const responseBuffer = Buffer.alloc( - args.requestBuffer.length, - ); - args.requestBuffer.copy(responseBuffer); - responseBuffer[1] = RFC_1928_REPLIES.SUCCEEDED; - - // write acknowledgement to client... - socket.write(responseBuffer, () => { - // listen for data bi-directionally - destination.pipe(socket); - socket.pipe(destination); - - // configure idle timeout for destination socket - if ( - self.idleTimeout && - typeof destination.setTimeout === "function" - ) { - destination.setTimeout(self.idleTimeout, () => { - try { - destination.destroy( - new Error("destination idle timeout"), - ); - } catch { - // ignore errors - } - }); - } - - // ensure proper teardown when either side ends/closes/errors - const teardownDestination = () => { - try { - destination.destroy(); - } catch { - // ignore socket destroy errors - } - }; - const teardownSocket = () => { - try { - socket.destroy(); - } catch { - // ignore socket destroy errors - } - }; - - socket.once("close", teardownDestination); - socket.once("end", teardownDestination); - socket.once("error", teardownDestination); - destination.once("error", teardownSocket); - }); - }, - ); - const destinationInfo = { - address: args.dst.addr, - port: args.dst.port, - }; - const originInfo = { - address: socket.remoteAddress, - port: socket.remotePort, - }; - - // capture successful connection - destination.on("connect", () => { - // emit connection event - self.server.emit( - EVENTS.PROXY_CONNECT, - destinationInfo, - destination, - ); - - // capture and emit proxied connection data - destination.on("data", (data) => { - self.server.emit(EVENTS.PROXY_DATA, data); - }); - - // capture close of destination and emit pending disconnect - // note: this event is only emitted once the destination socket is fully closed - destination.on("close", (hadError) => { - // indicate client connection end - self.server.emit( - EVENTS.PROXY_DISCONNECT, - originInfo, - destinationInfo, - hadError, - ); - }); - - connectionFilterDomain.exit(); - }); - - // capture connection errors and response appropriately - destination.on("error", (err) => { - // exit the connection filter domain - connectionFilterDomain.exit(); - - // notify of connection error - err.addr = args.dst.addr; - err.atyp = args.atyp; - err.port = args.dst.port; - - self.server.emit(EVENTS.PROXY_ERROR, err); - - if (err.code && err.code === "EADDRNOTAVAIL") { - return end(RFC_1928_REPLIES.HOST_UNREACHABLE, args); - } - - if (err.code && err.code === "ECONNREFUSED") { - return end(RFC_1928_REPLIES.CONNECTION_REFUSED, args); - } - - return end(RFC_1928_REPLIES.NETWORK_UNREACHABLE, args); - }); - }), - ); - } else { - // bind and udp associate commands - return end(RFC_1928_REPLIES.SUCCEEDED, args); - } - }); - } - - /** - * +----+-----+-------+------+----------+----------+ - * |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | - * +----+-----+-------+------+----------+----------+ - * | 1 | 1 | X'00' | 1 | Variable | 2 | - * +----+-----+-------+------+----------+----------+ - * - * @param {Buffer} response - a buffer representing the response - * @param {object} args - arguments to supply to the proxy end event - * @returns {undefined} - */ - function end(response, args) { - // either use the raw buffer (if available) or create a new one - const responseBuffer = args.requestBuffer || Buffer.allocUnsafe(2); - - if (!args.requestBuffer) { - responseBuffer[0] = RFC_1928_VERSION; - } - - responseBuffer[1] = response; - - // respond then end the connection - try { - socket.end(responseBuffer); - } catch { - socket.destroy(); - } - - // indicate end of connection - self.server.emit(EVENTS.PROXY_END, response, args); - } - - /** - * +----+----------+----------+ - * |VER | NMETHODS | METHODS | - * +----+----------+----------+ - * | 1 | 1 | 1 to 255 | - * +----+----------+----------+ - * - * @param {Buffer} buffer - a buffer - * @returns {undefined} - */ - function handshake(buffer) { - binary - .stream(buffer) - .word8("ver") - .word8("nmethods") - .buffer("methods", "nmethods") - .tap((args) => { - // verify version is appropriate - if (args.ver !== RFC_1928_VERSION) { - return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); - } - - // convert methods buffer to an array - const acceptedMethods = [].slice - .call(args.methods) - .reduce((methods, method) => { - methods[method] = true; - return methods; - }, {}); - const basicAuth = typeof self.options.authenticate === "function"; - const clientSupportsBasic = - typeof acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION] !== - "undefined" && - acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION]; - const clientSupportsGss = - typeof acceptedMethods[RFC_1928_METHODS.GSSAPI] !== "undefined" && - acceptedMethods[RFC_1928_METHODS.GSSAPI]; - const clientSupportsNoAuth = - typeof acceptedMethods[ - RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED - ] !== "undefined" && - acceptedMethods[RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED]; - let next = connect; - const responseBuffer = Buffer.allocUnsafe(2); - const serverSupportsGss = Boolean( - self.options.gssapi && - self.options.gssapi.enabled && - self.options.gssapi.provider, - ); - - // form response Buffer - responseBuffer[0] = RFC_1928_VERSION; - responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; - - // prefer GSSAPI when enabled and mutually supported - if (serverSupportsGss && clientSupportsGss) { - responseBuffer[1] = RFC_1928_METHODS.GSSAPI; - next = gssapi; - - // check for basic auth configuration and mutual support - } else if (basicAuth && clientSupportsBasic) { - responseBuffer[1] = RFC_1928_METHODS.BASIC_AUTHENTICATION; - next = authenticate; - - // if NO AUTHENTICATION REQUIRED and client supports it - } else if (!basicAuth && clientSupportsNoAuth) { - responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; - next = connect; - - // basic auth callback not provided and no auth is not supported - } else { - return end(RFC_1928_METHODS.NO_ACCEPTABLE_METHODS, args); - } - - // respond then listen for cmd and dst info - socket.write(responseBuffer, () => { - // emit handshake event - self.server.emit(EVENTS.HANDSHAKE, socket); - - // now listen for more details - socket.once("data", next); - }); - }); - } - - // capture the client handshake - socket.once("data", handshake); - - // capture socket closure - socket.once("end", removeActiveSession); - socket.once("close", removeActiveSession); - }); - } + constructor(options) { + const self = this; + + this.activeSessions = []; + this.options = options || {}; + + // compatAuth options + this.options.compatAuth = this.options.compatAuth || {}; + if (this.options.compatAuth.strictMethodNegotiation === false) { + throw new Error( + 'compatAuth.strictMethodNegotiation=false is not supported', + ); + } + + this.idleTimeout = this.options.idleTimeout || 0; + this.server = net.createServer((socket) => { + socket.on('error', (err) => { + self.server.emit(EVENTS.PROXY_ERROR, err); + }); + + // configure idle timeout for client socket + if (self.idleTimeout && typeof socket.setTimeout === 'function') { + socket.setTimeout(self.idleTimeout, () => { + try { + socket.destroy(new Error('socket idle timeout')); + } catch { + // ignore socket destroy errors + } + }); + } + + // helper to safely remove from active sessions + function removeActiveSession() { + const idx = self.activeSessions.indexOf(socket); + if (idx !== -1) { + self.activeSessions.splice(idx, 1); + } + } + + /** + * +----+------+----------+------+----------+ + * |VER | ULEN | UNAME | PLEN | PASSWD | + * +----+------+----------+------+----------+ + * | 1 | 1 | 1 to 255 | 1 | 1 to 255 | + * +----+------+----------+------+----------+ + * + * @param {Buffer} buffer - a buffer + * @returns {undefined} + */ + function authenticate(buffer) { + const authDomain = domain.create(); + + const allowEmptyUsername = Boolean( + self.options.compatAuth.allowEmptyUsername, + ); + + const allowEmptyPassword = Boolean( + self.options.compatAuth.allowEmptyPassword, + ); + + binary + .stream(buffer) + .word8('ver') + .word8('ulen') + .buffer('uname', 'ulen') + .word8('plen') + .buffer('passwd', 'plen') + .tap((args) => { + // capture the raw buffer + args.requestBuffer = buffer; + + // verify version is appropriate + if (args.ver !== RFC_1929_VERSION) { + return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); + } + + // per RFC 1929, username and password lengths must be 1..255 + if ( + (!allowEmptyUsername && !args.ulen) + || (!allowEmptyPassword && !args.plen) + ) { + return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); + } + + authDomain.on('error', (err) => { + // emit failed authentication event + self.server.emit( + EVENTS.AUTHENTICATION_ERROR, + args.uname.toString(), + err, + ); + + // respond with auth failure + return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); + }); + + // perform authentication + self.options.authenticate( + args.uname.toString(), + args.passwd.toString(), + socket, + authDomain.intercept(() => { + // emit successful authentication event + self.server.emit(EVENTS.AUTHENTICATION, args.uname.toString()); + + // respond with success... + const responseBuffer = Buffer.allocUnsafe(2); + responseBuffer[0] = RFC_1929_VERSION; + responseBuffer[1] = RFC_1929_REPLIES.SUCCEEDED; + + // respond then listen for cmd and dst info + socket.write(responseBuffer, () => { + // now listen for more details + socket.once('data', connect); + }); + }), + ); + }); + } + + /** + * RFC 1961 - GSSAPI authentication sub-negotiation. + * Delegates the negotiation to a user-provided provider. + * + * Provider contract: + * authenticate(socket, firstChunk, callback) + * - socket: client socket; provider may read/write further frames + * - firstChunk: Buffer after method selection + * - callback(err, principal): invoke with error or authenticated principal + * + * @param {Buffer} buffer - first data chunk for GSS-API negotiation + * @returns {undefined} + */ + function gssapi(buffer) { + const provider = self.options.gssapi && self.options.gssapi.provider; + + if (!provider || typeof provider.authenticate !== 'function') { + return socket.destroy( + new Error('GSSAPI requested but no provider configured'), + ); + } + + try { + provider.authenticate(socket, buffer, (err, principal) => { + if (err) { + self.server.emit(EVENTS.AUTHENTICATION_ERROR, '', err); + return socket.destroy(err); + } + + self.server.emit(EVENTS.AUTHENTICATION, principal || ''); + socket.once('data', connect); + }); + } catch (ex) { + return socket.destroy(ex); + } + } + + /** + * +----+-----+-------+------+----------+----------+ + * |VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT | + * +----+-----+-------+------+----------+----------+ + * | 1 | 1 | X'00' | 1 | Variable | 2 | + * +----+-----+-------+------+----------+----------+ + * + * @param {Buffer} buffer - a buffer + * @returns {undefined} + */ + function connect(buffer) { + const binaryStream = binary.stream(buffer); + + binaryStream + .word8('ver') + .word8('cmd') + .word8('rsv') + .word8('atyp') + .tap((args) => { + // capture the raw buffer + args.requestBuffer = buffer; + + // verify version is appropriate + if (args.ver !== RFC_1928_VERSION) { + return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); + } + + // append socket to active sessions + self.activeSessions.push(socket); + + // create dst + args.dst = {}; + + // ipv4 + if (args.atyp === RFC_1928_ATYP.IPV4) { + binaryStream + .buffer('addr.buf', LENGTH_RFC_1928_ATYP) + .tap((args) => { + args.dst.addr = [].slice.call(args.addr.buf).join('.'); + }); + + // domain name + } else if (args.atyp === RFC_1928_ATYP.DOMAINNAME) { + binaryStream + .word8('addr.size') + .buffer('addr.buf', 'addr.size') + .tap((args) => { + args.dst.addr = args.addr.buf.toString(); + }); + + // ipv6 + } else if (args.atyp === RFC_1928_ATYP.IPV6) { + binaryStream + .word32be('addr.a') + .word32be('addr.b') + .word32be('addr.c') + .word32be('addr.d') + .tap((args) => { + args.dst.addr = []; + + // extract the parts of the ipv6 address + ['a', 'b', 'c', 'd'].forEach((x) => { + x = args.addr[x]; + + // convert DWORD to two WORD values and append + /* eslint no-magic-numbers : 0 */ + args.dst.addr.push((x >>> 16).toString(16)); + args.dst.addr.push((x & 0xffff).toString(16)); + }); + + // format ipv6 address as string + args.dst.addr = args.dst.addr.join(':'); + }); + + // unsupported address type + } else { + return end(RFC_1928_REPLIES.ADDRESS_TYPE_NOT_SUPPORTED, args); + } + }) + .word16bu('dst.port') + .tap((args) => { + if (args.cmd === RFC_1928_COMMANDS.CONNECT) { + let connectionFilter = self.options.connectionFilter; + const connectionFilterDomain = domain.create(); + + // if no connection filter is provided, stub one + if (!connectionFilter || typeof connectionFilter !== 'function') { + connectionFilter = (destination, origin, callback) => setImmediate(callback); + } + + // capture connection filter errors + connectionFilterDomain.on('error', (err) => { + // emit failed destination connection event + self.server.emit( + EVENTS.CONNECTION_FILTER, + // destination + { + address: args.dst.addr, + port: args.dst.port, + }, + // origin + { + address: socket.remoteAddress, + port: socket.remotePort, + }, + err, + ); + + // respond with failure + return end(RFC_1928_REPLIES.CONNECTION_NOT_ALLOWED, args); + }); + + // perform connection + return connectionFilter( + // destination + { + address: args.dst.addr, + port: args.dst.port, + }, + // origin + { + address: socket.remoteAddress, + port: socket.remotePort, + }, + connectionFilterDomain.intercept(() => { + const destination = net.createConnection( + args.dst.port, + args.dst.addr, + () => { + // prepare a success response + const responseBuffer = Buffer.alloc( + args.requestBuffer.length, + ); + args.requestBuffer.copy(responseBuffer); + responseBuffer[1] = RFC_1928_REPLIES.SUCCEEDED; + + // write acknowledgement to client... + socket.write(responseBuffer, () => { + // listen for data bi-directionally + destination.pipe(socket); + socket.pipe(destination); + + // configure idle timeout for destination socket + if ( + self.idleTimeout + && typeof destination.setTimeout === 'function' + ) { + destination.setTimeout(self.idleTimeout, () => { + try { + destination.destroy( + new Error('destination idle timeout'), + ); + } catch { + // ignore errors + } + }); + } + + // ensure proper teardown when either side ends/closes/errors + const teardownDestination = () => { + try { + destination.destroy(); + } catch { + // ignore socket destroy errors + } + }; + const teardownSocket = () => { + try { + socket.destroy(); + } catch { + // ignore socket destroy errors + } + }; + + socket.once('close', teardownDestination); + socket.once('end', teardownDestination); + socket.once('error', teardownDestination); + destination.once('error', teardownSocket); + }); + }, + ); + const destinationInfo = { + address: args.dst.addr, + port: args.dst.port, + }; + const originInfo = { + address: socket.remoteAddress, + port: socket.remotePort, + }; + + // capture successful connection + destination.on('connect', () => { + // emit connection event + self.server.emit( + EVENTS.PROXY_CONNECT, + destinationInfo, + destination, + ); + + // capture and emit proxied connection data + destination.on('data', (data) => { + self.server.emit(EVENTS.PROXY_DATA, data); + }); + + // capture close of destination and emit pending disconnect + // note: this event is only emitted once the destination socket is fully closed + destination.on('close', (hadError) => { + // indicate client connection end + self.server.emit( + EVENTS.PROXY_DISCONNECT, + originInfo, + destinationInfo, + hadError, + ); + }); + + connectionFilterDomain.exit(); + }); + + // capture connection errors and response appropriately + destination.on('error', (err) => { + // exit the connection filter domain + connectionFilterDomain.exit(); + + // notify of connection error + err.addr = args.dst.addr; + err.atyp = args.atyp; + err.port = args.dst.port; + + self.server.emit(EVENTS.PROXY_ERROR, err); + + if (err.code && err.code === 'EADDRNOTAVAIL') { + return end(RFC_1928_REPLIES.HOST_UNREACHABLE, args); + } + + if (err.code && err.code === 'ECONNREFUSED') { + return end(RFC_1928_REPLIES.CONNECTION_REFUSED, args); + } + + return end(RFC_1928_REPLIES.NETWORK_UNREACHABLE, args); + }); + }), + ); + } else { + // bind and udp associate commands + return end(RFC_1928_REPLIES.SUCCEEDED, args); + } + }); + } + + /** + * +----+-----+-------+------+----------+----------+ + * |VER | REP | RSV | ATYP | BND.ADDR | BND.PORT | + * +----+-----+-------+------+----------+----------+ + * | 1 | 1 | X'00' | 1 | Variable | 2 | + * +----+-----+-------+------+----------+----------+ + * + * @param {Buffer} response - a buffer representing the response + * @param {object} args - arguments to supply to the proxy end event + * @returns {undefined} + */ + function end(response, args) { + // either use the raw buffer (if available) or create a new one + const responseBuffer = args.requestBuffer || Buffer.allocUnsafe(2); + + if (!args.requestBuffer) { + responseBuffer[0] = RFC_1928_VERSION; + } + + responseBuffer[1] = response; + + // respond then end the connection + try { + socket.end(responseBuffer); + } catch { + socket.destroy(); + } + + // indicate end of connection + self.server.emit(EVENTS.PROXY_END, response, args); + } + + /** + * +----+----------+----------+ + * |VER | NMETHODS | METHODS | + * +----+----------+----------+ + * | 1 | 1 | 1 to 255 | + * +----+----------+----------+ + * + * @param {Buffer} buffer - a buffer + * @returns {undefined} + */ + function handshake(buffer) { + binary + .stream(buffer) + .word8('ver') + .word8('nmethods') + .buffer('methods', 'nmethods') + .tap((args) => { + // verify version is appropriate + if (args.ver !== RFC_1928_VERSION) { + return end(RFC_1928_REPLIES.GENERAL_FAILURE, args); + } + + // convert methods buffer to an array + const acceptedMethods = [].slice + .call(args.methods) + .reduce((methods, method) => { + methods[method] = true; + return methods; + }, {}); + const basicAuth = typeof self.options.authenticate === 'function'; + const clientSupportsBasic = typeof acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION] + !== 'undefined' + && acceptedMethods[RFC_1928_METHODS.BASIC_AUTHENTICATION]; + const clientSupportsGss = typeof acceptedMethods[RFC_1928_METHODS.GSSAPI] !== 'undefined' + && acceptedMethods[RFC_1928_METHODS.GSSAPI]; + const clientSupportsNoAuth = typeof acceptedMethods[ + RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED + ] !== 'undefined' + && acceptedMethods[RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED]; + let next = connect; + const responseBuffer = Buffer.allocUnsafe(2); + const serverSupportsGss = Boolean( + self.options.gssapi + && self.options.gssapi.enabled + && self.options.gssapi.provider, + ); + + // form response Buffer + responseBuffer[0] = RFC_1928_VERSION; + responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; + + // prefer GSSAPI when enabled and mutually supported + if (serverSupportsGss && clientSupportsGss) { + responseBuffer[1] = RFC_1928_METHODS.GSSAPI; + next = gssapi; + + // check for basic auth configuration and mutual support + } else if (basicAuth && clientSupportsBasic) { + responseBuffer[1] = RFC_1928_METHODS.BASIC_AUTHENTICATION; + next = authenticate; + + // if NO AUTHENTICATION REQUIRED and client supports it + } else if (!basicAuth && clientSupportsNoAuth) { + responseBuffer[1] = RFC_1928_METHODS.NO_AUTHENTICATION_REQUIRED; + next = connect; + + // basic auth callback not provided and no auth is not supported + } else { + return end(RFC_1928_METHODS.NO_ACCEPTABLE_METHODS, args); + } + + // respond then listen for cmd and dst info + socket.write(responseBuffer, () => { + // emit handshake event + self.server.emit(EVENTS.HANDSHAKE, socket); + + // now listen for more details + socket.once('data', next); + }); + }); + } + + // capture the client handshake + socket.once('data', handshake); + + // capture socket closure + socket.once('end', removeActiveSession); + socket.once('close', removeActiveSession); + }); + } } export default { - SocksServer, - createServer: (options) => { - const socksServer = new SocksServer(options); - return socksServer.server; - }, - events: EVENTS, + SocksServer, + createServer: (options) => { + const socksServer = new SocksServer(options); + return socksServer.server; + }, + events: EVENTS, };