mikemintz/rethinkdb-websocket-server

Support rethinkdb auth key

Closed this issue · 25 comments

We have some basic security on our RethinkDB instances with the built-in authKey support for securing drivers. However, this library doesn't seem to support that.

@coffenbacher good point. I'll see if I can get that working.

@coffenbacher Do you think it makes more sense to have rethinkdb-websocket-client send the auth key when it connects, or use a statically configured key only on the server side?

Having the client send the key makes more sense from the perspective of this being a light-weight websocket wrapper around the actual protocol. But I don't really understand the use case of having auth keys at all, since the rethinkdb server should be firewalled so only allowed clients can connect anyway.

opsb commented

@mikemintz if your rethinkdb is cloud hosted (I'm looking at compose.io) then firewalling your rethindkb server isn't an option.

@opsb Got it. So for that use case, can you confirm you would want rethinkdb-websocket-server to inject a server-side configured auth token when connecting to rethinkdb, not have rethinkdb-websocket-client specify one?

@opsb Is there any reason you wouldn't use ssh for compose.io on your server that connects to the cloud? It seems far more secure than sending the auth key over the wire.

opsb commented

@mikemintz that was what I was thinking, ssh for compose.io might work, was planning on using heroku for hosting though so I'd have to check if that would be possible.

@mikemintz Fair enough, and we do use firewalls on our main clusters. So if you want to close this as a Won't-Fix that's fine! At least it will be documented per this issue 👍

(The only reason I was using an auth key here is for a little experiment that would be fine to get compromised / destroyed...just a little inconvenience. Premature bothering to secure that properly so I just slapped something simple on. I'm not sure if people have actual uses for auth key, although since it's officially supported there may be some reason.)

Got it, thanks for the feedback @opsb and @coffenbacher. I will leave this open for the time being, to see if anyone else has use cases they want to comment on, to determine if it's better to send the authKey from the client or configure it in the server.

I am trying to get this working with Compose.io as well, and have been banging my head against authKey and/or SSH for the past couple days. @opsb and @coffenbacher, did you get SSH working? I tried using the tunnel.py script recommended by Compose.io, but I get two problems. First, my id.rsa has some extra headers (Proc-Type: 4,ENCRYPTED and DEK-Info: AES-128-CBC,BD2A679A8905EC4245A2C82782F2FEA5, and I don't really understand why he's base-64-decoding and re-wrapping anyway - the whole id.rsa can be pasted right into Herok config vars with newlines as-is), and second, after binding the first local port, ssh complains when trying to bind the .2 and .3 local IPs on the same port.

As for using authKey, it seems to me that the RethinkDB connection definition is completely on the server side in these rethinkdb-websocket-* projects. As such, I simply extended the object sent to the Promise-wrapped r.connect in your "Involved Example" to include the authKey and ssl: ca: Buffer that are now required by Compose.io, getting the authKey from a Heroku config var (process.env.DB_AUTH_KEY), and the cacert read from disk with fs (it's a public key, so I just included it in the build that was pushed to Heroku).

I finally got to the point where I'm not getting connection errors (or any errors) in my Heroku logs, and if I console.log the RethinkDB connection object server-side after connecting to Compose.io, I see a connection in good state in my Heroku logs. But, I get timeouts when trying to perform any queries through the client.

Moreover, I get the same client-side timeout when connecting through the Websocket client and server from my local machine, yet the same configuration works from the node command-line without using the Websocket server/client, with the same good-state connection object in my Node console. Here's the exact error from my Chrome console:

Unhandled rejection ReqlTimeoutError: Could not connect to mivid-stack.herokuapp.com:443, operation timed out.
at ReqlTimeoutError.ReqlError as constructor
at new ReqlTimeoutError (https://mivid-stack.herokuapp.com/bundle.js:24849:52)
at https://mivid-stack.herokuapp.com/bundle.js:23359:37

You can run this yourself at https://mivid-stack.herokuapp.com/#turtles (click "Get Turtles"), and you may view the source for my implementation at https://github.com/mividtim/mivid-stack (see /src/service/app.coffee and /src/client/routes/turtles/turtles.coffee).

I guess this is something that needs to be added to the client after all? It would be great if it could be injected by the server, if so. I plan on having the client open to the world for use, so including the authKey from the client is a non-starter. I'd have to skip ReQL from the browser, which would be a total bummer. The configuration works great against my local RethinkDB without authKey, and now that I've tasted it, I just can't stop drinking! ;-)

Thanks for the awesome contribution with these projects!

@mividtim thanks for testing all of this with compose, it's great to validate this runs in more environments.

I don't know anything about the SSH tunnel, other than that it looks like the recommended solution and shouldn't require an authKey if it's used, right?

As for using authKey, it seems to me that the RethinkDB connection definition is completely on the server side in these rethinkdb-websocket-* projects. As such, I simply extended the object sent to the Promise-wrapped r.connect in your "Involved Example" to include the authKey and ssl: ca: Buffer that are now required by Compose.io, getting the authKey from a Heroku config var (process.env.DB_AUTH_KEY), and the cacert read from disk with fs (it's a public key, so I just included it in the build that was pushed to Heroku).

I think there might be some confusion here. The r.connect object in "Involved Example" is not the same connection that the browser ultimately connects with. That's just custom code for the example, to demonstrate the server running its own validations in response to seeing queries from the browser.

So in that example, for a given request, there are 2 connections to rethinkdb: (1) from the browser that goes through the rethinkdb-websocket-server "proxy", and (2) directly from rethinkdb-websocket-server to rethinkdb that is only used for runQuery(userQuery) and runQuery(validHerdQuery).

Presumably, when you added authKey to r.connect, that only affected connection 2, so connection 1 was still connecting without an authKey.

yet the same configuration works from the node command-line without using the Websocket server/client

Can you confirm you mean you can run an ordinary r.connect on node.js to mivid-stack.herokuapp.com using the authKey? And if you omit authKey, do you also get ReqlTimeoutError?

I guess this is something that needs to be added to the client after all? It would be great if it could be injected by the server, if so. I plan on having the client open to the world for use, so including the authKey from the client is a non-starter.

I agree that makes sense. I'll go ahead and implement something on rethinkdb-websocket-server that would look like this below, can you confirm that would enable this use case to work?

var options = {
  dbHost: 'rethink01.example.com',
  dbPort: 28015,
  authKey: '12345',
  ...
};
RethinkdbWebsocketServer.listen(options);

Ahh! I see. I didn't realize there were two different connections going on.

Yes, that parameter would do it. I actually started down this road a bit yesterday. This code doesn't work yet (afaik - now I realize that I have to get my cacert Buffer into the connection options client-side, as well), but it might get you started. It seems that the size goes into the second four bytes, and the auth key goes in starting at byte 8 up to that size. Does this handshake only happen during connection, or does it happen on every query?

+++ b/src/Connection.js
@@ -18,7 +18,7 @@ export class Connection {
     this.remotePort = webSocket._socket.remotePort;
   }
 
-  start({sessionCreator, dbHost, dbPort}) {
+  start({sessionCreator, dbHost, dbPort, dbAuthKey}) {
     const urlQueryParams = url.parse(this.webSocket.upgradeReq.url, true).query;
     this.sessionPromise = sessionCreator(urlQueryParams).catch(e => {
       this.cleanupAndLogErr('Error in sessionCreator', e);
@@ -27,6 +27,9 @@ export class Connection {
     this.handshakeComplete = false;
     this.isClosed = false;
     this.dbSocket = net.createConnection(dbPort, dbHost);
+    if (dbAuthKey !== null) {
+      this.dbAuthKeyBytes = new Buffer(dbAuthKey, 'base64');
+    }
     this.setupDbSocket();
     this.setupWebSocket();
     if (this.loggingMode === 'all') {
@@ -151,7 +154,17 @@ export class Connection {
           return 0;
         }
         const handshakeLength = 12 + keyLength;
-        this.dbSocket.write(buf.slice(0, handshakeLength), 'binary');
+        let outBuf;
+        if (typeof this.dbAuthKeyBytes !== undefined) {
+          buf.writeUInt32LE(this.dbAuthKeyBytes.length, 4);
+          const startBuf = buf.slice(0, handshakeLength);
+          const authKeyBuf = new Buffer(this.dbAuthKeyBytes.length);
+          authKeyBuf.writeUInt32LE(this.dbAuthKeyBytes, 0);
+          outBuf = Buffer.concat([startBuf, authKeyBuf]);
+        } else {
+          outBuf = buf.slice(0, handshakeLength);
+        }
+        this.dbSocket.write(outBuf, outBuf.length, 'binary');
         this.handshakeComplete = true;
         return handshakeLength;
       }
diff --git a/src/index.js b/src/index.js
index ff2db7a..e609371 100644
--- a/src/index.js
+++ b/src/index.js
@@ -38,6 +38,9 @@ export function listen({
   // RethinkDB port to connect to
   dbPort = 28015,
 
+  // RethinkDB authKey: null if not required
+  dbAuthKey = null,
+
   // List of pattern RQs, where an incoming query must match at least one
   // (see QueryValidator.js)
   queryWhitelist = [],
@@ -69,6 +72,6 @@ export function listen({
   });
   wsServer.on('connection', webSocket => {
     const connection = new Connection(queryValidator, webSocket, loggingMode);
-    connection.start({sessionCreator, dbHost, dbPort});
+    connection.start({sessionCreator, dbHost, dbPort, dbAuthKey});
   });
 }

Can you confirm you mean you can run an ordinary r.connect on node.js to mivid-stack.herokuapp.com using the authKey? And if you omit authKey, do you also get ReqlTimeoutError?

Sort of. Heroku doesn't host RethinkDB... Compose.io does. But yes, if I use the node command prompt to connect to RethinkDB hosted on Compose.io using the authKey (along with passing the public cacert from Compose on the connection), it works. I can query Rethink on the server and get results. Going through the Websocket doesn't. Now that I understand the authKey needs to be injected, I understand fully why this is the case. :-)

Thanks for posting your code, that helps a lot. I'll have time tomorrow to implement it, but if you wanted to try to get it to work in the meantime, I think you're sending the handshake incorrectly, since it looks like you're still starting with the 12 bytes that the client sent (without the auth token). Take a look at https://rethinkdb.com/docs/writing-drivers/ under "Perform a handshake", you'll probably want to ignore what the client sent and just reconstruct a handshake buffer (4 bytes "V0_4", 4 bytes auth key length, n bytes auth key (I'm not sure if it's actually base64), 4 bytes JSON). The handshake only happens once on connect.

For cacert, I'll also need to add an option just like authkey to allow it to be configured on the server-side, right?

HI Mike,

I just sent a pull request. It's still not working. I may not have the proper understanding here, but I'm sending the size 32-bit little-endian, and the auth key in ASCII (new Buffer(string, exactly as copied out of Compose.io page, and set into Heroku config var)), followed by the JSON string (protocol version and JSON string taken directly from the original Buffer), then telling the client to skip 12 bytes (unchanged - we still don't support authKey sent from client as this would be insecure).

Compose.io has a free month trial, if you're up for it. I'd /love/ to get this working.

The cacert is a public key, so I have no problem sending the asset to the client (HTTP), which uses Browserify's Buffer and sending it through myself. In short, it's not insecure to send the cert to the client, so I don't think it needs to be handled on the server. That said, I do also read it with Node's fs on the server, for use in the "other" Rethink connection for validations.

Then again, perhaps I'm wrong, and this is why my connection isn't working. ;-) You may need to handle the cert server-side as well.

Thanks a million!

Tim

I actually just changed the net socket to a TLS socket. I'll send another pull request. Still not working, but at least the socket Connect stay open! I think the cacert is working, and the issue is just at the protocol level now.

@mividtim I just pushed support for dbAuthKey, and tested it locally and it works. Do you want to pull from that, and submit a new PR with just the tls/cacert stuff? I'd be happy to merge that in. Make sure to just commit changes to src/ and not dist/.

@mikemintz Just updated my branch to include CA cert checking and SSL support. I also re-added dist to gitignore and removed it from the repo. Was having difficulty testing in my own project. Been using git for a few years, but not any of this fancy pull-request and src/dist stuff. I'll get a hang of it! :-D

Thanks for offering to accept a pull request. This will be my first!

Cheers

@mikemintz I pushed up the single-parameter version, squashed. It works on my localhost, through ws:, but not from Heroku through wss:. Same exact code and parameters passed. Here's the version on Heroku that times out:

https://mivid-stack.herokuapp.com/#turtles (Click "Get Turtles")

The timeout is straight from the internals of RethinkDB in the browser:

Unhandled rejection ReqlTimeoutError: Could not connect to mivid-stack.herokuapp.com:443, operation timed out.
at ReqlTimeoutError.ReqlError as constructor
at new ReqlTimeoutError (https://mivid-stack.herokuapp.com/index.js:24862:52)
at https://mivid-stack.herokuapp.com/index.js:23372:37

(I really need to fix my sourcemap generation.)

There are no errors in the logs server-side.

I confirmed that this is an issue with using the "secure" option on the client, along with "dbSsl" on the server. I created a self-signed cert for localhost, trusted it, used it in Express, and tested out the scenario locally. Same error I get on Heroku - a timeout. I'm missing something here, logically. I would have figured that the result of using ws or wss would be the same, before tunneling through to RethinkDB over TLS, since the server should be effectively decrypting the data from the websocket before sending it out to Rethink over TLS - there should be no effective difference. Yes?

@mividtim Yes you're right, there should be no effective difference between ws and wss, since it'll be decrypted and sent to compose/rethinkdb in the same way.

What do you see in the rethinkdb-websocket-server console when you attempt to connect with wss? Does it see an incoming connect?

@mikemintz Yes, it sees the connection. I actually got this working on localhost through WSS to Compose.io, so the issue is strictly isolated to Heroku. I believe the "real" reason here is that I don't have a dist branch on my fork, so the dist folder is empty when Heroku pulls the dependency from Github. I think it will work once I complete that bit of detail. Moreover, once you accept my pull request (assuming things are good), I can re-point my package.json at the upstream (your repo), and it should "just work."

Thanks again!

@mikemintz I just created my dist branch, altered my package.json to reference it, and confirmed this works on Heroku! Details, details...

Thanks for all your help. I'm looking forward to using this going forward, and I hope that the authKey and TLS options features help others as well.

Cheers!

@mividtim Glad to hear it. For future reference, I suspect your dist folder was empty because in package.json we only run babel in the prepublish step. If we changed it from prepublish to postinstall, it would probably happen on heroku deploy. But I think prepublish is the correct place to do it for publishing an npm library.

I agree. Seems lots of folks have competing interests in what belongs in a Github repo. :-) This should be a short-lived fork, anyhow. Thanks again.

RethinkDB authKey and ssl options are now merged in for version 0.4.1, thanks @mividtim! See the dbAuthKey and dbSsl options in RethinkdbWebsocketServer.listen().