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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "simple-socks",
"version": "3.3.1",
"version": "3.4.0",
"description": "proxies requests ",
"type": "module",
"main": "src/socks5.js",
Expand Down
37 changes: 37 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
74 changes: 59 additions & 15 deletions src/socks5.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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')
Expand All @@ -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);
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;

Expand All @@ -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
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down