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的逻辑就很清晰了
- 用户A创建组织Z,监听组织变化的这里调用了一次nsp.to(userARoom),nsp.rooms被push了一个用户A的房间,但是没有调用emit方法,所以rooms数组没有被清空
- 用户A立即邀请用户B,发送邀请通知时nsp.to(userBRoom),又push一次用户B的room,调用emit,所以发送给了用户A和用户B
- 用户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这样的调用顺序,造成推送给错误的客户端。