Skip to content

Commit

Permalink
feat(NODE-3633): add Socks5 support (#220)
Browse files Browse the repository at this point in the history
  • Loading branch information
addaleax committed Dec 17, 2021
1 parent f13cf78 commit b00850a
Show file tree
Hide file tree
Showing 7 changed files with 203 additions and 41 deletions.
15 changes: 15 additions & 0 deletions bindings/node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ export type ClientEncryptionDataKeyProvider = 'aws' | 'azure' | 'gcp' | 'local'
export class MongoCryptError extends Error {
}

/**
* A set of options for specifying a Socks5 proxy.
*/
export interface ProxyOptions {
host: string;
port?: number;
username?: string;
password?: string;
}

export interface ClientEncryptionCreateDataKeyCallback {
/**
* @param error If present, indicates an error that occurred in the creation of the data key
Expand Down Expand Up @@ -185,6 +195,11 @@ export interface ClientEncryptionOptions {
*/
kmsProviders?: KMSProviders;

/**
* Options for specifying a Socks5 proxy to use for connecting to the KMS.
*/
proxyOptions?: ProxyOptions;

/**
* TLS options for kms providers to use.
*/
Expand Down
4 changes: 2 additions & 2 deletions bindings/node/lib/autoEncrypter.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ module.exports = function(modules) {
context.ns = ns;
context.document = cmd;

const stateMachine = new StateMachine(Object.assign({ bson }, options));
const stateMachine = new StateMachine({ bson, ...options, proxyOptions: this._proxyOptions });
stateMachine.execute(this, context, callback);
}

Expand Down Expand Up @@ -243,7 +243,7 @@ module.exports = function(modules) {
// TODO: should this be an accessor from the addon?
context.id = this._contextCounter++;

const stateMachine = new StateMachine(Object.assign({ bson }, options));
const stateMachine = new StateMachine({ bson, ...options, proxyOptions: this._proxyOptions });
stateMachine.execute(this, context, callback);
}
}
Expand Down
7 changes: 4 additions & 3 deletions bindings/node/lib/clientEncryption.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ module.exports = function(modules) {
constructor(client, options) {
this._client = client;
this._bson = options.bson || client.topology.bson;
this._proxyOptions = options.proxyOptions;

if (options.keyVaultNamespace == null) {
throw new TypeError('Missing required option `keyVaultNamespace`');
Expand Down Expand Up @@ -198,7 +199,7 @@ module.exports = function(modules) {

const dataKeyBson = bson.serialize(dataKey);
const context = this._mongoCrypt.makeDataKeyContext(dataKeyBson, { keyAltNames });
const stateMachine = new StateMachine({ bson });
const stateMachine = new StateMachine({ bson, proxyOptions: this._proxyOptions });

return promiseOrCallback(callback, cb => {
stateMachine.execute(this, context, (err, dataKey) => {
Expand Down Expand Up @@ -290,7 +291,7 @@ module.exports = function(modules) {
contextOptions.keyAltName = bson.serialize({ keyAltName });
}

const stateMachine = new StateMachine({ bson });
const stateMachine = new StateMachine({ bson, proxyOptions: this._proxyOptions });
const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions);

return promiseOrCallback(callback, cb => {
Expand Down Expand Up @@ -335,7 +336,7 @@ module.exports = function(modules) {
const valueBuffer = bson.serialize({ v: value });
const context = this._mongoCrypt.makeExplicitDecryptionContext(valueBuffer);

const stateMachine = new StateMachine({ bson });
const stateMachine = new StateMachine({ bson, proxyOptions: this._proxyOptions });

return promiseOrCallback(callback, cb => {
stateMachine.execute(this, context, (err, result) => {
Expand Down
69 changes: 57 additions & 12 deletions bindings/node/lib/stateMachine.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

module.exports = function(modules) {
const tls = require('tls');
const net = require('net');
const { once } = require('events');
const { SocksClient } = require('socks');

// Try first to import 4.x name, fallback to 3.x name
const MongoNetworkTimeoutError =
Expand Down Expand Up @@ -224,27 +227,69 @@ module.exports = function(modules) {
const options = { host: parsedUrl[0], servername: parsedUrl[0], port };
const message = request.message;

return new Promise((resolve, reject) => {
return new Promise(async (resolve, reject) => {
const buffer = new BufferList();
const socket = tls.connect(options, () => {
socket.write(message);
});

socket.once('timeout', () => {
socket.removeAllListeners();
socket.destroy();
reject(new MongoCryptError('KMS request timed out'));
});
let socket;
let rawSocket;

function destroySockets() {
for (const sock of [socket, rawSocket]) {
if (sock) {
sock.removeAllListeners();
sock.destroy();
}
}
}

socket.once('error', err => {
socket.removeAllListeners();
socket.destroy();
function ontimeout() {
destroySockets();
reject(new MongoCryptError('KMS request timed out'));
}

function onerror(err) {
destroySockets();
const mcError = new MongoCryptError('KMS request failed');
mcError.originalError = err;
reject(mcError);
}

if (this.options.proxyOptions && this.options.proxyOptions.host) {
rawSocket = net.connect({
host: this.options.proxyOptions.host,
port: this.options.proxyOptions.port || 1080
});

rawSocket.on('timeout', ontimeout);
rawSocket.on('error', onerror);
try {
await once(rawSocket, 'connect');
options.socket = (
await SocksClient.createConnection({
existing_socket: rawSocket,
command: 'connect',
destination: { host: options.host, port: options.port },
proxy: {
host: 'locahost',
port: 0,
type: 5,
userId: this.options.proxyOptions.username,
password: this.options.proxyOptions.username
}
})
).socket;
} catch (err) {
return onerror(err);
}
}

socket = tls.connect(options, () => {
socket.write(message);
});

socket.once('timeout', ontimeout);
socket.once('error', onerror);

socket.on('data', data => {
buffer.append(data);
while (request.bytesNeeded > 0 && buffer.length) {
Expand Down
51 changes: 49 additions & 2 deletions bindings/node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion bindings/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
"bindings": "^1.5.0",
"bl": "^2.2.1",
"node-addon-api": "^4.1.0",
"prebuild-install": "6.1.2"
"prebuild-install": "6.1.2",
"socks": "^2.6.1"
},
"devDependencies": {
"bson": "^4.4.0",
Expand Down
95 changes: 74 additions & 21 deletions bindings/node/test/stateMachine.test.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
'use strict';

const BSON = require('bson');
const EventEmitter = require('events').EventEmitter;
const { EventEmitter, once } = require('events');
const net = require('net');
const tls = require('tls');
const expect = require('chai').expect;
const sinon = require('sinon');
const mongodb = require('mongodb');
const StateMachine = require('../lib/stateMachine')({ mongodb }).StateMachine;

describe('StateMachine', function() {
describe('kmsRequest', function() {
class MockRequest {
constructor(message, bytesNeeded) {
this._bytesNeeded = typeof bytesNeeded === 'number' ? bytesNeeded : 1024;
this._message = message;
this.endpoint = 'some.fake.host.com';
this._kmsProvider = 'aws';
}
get message() {
return this._message;
}
class MockRequest {
constructor(message, bytesNeeded) {
this._bytesNeeded = typeof bytesNeeded === 'number' ? bytesNeeded : 1024;
this._message = message;
this.endpoint = 'some.fake.host.com';
this._kmsProvider = 'aws';
}

get bytesNeeded() {
return this._bytesNeeded;
}
get message() {
return this._message;
}

get kmsProvider() {
return this._kmsProvider;
}
get bytesNeeded() {
return this._bytesNeeded;
}

addResponse(buffer) {
this._bytesNeeded -= buffer.length;
}
get kmsProvider() {
return this._kmsProvider;
}

addResponse(buffer) {
this._bytesNeeded -= buffer.length;
}
}

describe('kmsRequest', function() {
class MockSocket extends EventEmitter {
constructor(callback) {
super();
Expand Down Expand Up @@ -93,4 +95,55 @@ describe('StateMachine', function() {
this.sinon.restore();
});
});

describe('Socks5 support', function() {
let socks5srv;
let hasTlsConnection;

beforeEach(async () => {
hasTlsConnection = false;
socks5srv = net.createServer(async(socket) => {
expect(await once(socket, 'data')).to.deep.equal([Buffer.from('05020002', 'hex')]);
socket.write(Buffer.from('0500', 'hex'));
expect(await once(socket, 'data')).to.deep.equal([Buffer.concat([
Buffer.from('0501000312', 'hex'),
Buffer.from('some.fake.host.com'),
Buffer.from('01bb', 'hex')
])]);
socket.write(Buffer.from('0500007f0000010100', 'hex'));
expect((await once(socket, 'data'))[0][1]).to.equal(3); // TLS handshake version byte
hasTlsConnection = true;
socket.end();
});
socks5srv.listen(0);
await once(socks5srv, 'listening');
});

afterEach(() => {
socks5srv.close();
});

it('should create HTTPS connections through a Socks5 proxy', async function() {
const stateMachine = new StateMachine({
bson: BSON,
proxyOptions: {
host: 'localhost',
port: socks5srv.address().port,
username: 'foo',
password: 'bar'
}
});

const request = new MockRequest(Buffer.from('foobar'), 500);
try {
await stateMachine.kmsRequest(request);
} catch (err) {
expect(err.name).to.equal('MongoCryptError');
expect(err.originalError.code).to.equal('ECONNRESET');
expect(hasTlsConnection).to.equal(true);
return;
}
expect.fail('missed exception');
});
});
});

0 comments on commit b00850a

Please sign in to comment.