Dev help: modifying docservice to manage session/auth cookies

TL;DR:

  1. When passing headers to taskqueueRabbitMQ.js::addTaskString, they aren’t sent to the destination URL. Why not?
  2. How do I access the response headers returned to taskqueueRabbitMQ.js::addTaskString?

The full story:

This is a bit of an advanced question. The full context is that I am using Nextcloud with per-user encryption keys (plus Keycloak to manage sessions). OnlyOffice supports full encryption, but not per-user encryption. To do so, OnlyOffice Document Server needs to send and receive session cookies for the user.

The requests that occur when opening a document from NC go like this:

1. sdk-all-min.js -(websocket)-> oo.domainname.com -(POST)-> nc.domainname.com
2.                               oo.domainname.com <       - nc.domainname.com
3.                               oo.domainname.com -(amqp)-> nc.domainname.com
4.                               oo.domainname.com <       - nc.domainname.com

To include the session cookies with the first request, I’ve written a plugin for NC that encrypts all session cookies with the JWT secret and sets it as an insecure cookie. Then, I modified sdk-all-min.js to send the cookie with the WS message. The JWT secret is known by both OO and NC, so I can decrypt the session cookies, attach them to the POST request made to NC, and I receive a successful response from NC (response: 2).

For request (3), I modified the DocsCoServer.js::addTask function and added the session cookie string as a header:

function* addTask(data, priority, opt_queue, opt_expiration) {
  const opt_headers = {'Cookie': 'param=val'};
  var realQueue = opt_queue ? opt_queue : queue;
  yield realQueue.addTask(data, priority, opt_expiration, opt_headers);
}

This is where the trouble beings. I have traced the cookie all the way through the amqp stack, and the values are still there. However, when the requests reach NC, there are no cookies present. Why?

With response (4), NC updates the session cookie values, and I need to capture them when they are returned to OO. I believe the response is handled by the taskqueueRabbitMQ.js::addTaskString function via resolve(). I have tried adding parameters to resolve() and a then() block at the end of the promise, but I don’t see the amqp response headers anywhere. How can I access them?

If I can figure out these last two steps, I would be glad to share my code and possibly open a PR on GitHub in order to support per-user encryption with NC.

Hello,
We are discussing the case internally. I will inform you when we have some info to share.

Thanks, Carl. When I posted, I had never used RabbitMQ and didn’t fully grasp how DocsCoServer and FileConverter interacted. I actually wound up getting this to work with a little hackery. Here is the basic process:

  1. Create a NC app that encrypts the user’s session cookies with the JWT passphrase and sets this data as an insecure cookie.
  2. Modify sdkjs/common/docscoapi.js::DocsCoApi.prototype.auth to add a 'sessionData': document.cookie field to the payload. (OnlyOffice uses websockets, but cookies are not sent with websockets. This sends the encrypted session cookies from step 1 to the server as part of the websocket body).
  3. Add the following object and a variable to store the session data to utils.js:
    https://github.com/brainfoolong/cryptojs-aes-php/blob/master/dist/cryptojs-aes-format.js
  4. Modify the following functions to set, get, receive, and pass the encrypted cookie data throughout the system as necessary:
    DocsCoServer.js::addTask
    DocsCoServer.js::exports.install (under conn.on)
    FileConverter.js::receiveTask
    utils.js::downloadUrlPromise
    utils.js::postRequestPromise
    taskqueueRabbitMQ.js::TaskQueueRabbitMQ.prototype.addTask

I have a working system that integrates perfectly with Keycloak and NC with per-user encryption enabled (occ encryption:enable-master-key disable). It is a unique but extremely secure configuration. I would be happy to share my code if your team is interested.

Thanks!

Edit: here’s a really hacky bash script that should patch the OnlyOffice sources before build. I could have used a patch file, but I wanted to write it in a way that would keep things immune from updates to the files down the road.

sed "s#\( \+\)\('indexUser': this._indexUser,\)#\1\2\n\1'sessionData': document.cookie#g" sdkjs/common/docscoapi.js
sed "s#\( \+\)\(\"content-disposition\": \"^0.5.3\",\)#\1\2\n\1\"crypto-js\": \"^4.1.1\",#g" server/Common/package.jsonsed "s#\(function\* addTask(data, priority, opt_queue, opt_expiration) {\)#\1\n  data.sessionData = utils.SessionData.get(1);#g" server/DocService/sources/DocsCoServer.js
sed "s#\( \+\)\(logger.info('data.type = ' + data.type + ' id = ' + docId);\)#\n\1if (typeof data.user !== 'undefined') {\n\1  utils.SessionData.set(data.user.sessionData);\n\1}\n\n\1\2#g" server/DocService/sources/DocsCoServer.js
sed "s#\( \+\)\(task = new commonDefines.TaskQueueData(JSON.parse(data));\)#\1var payload = JSON.parse(data);\n\1if (payload.sessionData != null) {\n\1  utils.SessionData.set(payload.sessionData);\n\1}\n\n\1\2#g" server/FileConverter/sources/converter.js
sed "s#\(TaskQueueRabbitMQ.prototype.addTask = function (task, priority, opt_expiration, opt_headers) {\)#\1\n  task.sessionData = utils.SessionData.get(1);#g" server/Common/sources/taskqueueRabbitMQ.js
echo "exports.SessionData = SessionData;" >> server/Common/source/utils.js

read -d '' block << EOF
  if (! opt_headers) {
    var opt_headers = {};
  }
  opt_headers['Cookie'] = this.SessionData.get();

  if (opt_Authorization) {
    opt_headers[cfgTokenOutboxHeader] = cfgTokenOutboxPrefix + opt_Authorization;
  }
EOF
block=$(echo "$block" | sed ':a $!{N; ba}; s/\n/\\n/g')
sed "s#\(function downloadUrlPromise(uri, optTimeout, optLimit, opt_Authorization, opt_filterPrivate, opt_headers) {\)#\1\n  $block\n#g" server/Common/source/utils.js


read -d '' block << EOF
  var cookies = "";
  if (this.SessionData.get() != null) {
    cookies = this.SessionData.get();
  }
EOF
block=$(echo "$block" | sed ':a $!{N; ba}; s/\n/\\n/g')
sed "s#\(function postRequestPromise(uri, postData, postDataStream, optTimeout, opt_Authorization, opt_header) {\)#\1\n  $block\n#g" server/Common/source/utils.js


read -d '' block << EOF
var CryptoJS = require('crypto-js');

/**
 * Adapted from:
 * AES JSON formatter for CryptoJS
 * @link https://github.com/brainfoolong/cryptojs-aes-php
 * @version 2.1.1
 */

 var SessionData = {
  sessionData: null,
  secret: null,
  cookieName: null,

  /**
   * Encrypt any value
   * @param {*} value
   * @param {string} password
   * @return {string}
   */
  'encrypt': function (value, password = this.secret) {
    const ciphertext = CryptoJS.AES.encrypt(JSON.stringify(value), password, { format: SessionData }).toString();
    const buff = Buffer.from(ciphertext, 'utf-8');
    const base64 = buff.toString('base64');
    return base64;
  },
  /**
   * Decrypt a previously encrypted value
   * @param {string} jsonStr
   * @param {string} password
   * @return {*}
   */
  'decrypt': function (data, password = this.secret) {
    this.cookieName = data.split('=')[0];
    const buff = Buffer.from(data.split(';')[0].split('=')[1], 'base64');
    var jsonStr = buff.toString('utf-8');

    try {
      return JSON.parse(CryptoJS.AES.decrypt(jsonStr, password, { format: SessionData }).toString(CryptoJS.enc.Utf8))
    } catch (err) {
      jsonStr = jsonStr.substr(0, jsonStr.lastIndexOf("\}") + 1);
      return JSON.parse(CryptoJS.AES.decrypt(jsonStr, password, { format: SessionData }).toString(CryptoJS.enc.Utf8))
    }
  },
  /**
   * Stringify cryptojs data
   * @param {Object} cipherParams
   * @return {string}
   */
  'stringify': function (cipherParams) {
    var j = { ct: cipherParams.ciphertext.toString(CryptoJS.enc.Base64) }
    if (cipherParams.iv) j.iv = cipherParams.iv.toString()
    if (cipherParams.salt) j.s = cipherParams.salt.toString()
    return JSON.stringify(j).replace(/\s/g, '')
  },
  /**
   * Parse cryptojs data
   * @param {string} jsonStr
   * @return {*}
   */
  'parse': function (jsonStr) {
    var j = JSON.parse(jsonStr)
    var cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(j.ct) })
    if (j.iv) cipherParams.iv = CryptoJS.enc.Hex.parse(j.iv)
    if (j.s) cipherParams.salt = CryptoJS.enc.Hex.parse(j.s)
    return cipherParams
  },
  'get': function(encrypted = 0) {
    if (encrypted) {
      return this.cookieName + '=' + this.encrypt(this.sessionData);
      //return this.encrypt(this.sessionData);
    }
    return this.sessionData;
  },
  'set': function(str) {
    this.sessionData = decodeURIComponent(this.decrypt(str));
  }
}

SessionData.secret = config.get('services.CoAuthoring.secret.session.string');
EOF
block=$(echo "$block" | sed ':a $!{N; ba}; s/\n/\\n/g')
sed "s#\(var g_oIpFilterRules = function() {\)#$block\n\n\1#" server/Common/source/utils.js

Hi, this is an interesting approach. I am not sure we are going to implement the functionality this way, but thank you for sharing.