socket.io的一个“坑”

2019-4-24 12:39:24

0x00 背景

厂里有一个推送服务,负责网页推送和数据同步,基于socket.io。
网页推送通过rabbitmq监听队列实现组织成员变化和对应socket.io房间用户的同步。

0x01 表现

新建一个组织后,立即邀请一个用户B,则当前用户A(不是被邀请的,是邀请别人的)用户也会收到目标用户的邀请通知推送,但是由于A并不是这个通知的接收人,所以点开会丢出403。
正常情况下用户A根本不应该收到这条通知。
并且仅限创建组织后立即邀请,无论是刷新过后还是再邀请第二个用户,就不会收到这条邀请推送了。

0x02 初步研究

第一反应当然是发送推送的时候是不是多带了一个用户id,可是无论打断点还是console.log,均只有对应的用户B的id。
到推送服务那边添加日志,也只看到用户B的id。

if (authIds) {
  for (const authId of authIds) { //authIds只有B用户的uid
    nsp.to(userRoom(authId))
      .emit(event, {
        messageId,
        message: data.message,
        icon: data.icon,
        payload: data.payload,
      })
  }
}

0x03 我们需要更深入些

我将代码改成了

if (authIds) {
  for (const authId of authIds) {
    const room = nsp.to(userRoom(authId))
    console.log(userRoom(authId))
    console.log(room.sockets)
    room.emit(event, {
        messageId,
        message: data.message,
        icon: data.icon,
        payload: data.payload,
      })
  }
}

发现第二个console.log输出的sockets总是带有用户A的socket
于是跟踪一下emit方法的实现

// socket.io/lib/namespace.js:204
/**
 * Emits to all clients.
 *
 * @return {Namespace} self
 * @api public
 */

Namespace.prototype.emit = function(ev){
  if (~exports.events.indexOf(ev)) {
    emit.apply(this, arguments);
    return this;
  }
  // set up packet object
  var args = Array.prototype.slice.call(arguments);
  var packet = {
    type: (this.flags.binary !== undefined ? this.flags.binary : hasBin(args)) ? parser.BINARY_EVENT : parser.EVENT,
    data: args
  };

  if ('function' == typeof args[args.length - 1]) {
    throw new Error('Callbacks are not supported when broadcasting');
  }

  var rooms = this.rooms.slice(0);
  var flags = Object.assign({}, this.flags);

  // reset flags
  this.rooms = [];
  this.flags = {};

  this.adapter.broadcast(packet, {
    rooms: rooms,
    flags: flags
  });

  return this;
};
// socket.io-adapter/index.js:110
/**
 * Broadcasts a packet.
 *
 * Options:
 *  - `flags` {Object} flags for this packet
 *  - `except` {Array} sids that should be excluded
 *  - `rooms` {Array} list of rooms to broadcast to
 *
 * @param {Object} packet object
 * @api public
 */

Adapter.prototype.broadcast = function(packet, opts){
  var rooms = opts.rooms || [];
  var except = opts.except || [];
  var flags = opts.flags || {};
  var packetOpts = {
    preEncoded: true,
    volatile: flags.volatile,
    compress: flags.compress
  };
  var ids = {};
  var self = this;
  var socket;

  packet.nsp = this.nsp.name;
  this.encoder.encode(packet, function(encodedPackets) {
    if (rooms.length) {
      for (var i = 0; i < rooms.length; i++) {
        var room = self.rooms[rooms[i]];
        if (!room) continue;
        var sockets = room.sockets;
        for (var id in sockets) {
          if (sockets.hasOwnProperty(id)) {
            if (ids[id] || ~except.indexOf(id)) continue;
            socket = self.nsp.connected[id];
            if (socket) {
              socket.packet(encodedPackets, packetOpts);
              ids[id] = true;
            }
          }
        }
      }
    } else {
      for (var id in self.sids) {
        if (self.sids.hasOwnProperty(id)) {
          if (~except.indexOf(id)) continue;
          socket = self.nsp.connected[id];
          if (socket) socket.packet(encodedPackets, packetOpts);
        }
      }
    }
  });
};

顺着这里的代码不难发现,socket.io是在发送的时候才去对应的room里遍历对应的socket,也就是说nsp.sockets属性永远保存的是所有sockets,而不受to方法的影响。
为了查找原因,我们需要看一下to方法的实现

// socket.io/lib/namespace.js:139
/**
 * Targets a room when emitting.
 *
 * @param {String} name
 * @return {Namespace} self
 * @api public
 */

Namespace.prototype.to =
Namespace.prototype.in = function(name){
  if (!~this.rooms.indexOf(name)) this.rooms.push(name);
  return this;
};

to在这里只是push了一下this.rooms数组。于是就有查找的方向了,一定是有某个地方多调用了一次to。

0x04 真相永远只有一个

还好在距离上面代码不远的地方,我找到了

const {orgId} = data
switch (ctx.fields.routingKey) {
  case 'create':
  case 'members.add': 
    const {userId} = data
    const sockets = 
    Object.values(nsp.to(userRoom(userId)).connected)
    for (const socket of sockets) {
      socket.join(orgRoom(orgId))
    }

按照to方法的逻辑,其实这里是有问题的,connected属性和sockets属性一样,to只是push了一下数组,并不会影响这两个属性的值,这里的代码负责的也正好是同步组织创建和组织房间的socket。
于是这个bug的逻辑就很清晰了

  1. 用户A创建组织Z,监听组织变化的这里调用了一次nsp.to(userARoom),nsp.rooms被push了一个用户A的房间,但是没有调用emit方法,所以rooms数组没有被清空
  2. 用户A立即邀请用户B,发送邀请通知时nsp.to(userBRoom),又push一次用户B的room,调用emit,所以发送给了用户A和用户B
  3. 用户A再邀请用户C,发送邀请通知时nsp.to(userCRoom),但是没有步骤一带入的userARoom,所以表现正常
    最终只要修改一下所有求房间内用户的方法就可以了
const sockets = Object.values(nsp.connected)
          .filter((socket) => Object.keys(socket.rooms).includes(userRoom(userId)))

0x05 后话

本来以为nsp.to方法返回的是一个独立的namespace实例,没想到只是push了一下room数组。
不过socket.io这样做其实有隐患,如果在to和emit之间有异步调用,可能会出现to,to,emit,emit这样的调用顺序,造成推送给错误的客户端。