socket.io的一个“坑”

栏目: Html5 · 发布时间: 4年前

内容简介:厂里有一个推送服务,负责网页推送和数据同步,基于socket.io。网页推送通过rabbitmq监听队列实现组织成员变化和对应socket.io房间用户的同步。新建一个组织后,立即邀请一个用户B,则当前用户A(不是被邀请的,是邀请别人的)用户也会收到目标用户的邀请通知推送,但是由于A并不是这个通知的接收人,所以点开会丢出403。

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这样的调用顺序,造成推送给错误的客户端。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

APP蓝图

APP蓝图

吕皓月 / 清华大学出版社 / 2015-1-1 / 69.00

移动互联网原型设计,简单来说,就是使用建模软件制作基于手机或者平板电脑的App,HTML 5网站的高保真原型。在7.0 之前的版本中,使用Axure RP进行移动互联网的建模也是可以的。比如,对于桌面的网站模型,制作一个1024像素宽度的页面就可以了;现在针对移动设备,制作320像素宽度的页面就好了。但是在新版本的Axure RP 7.0 中,加入了大量对于移动互联网的支持,如手指滑动,拖动,横屏......一起来看看 《APP蓝图》 这本书的介绍吧!

图片转BASE64编码
图片转BASE64编码

在线图片转Base64编码工具

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具