diff --git a/backend/server.js b/backend/server.js index 364220a..239eb34 100644 --- a/backend/server.js +++ b/backend/server.js @@ -97,6 +97,94 @@ export const io = new Server(server, { export const userSockets = new Map() // userId -> Set export const roomTimers = new Map() // roomId -> { timeLeft, state, intervalId, type } export const userLastActive = new Map() // userId -> timestamp +export const roomMembers = new Map() // roomId -> Map +export const roomHosts = new Map() // roomId -> userId + +const normalizeRoomId = (roomId) => + typeof roomId === "string" ? roomId.trim() : "" + +const getRoomMembers = (roomId) => { + if (!roomMembers.has(roomId)) { + roomMembers.set(roomId, new Map()) + } + return roomMembers.get(roomId) +} + +const emitRoomError = (socket, event, reason) => { + socket.emit("roomError", { event, reason }) +} + +const canJoinRoom = (socket, roomId) => { + if (!roomId) return false + if (!socket.isGuest) return true + return socket.roomId === roomId +} + +const addRoomMembership = (socket, roomId, userIdStr) => { + const members = getRoomMembers(roomId) + const existing = members.get(userIdStr) + + if (existing) { + existing.socketIds.add(socket.id) + } else { + members.set(userIdStr, { + username: socket.data.username, + isGuest: socket.isGuest || false, + socketIds: new Set([socket.id]), + }) + } + + if (!socket.isGuest && !roomHosts.has(roomId)) { + roomHosts.set(roomId, userIdStr) + } + + socket.join(roomId) +} + +const isRoomMember = (roomId, userIdStr) => + Boolean(roomMembers.get(roomId)?.has(userIdStr)) + +const isRoomHost = (roomId, userIdStr) => roomHosts.get(roomId) === userIdStr + +const removeSocketFromRoomState = (socket, userIdStr) => { + for (const [roomId, members] of roomMembers.entries()) { + const member = members.get(userIdStr) + if (!member) continue + + member.socketIds.delete(socket.id) + if (member.socketIds.size > 0) continue + + members.delete(userIdStr) + if (roomHosts.get(roomId) === userIdStr) { + const nextHost = Array.from(members.entries()).find( + ([, candidate]) => !candidate.isGuest, + ) + if (nextHost) { + roomHosts.set(roomId, nextHost[0]) + } else { + roomHosts.delete(roomId) + } + } + + if (members.size === 0) { + roomMembers.delete(roomId) + roomHosts.delete(roomId) + } + } +} + +const removeRoomMember = (roomId, userIdStr) => { + const members = roomMembers.get(roomId) + if (!members?.has(userIdStr)) return false + + members.delete(userIdStr) + if (members.size === 0) { + roomMembers.delete(roomId) + roomHosts.delete(roomId) + } + + return true +} // Socket rate limiting: max 20 concurrent connections per IP const socketConnectionCounts = new Map() @@ -195,7 +283,7 @@ io.on("connection", async (socket) => { // If guest, auto-join their specific room if (socket.isGuest && socket.roomId) { - socket.join(socket.roomId) + addRoomMembership(socket, socket.roomId, userIdStr) socket.to(socket.roomId).emit("userJoinedRoom", { userId: userIdStr, username: socket.data.username, @@ -244,14 +332,20 @@ io.on("connection", async (socket) => { // Room Handlers socket.on("joinRoom", (roomId) => { - socket.join(roomId) - console.log(`User ${userIdStr} joined room ${roomId}`) - socket.to(roomId).emit("userJoinedRoom", { + const safeRoomId = normalizeRoomId(roomId) + if (!canJoinRoom(socket, safeRoomId)) { + emitRoomError(socket, "joinRoom", "You are not allowed to join this room.") + return + } + + addRoomMembership(socket, safeRoomId, userIdStr) + console.log(`User ${userIdStr} joined room ${safeRoomId}`) + socket.to(safeRoomId).emit("userJoinedRoom", { userId: userIdStr, username: socket.data.username, isGuest: socket.isGuest || false, }) - const timer = roomTimers.get(roomId) + const timer = roomTimers.get(safeRoomId) if (timer) { socket.emit("pomodoroTick", { timeLeft: timer.timeLeft, state: timer.state, type: timer.type }) } @@ -287,35 +381,59 @@ io.on("connection", async (socket) => { }) socket.on("roomMessage", (data) => { + const roomId = normalizeRoomId(data?.roomId) + if (!isRoomMember(roomId, userIdStr)) { + emitRoomError(socket, "roomMessage", "Join the room before sending messages.") + return + } + // Ephemeral room message (not saved to DB) - io.to(data.roomId).emit("newRoomMessage", { - roomId: data.roomId, + io.to(roomId).emit("newRoomMessage", { + roomId, senderId: userIdStr, senderName: socket.data.username, isGuest: socket.isGuest || false, - text: data.text, + text: typeof data?.text === "string" ? data.text : "", createdAt: new Date().toISOString(), }) }) socket.on("removeGuest", (data) => { - // Only host should call this. Data = { roomId, guestId } - // In a real app, verify this socket is the host of the room - const targetSockets = userSockets.get(data.guestId) + const roomId = normalizeRoomId(data?.roomId) + const guestId = typeof data?.guestId === "string" ? data.guestId : "" + + if (!isRoomHost(roomId, userIdStr)) { + emitRoomError(socket, "removeGuest", "Only the room host can remove guests.") + return + } + + const target = roomMembers.get(roomId)?.get(guestId) + if (!target?.isGuest) { + emitRoomError(socket, "removeGuest", "Guest is not in this room.") + return + } + + removeRoomMember(roomId, guestId) + const targetSockets = userSockets.get(guestId) if (targetSockets) { targetSockets.forEach((socketId) => { const targetSocket = io.sockets.sockets.get(socketId) if (targetSocket) { - targetSocket.leave(data.roomId) - targetSocket.emit("removedFromRoom", { roomId: data.roomId }) + targetSocket.leave(roomId) + targetSocket.emit("removedFromRoom", { roomId }) } }) } }) socket.on("pomodoroAction", (data) => { - if (socket.isGuest) return; // Only host can control timer - const { roomId, action, type } = data; + const roomId = normalizeRoomId(data?.roomId) + if (!isRoomHost(roomId, userIdStr)) { + emitRoomError(socket, "pomodoroAction", "Only the room host can control the timer.") + return + } + + const { action, type } = data; let timer = roomTimers.get(roomId); if (action === "start") { @@ -370,6 +488,7 @@ io.on("connection", async (socket) => { userLastActive.set(userIdStr, Date.now()) console.log(`User disconnected: ${userIdStr} (socket: ${socket.id})`) } + removeSocketFromRoomState(socket, userIdStr) io.emit("getOnlineUsers", Array.from(userSockets.keys())) }) }) @@ -437,3 +556,4 @@ if (isVercel()) { // Export for potential use in integration tests (e.g., supertest) export default app +export { server } diff --git a/backend/tests/roomSocketAuth.test.js b/backend/tests/roomSocketAuth.test.js new file mode 100644 index 0000000..94eaaf1 --- /dev/null +++ b/backend/tests/roomSocketAuth.test.js @@ -0,0 +1,153 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" +import { io as Client } from "socket.io-client" +import jwt from "jsonwebtoken" +import mongoose from "mongoose" +import { + server, + roomHosts, + roomMembers, + roomTimers, + userSockets, +} from "../server.js" + +const waitFor = (socket, event) => + new Promise((resolve) => { + socket.once(event, resolve) + }) + +const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 25)) + +const closeSocket = (socket) => + new Promise((resolve) => { + if (!socket?.connected) { + socket?.close() + resolve() + return + } + socket.once("disconnect", resolve) + socket.close() + }) + +const connectSocket = async (url, payload) => { + const token = jwt.sign(payload, process.env.JWT_SECRET) + const socket = Client(url, { + auth: { token }, + forceNew: true, + reconnection: false, + transports: ["websocket"], + }) + await waitFor(socket, "connect") + return socket +} + +describe("room socket authorization", () => { + let baseUrl + let hostSocket + let guestSocket + let outsiderSocket + + beforeAll(async () => { + await new Promise((resolve) => { + server.listen(0, resolve) + }) + const address = server.address() + baseUrl = `http://127.0.0.1:${address.port}` + }) + + afterAll(async () => { + await Promise.all( + [hostSocket, guestSocket, outsiderSocket].filter(Boolean).map(closeSocket), + ) + await new Promise((resolve) => server.close(resolve)) + }) + + beforeEach(async () => { + await Promise.all( + [hostSocket, guestSocket, outsiderSocket].filter(Boolean).map(closeSocket), + ) + hostSocket = null + guestSocket = null + outsiderSocket = null + roomHosts.clear() + roomMembers.clear() + roomTimers.clear() + userSockets.clear() + }) + + it("rejects guest attempts to join arbitrary rooms", async () => { + guestSocket = await connectSocket(baseUrl, { + id: "guest-allowed", + name: "Guest Allowed", + isGuest: true, + roomId: "allowed-room", + }) + + const errorPromise = waitFor(guestSocket, "roomError") + guestSocket.emit("joinRoom", "other-room") + + await expect(errorPromise).resolves.toEqual({ + event: "joinRoom", + reason: "You are not allowed to join this room.", + }) + expect(roomMembers.get("allowed-room")?.has("guest-allowed")).toBe(true) + expect(roomMembers.get("other-room")?.has("guest-allowed")).toBeFalsy() + }) + + it("rejects room messages from sockets that are not room members", async () => { + const hostId = new mongoose.Types.ObjectId().toString() + const outsiderId = new mongoose.Types.ObjectId().toString() + hostSocket = await connectSocket(baseUrl, { id: hostId }) + outsiderSocket = await connectSocket(baseUrl, { id: outsiderId }) + + hostSocket.emit("joinRoom", "secure-room") + await waitForTick() + + const errorPromise = waitFor(outsiderSocket, "roomError") + outsiderSocket.emit("roomMessage", { + roomId: "secure-room", + text: "I should not be delivered", + }) + + await expect(errorPromise).resolves.toEqual({ + event: "roomMessage", + reason: "Join the room before sending messages.", + }) + }) + + it("allows only the room host to remove guest members", async () => { + const hostId = new mongoose.Types.ObjectId().toString() + const outsiderId = new mongoose.Types.ObjectId().toString() + hostSocket = await connectSocket(baseUrl, { id: hostId }) + outsiderSocket = await connectSocket(baseUrl, { id: outsiderId }) + guestSocket = await connectSocket(baseUrl, { + id: "guest-removable", + name: "Guest Removable", + isGuest: true, + roomId: "host-room", + }) + + hostSocket.emit("joinRoom", "host-room") + await waitForTick() + + const deniedPromise = waitFor(outsiderSocket, "roomError") + outsiderSocket.emit("removeGuest", { + roomId: "host-room", + guestId: "guest-removable", + }) + + await expect(deniedPromise).resolves.toEqual({ + event: "removeGuest", + reason: "Only the room host can remove guests.", + }) + expect(roomMembers.get("host-room")?.has("guest-removable")).toBe(true) + + const removedPromise = waitFor(guestSocket, "removedFromRoom") + hostSocket.emit("removeGuest", { + roomId: "host-room", + guestId: "guest-removable", + }) + + await expect(removedPromise).resolves.toEqual({ roomId: "host-room" }) + expect(roomMembers.get("host-room")?.has("guest-removable")).toBeFalsy() + }) +})