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/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..0401f1c 100644 --- a/src/socks5.js +++ b/src/socks5.js @@ -38,6 +38,15 @@ class SocksServer { 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) => { @@ -76,6 +85,14 @@ class SocksServer { 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') @@ -93,7 +110,10 @@ class SocksServer { } // per RFC 1929, username and password lengths must be 1..255 - if (!args.ulen || !args.plen) { + if ( + (!allowEmptyUsername && !args.ulen) + || (!allowEmptyPassword && !args.plen) + ) { return end(RFC_1929_REPLIES.GENERAL_FAILURE, args); } @@ -150,7 +170,9 @@ class SocksServer { 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')); + return socket.destroy( + new Error('GSSAPI requested but no provider configured'), + ); } try { @@ -298,7 +320,9 @@ class SocksServer { args.dst.addr, () => { // prepare a success response - const responseBuffer = Buffer.alloc(args.requestBuffer.length); + const responseBuffer = Buffer.alloc( + args.requestBuffer.length, + ); args.requestBuffer.copy(responseBuffer); responseBuffer[1] = RFC_1928_REPLIES.SUCCEEDED; @@ -309,10 +333,15 @@ class SocksServer { socket.pipe(destination); // configure idle timeout for destination socket - if (self.idleTimeout && typeof destination.setTimeout === 'function') { + if ( + self.idleTimeout + && typeof destination.setTimeout === 'function' + ) { destination.setTimeout(self.idleTimeout, () => { try { - destination.destroy(new Error('destination idle timeout')); + destination.destroy( + new Error('destination idle timeout'), + ); } catch { // ignore errors } @@ -354,7 +383,11 @@ class SocksServer { // capture successful connection destination.on('connect', () => { // emit connection event - self.server.emit(EVENTS.PROXY_CONNECT, destinationInfo, destination); + self.server.emit( + EVENTS.PROXY_CONNECT, + destinationInfo, + destination, + ); // capture and emit proxied connection data destination.on('data', (data) => { @@ -365,7 +398,12 @@ class SocksServer { // 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); + self.server.emit( + EVENTS.PROXY_DISCONNECT, + originInfo, + destinationInfo, + hadError, + ); }); connectionFilterDomain.exit(); @@ -457,22 +495,28 @@ class SocksServer { } // convert methods buffer to an array - const acceptedMethods = [].slice.call(args.methods).reduce((methods, method) => { - methods[method] = true; - return methods; - }, {}); + 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' + 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' + 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, + self.options.gssapi + && self.options.gssapi.enabled + && self.options.gssapi.provider, ); // form response Buffer 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);