diff --git a/src/socket/handlers/clientRecordLeaks.test.ts b/src/socket/handlers/clientRecordLeaks.test.ts new file mode 100644 index 00000000..125e60e8 --- /dev/null +++ b/src/socket/handlers/clientRecordLeaks.test.ts @@ -0,0 +1,310 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; + +import { calculateJwkThumbprint, exportJWK, generateKeyPair, SignJWT } from "jose"; + +import { getSqliteDb, initSqlite } from "../../db/sqlite/connection"; +import { upsertServerChannel } from "../../db/sqlite/channels"; +import { createPermissionScope, setChannelPermissionScope } from "../../db/sqlite/channelScopes"; +import { openDirectConversation } from "../../db/sqlite/conversations"; +import { createServerConfigIfNotExists } from "../../db/sqlite/servers"; +import { resetChannelPermissionCache } from "../../services/channelPermissions"; +import type { Clients } from "../../types"; +import { registerDiagnosticsHandlers } from "./diagnostics"; +import { registerJoinHandlers } from "./join"; +import type { EventHandlerMap, HandlerContext } from "./types"; + +/** + * Two guests join through the real handshake, then everything each one was sent + * is searched for the other's token and identity. GRYT-1239. + */ + +const HOST = "record-leaks.test:5001"; +const SERVER_ID = "record-leaks-test"; + +/** Secrets by name. `permissions` is a Set on the record, which JSON turns into `{}`. */ +const SECRET_KEYS = new Set(["accessToken", "refreshToken", "fileToken", "grytUserId", "permissions", "latencyStats"]); +const ADDRESS_KEY = /^(ip|ipAddress|address|remoteAddress|forwardedFor)$/i; + +interface Delivery { + event: string; + payload: unknown; + via: "socket" | "room"; +} + +interface Guest { + id: string; + received: Delivery[]; + handlers: EventHandlerMap; + accessToken: string; +} + +/** socket.io sends JSON, so this is what arrives rather than the object handed to emit. */ +function wire(payload: unknown): unknown { + return payload === undefined ? undefined : JSON.parse(JSON.stringify(payload)); +} + +const clientsInfo: Clients = {}; +const inboxes = new Map; received: Delivery[]; emit: (e: string, p?: unknown) => boolean }>(); + +const io = { + sockets: { sockets: inboxes }, + to(room: string) { + return { + emit(event: string, payload?: unknown) { + for (const s of inboxes.values()) { + if (s.rooms.has(room)) s.received.push({ event, payload: wire(payload), via: "room" }); + } + }, + }; + }, + emit(event: string, payload?: unknown) { + for (const s of inboxes.values()) s.received.push({ event, payload: wire(payload), via: "room" }); + }, +}; + +/** The broadcasts are debounced and fired without awaiting. */ +const settle = () => new Promise((r) => setTimeout(r, 400)); + +function connect(n: number): Omit { + const id = `sock-${n}`; + const received: Delivery[] = []; + const rooms = new Set([id]); + const socket = { + id, + rooms, + received, + handshake: { headers: { host: HOST }, address: "127.0.0.1", auth: {} }, + emit(event: string, payload?: unknown) { + received.push({ event, payload: wire(payload), via: "socket" }); + return true; + }, + join(room: string) { rooms.add(room); }, + leave(room: string) { rooms.delete(room); }, + to(room: string) { + return { + emit(event: string, payload?: unknown) { + for (const [sid, s] of inboxes) { + if (sid !== id && s.rooms.has(room)) s.received.push({ event, payload: wire(payload), via: "room" }); + } + }, + }; + }, + disconnect() {}, + }; + inboxes.set(id, socket); + + // What `socketHandler` writes for a fresh connection. + clientsInfo[id] = { + serverUserId: `temp_${id}`, nickname: "User", color: "#5865f2", + isMuted: false, isDeafened: false, streamID: "", hasJoinedChannel: false, voiceChannelId: "", + isConnectedToVoice: false, isAFK: false, cameraEnabled: false, cameraStreamID: "", + screenShareEnabled: false, screenShareVideoStreamID: "", screenShareAudioStreamID: "", + isServerMuted: false, isServerDeafened: false, + }; + + const ctx = { + io, socket, clientId: id, serverId: SERVER_ID, clientsInfo, sfuClient: null, + getClientIp: () => "127.0.0.1", clientAddressIsOwn: () => true, + } as unknown as HandlerContext; + + return { id, received, handlers: { ...registerJoinHandlers(ctx), ...registerDiagnosticsHandlers(ctx) } }; +} + +/** A local identity signed the way the client signs one. */ +async function joinAsGuest(n: number, nickname: string): Promise { + const guest = connect(n); + await guest.handlers["server:join"]({ nickname }); + const challenge = guest.received.find((d) => d.event === "server:challenge")?.payload as + | { nonce: string; serverHost: string } + | undefined; + assert.ok(challenge, `${nickname} got no challenge: ${JSON.stringify(guest.received)}`); + + const { privateKey, publicKey } = await generateKeyPair("ES256", { extractable: true }); + const jwk = await exportJWK(publicKey); + const sub = `key:${await calculateJwkThumbprint(jwk, "sha256")}`; + const certificate = await new SignJWT({ jwk }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .setIssuer("gryt:self") + .setSubject(sub) + .setIssuedAt() + .setExpirationTime("24h") + .sign(privateKey); + const assertion = await new SignJWT({ nonce: challenge.nonce }) + .setProtectedHeader({ alg: "ES256", typ: "JWT" }) + .setSubject(sub) + .setAudience(challenge.serverHost) + .setIssuedAt() + .setExpirationTime("60s") + .sign(privateKey); + + await guest.handlers["server:verify"]({ certificate, assertion }); + const joined = guest.received.find((d) => d.event === "server:joined")?.payload as { accessToken?: string } | undefined; + assert.ok( + joined?.accessToken, + `${nickname} did not get in: ${JSON.stringify(guest.received.filter((d) => d.event === "server:error"))}`, + ); + return { ...guest, accessToken: joined.accessToken }; +} + +/** Every secret-named key at any depth, as a path. */ +function secretPaths(value: unknown, path: string): string[] { + if (Array.isArray(value)) return value.flatMap((v, i) => secretPaths(v, `${path}[${i}]`)); + if (!value || typeof value !== "object") return []; + return Object.entries(value).flatMap(([k, v]) => [ + ...(SECRET_KEYS.has(k) || ADDRESS_KEY.test(k) ? [`${path}.${k}`] : []), + ...secretPaths(v, `${path}.${k}`), + ]); +} + +/** Your own token and standing reach you by name; nothing else may carry either. */ +function secretsIn(received: Delivery[]): string[] { + return received.flatMap((d, i) => { + if (d.event === "server:joined" || d.event === "token:refreshed") return []; + if (d.event === "server:details") { + const rest = { ...(d.payload as Record) }; + delete rest.server_info; + return secretPaths(rest, `#${i} server:details`); + } + return secretPaths(d.payload, `#${i} ${d.event}`); + }); +} + +function latest(received: Delivery[], event: string): Delivery | undefined { + return [...received].reverse().find((d) => d.event === event); +} + +let dir: string; +let alice: Guest; +let bob: Guest; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), "gryt-record-leaks-")); + process.env.DATA_DIR = dir; + process.env.GRYT_IDENTITY_TIERS = "local"; + await initSqlite(); + await createServerConfigIfNotExists(); + getSqliteDb().prepare(`UPDATE server_config SET join_policy = 'open'`).run(); + + alice = await joinAsGuest(1, "Alice"); + bob = await joinAsGuest(2, "Bob"); + await settle(); +}); + +after(() => { + delete process.env.DATA_DIR; + delete process.env.GRYT_IDENTITY_TIERS; + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // Windows keeps the SQLite file open past the run. + } +}); + +describe("what one member is sent about another", () => { + it("carries nobody else's access token or identity, anywhere", () => { + for (const [viewer, other] of [[alice, bob], [bob, alice]] as const) { + const everything = JSON.stringify(viewer.received); + assert.ok(viewer.received.some((d) => d.event === "server:clients"), `${viewer.id} was never sent server:clients`); + assert.equal(everything.includes(other.accessToken), false, `${viewer.id} was sent ${other.id}'s access token`); + const otherIdentity = clientsInfo[other.id].grytUserId!; + assert.equal(everything.includes(otherIdentity), false, `${viewer.id} was sent ${other.id}'s grytUserId`); + } + }); + + it("names no secret field in server:clients, server:details.clients or members:list", () => { + // Bob joined second, so his copy of details holds Alice and Alice's broadcast holds Bob. + const details = latest(bob.received, "server:details")?.payload as { clients?: Record }; + assert.ok(details?.clients && Object.keys(details.clients).length === 2, "server:details listed nobody to check"); + assert.deepEqual(secretPaths(details.clients, "server:details.clients"), []); + + const clients = latest(alice.received, "server:clients")?.payload as Record; + assert.ok(clients && Object.keys(clients).length === 2, "server:clients listed nobody to check"); + assert.deepEqual(secretPaths(clients, "server:clients"), []); + + assert.ok(latest(alice.received, "members:list"), "members:list was never sent"); + assert.deepEqual(secretsIn(alice.received), []); + assert.deepEqual(secretsIn(bob.received), []); + }); + + it("still tells each member their own state, token and standing", () => { + for (const guest of [alice, bob]) { + const own = (latest(guest.received, "server:clients")?.payload as Record>)[guest.id]; + assert.ok(own, `${guest.id} is missing from its own server:clients`); + assert.equal(own.serverUserId, clientsInfo[guest.id].serverUserId); + assert.equal(own.nickname, clientsInfo[guest.id].nickname); + for (const field of ["isServerMuted", "isServerDeafened", "isMuted", "isDeafened", "hasJoinedChannel", "voiceChannelId"]) { + assert.ok(field in own, `${guest.id}'s own entry lost ${field}`); + } + + const info = (latest(guest.received, "server:details")?.payload as { server_info: { permissions: string[]; role: string } }).server_info; + assert.ok(Array.isArray(info.permissions) && info.permissions.length > 0, `${guest.id} lost its own permissions`); + assert.equal(typeof info.role, "string"); + } + }); +}); + +describe("the same, with people in voice", () => { + it("leaks nothing once somebody is sharing, on camera and in a DM call", async () => { + const lounge = "lounge"; + Object.assign(clientsInfo[alice.id], { + hasJoinedChannel: true, isConnectedToVoice: true, voiceChannelId: lounge, streamID: "stream-a", + cameraEnabled: true, cameraStreamID: "camera-a", screenShareEnabled: true, + screenShareVideoStreamID: "share-video-a", screenShareAudioStreamID: "share-audio-a", + activity: "Playing something", latencyStats: { estimatedOneWayMs: 20, networkRttMs: 40, jitterMs: 2, codec: "opus", bitrateKbps: 64 }, + }); + const dm = await openDirectConversation(clientsInfo[alice.id].serverUserId, clientsInfo[bob.id].serverUserId); + Object.assign(clientsInfo[bob.id], { + hasJoinedChannel: true, isConnectedToVoice: true, voiceChannelId: dm.conversation_id, streamID: "stream-b", + }); + + const mark = { alice: alice.received.length, bob: bob.received.length }; + await alice.handlers["presence:heartbeat"](); + await alice.handlers["server:details"](); + await bob.handlers["server:details"](); + await settle(); + + const aliceNow = alice.received.slice(mark.alice); + const bobNow = bob.received.slice(mark.bob); + assert.ok(bobNow.some((d) => d.event === "server:clients"), "the voice change was never broadcast"); + assert.ok(aliceNow.some((d) => d.event === "voice:call:members"), "the DM call was never announced"); + assert.deepEqual(secretsIn(aliceNow), []); + assert.deepEqual(secretsIn(bobNow), []); + assert.equal(JSON.stringify(bobNow).includes(alice.accessToken), false, "bob was sent alice's token"); + assert.equal(JSON.stringify(aliceNow).includes(bob.accessToken), false, "alice was sent bob's token"); + }); + + it("leaks nothing while a gated channel exists with nobody in it", async () => { + await upsertServerChannel({ channelId: "backroom", name: "Back room", type: "voice", position: 50 }); + await setChannelPermissionScope("backroom", await createPermissionScope({ name: "Gated", isTemplate: false })); + resetChannelPermissionCache(); + // Something has to change, or the dedupe sends nothing to check. + clientsInfo[alice.id].isMuted = true; + + const mark = bob.received.length; + await alice.handlers["presence:heartbeat"](); + await settle(); + + const bobNow = bob.received.slice(mark); + assert.ok(bobNow.some((d) => d.event === "server:clients" && d.via === "room"), "server:clients was not sent to the room"); + assert.deepEqual(secretsIn(bobNow), []); + assert.equal(JSON.stringify(bobNow).includes(alice.accessToken), false, "bob was sent alice's token"); + }); + + it("leaks nothing on the per-recipient path a gated channel takes", async () => { + clientsInfo[alice.id].voiceChannelId = "backroom"; + + const mark = bob.received.length; + await alice.handlers["presence:heartbeat"](); + await settle(); + + const bobNow = bob.received.slice(mark); + const perSocket = bobNow.filter((d) => d.event === "server:clients" && d.via === "socket"); + assert.ok(perSocket.length > 0, "server:clients did not take the per-recipient branch"); + assert.deepEqual(secretsIn(bobNow), []); + assert.equal(JSON.stringify(bobNow).includes(alice.accessToken), false, "bob was sent alice's token"); + }); +}); diff --git a/src/socket/utils/clients.ts b/src/socket/utils/clients.ts index b71db48e..3abf2e85 100644 --- a/src/socket/utils/clients.ts +++ b/src/socket/utils/clients.ts @@ -33,12 +33,52 @@ function publicVoiceRoom(voiceChannelId: string | undefined): string { /** The same blanking for a channel this recipient may not see, so unlike `publicVoiceRoom` it takes the recipient. */ -function voiceRoomFor(visible: Set, voiceChannelId: string | undefined): string { +function voiceRoomFor(visible: ReadonlySet, voiceChannelId: string | undefined): string { const id = publicVoiceRoom(voiceChannelId); if (!id) return ""; return visible.has(id) ? id : ""; } +/** Everything another member may read off a connection, named one field at a + time. The record also holds the access token, which must never be copied. */ +export function publicClientRecord(client: Clients[string], voiceChannelId: string) { + return { + serverUserId: client.serverUserId, + nickname: client.nickname, + isMuted: client.isMuted, + isDeafened: client.isDeafened, + isServerMuted: client.isServerMuted, + isServerDeafened: client.isServerDeafened, + isAFK: client.isAFK, + streamID: client.streamID, + hasJoinedChannel: client.hasJoinedChannel, + voiceChannelId, + isConnectedToVoice: client.isConnectedToVoice, + cameraEnabled: client.cameraEnabled, + cameraStreamID: client.cameraStreamID, + screenShareEnabled: client.screenShareEnabled, + screenShareVideoStreamID: client.screenShareVideoStreamID, + screenShareAudioStreamID: client.screenShareAudioStreamID, + }; +} + +export type PublicClient = ReturnType; + +/** `server:clients` and `server:details.clients`, keyed by socket id. `visible` + also blanks the channels this recipient cannot see. */ +export function publicClientList( + clientsInfo: Clients, + visible?: ReadonlySet, +): Record { + const list: Record = {}; + for (const [clientId, client] of Object.entries(clientsInfo)) { + if (!client.serverUserId || client.serverUserId.startsWith("temp_")) continue; + const room = visible ? voiceRoomFor(visible, client.voiceChannelId) : publicVoiceRoom(client.voiceChannelId); + list[clientId] = publicClientRecord(client, room); + } + return list; +} + /** A gate is not part of the hashed state, so hiding a channel changes what each recipient should be told without changing the hash. */ export function invalidateBroadcastDedupe(io: Server): void { @@ -56,37 +96,26 @@ async function emitClientsNow(io: Server, clientsInfo: Clients, stateHash: strin lastEmitAtByIO.set(io, Date.now()); lastClientsStateByIO.set(io, stateHash); - const registeredClients: Clients = {}; - Object.entries(clientsInfo).forEach(([clientId, client]) => { - if (client.serverUserId && !client.serverUserId.startsWith('temp_')) { - // Copied, not passed through: this is the live record, and blanking the - // field on it would take the person out of their own call. - registeredClients[clientId] = { ...client, voiceChannelId: publicVoiceRoom(client.voiceChannelId) }; - } - }); + const everyone = publicClientList(clientsInfo); // One payload to the room while nothing is gated. The per-socket branch below // costs a standing lookup each and only earns it once. const scoped = await scopedChannelIds(); if (scoped.size === 0) { - io.to("verifiedClients").emit("server:clients", registeredClients); + io.to("verifiedClients").emit("server:clients", everyone); return; } - const anyScopedInUse = Object.values(registeredClients).some((c) => scoped.has(c.voiceChannelId || "")); + const anyScopedInUse = Object.values(everyone).some((c) => scoped.has(c.voiceChannelId)); if (!anyScopedInUse) { - io.to("verifiedClients").emit("server:clients", registeredClients); + io.to("verifiedClients").emit("server:clients", everyone); return; } for (const [sid, sock] of io.sockets.sockets) { if (!sock.rooms.has("verifiedClients")) continue; const visible = await visibleChannelIds(clientsInfo[sid]?.serverUserId, clientsInfo[sid]?.grytUserId); - const forThem: Clients = {}; - for (const [cid, client] of Object.entries(registeredClients)) { - forThem[cid] = { ...client, voiceChannelId: voiceRoomFor(visible, client.voiceChannelId) }; - } - sock.emit("server:clients", forThem); + sock.emit("server:clients", publicClientList(clientsInfo, visible)); } } diff --git a/src/socket/utils/publicClientList.test.ts b/src/socket/utils/publicClientList.test.ts new file mode 100644 index 00000000..e185c902 --- /dev/null +++ b/src/socket/utils/publicClientList.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { describe, it } from "node:test"; + +import type { Permission } from "../../constants/permissions"; +import { directConversationId } from "../../db/sqlite/conversations"; +import type { Clients } from "../../types"; +import { publicClientList, publicClientRecord } from "./clients"; + +/** + * The allowlist both member broadcasts are built from, and the wiring that makes + * it the only builder. GRYT-1239. + */ + +/** What the web client reads off these records; mobile reads a subset. Adding one + is deciding that every member may see it. */ +const PUBLIC_FIELDS = [ + "cameraEnabled", "cameraStreamID", "hasJoinedChannel", "isAFK", "isConnectedToVoice", + "isDeafened", "isMuted", "isServerDeafened", "isServerMuted", "nickname", + "screenShareAudioStreamID", "screenShareEnabled", "screenShareVideoStreamID", + "serverUserId", "streamID", "voiceChannelId", +]; + +/** Every field the record can hold, so a new one fails to compile here first. */ +const everything: Required = { + grytUserId: "key:alice-identity", + serverUserId: "alice-server-id", + nickname: "Alice", + color: "#5865f2", + isMuted: true, + isDeafened: false, + streamID: "stream-a", + hasJoinedChannel: true, + voiceChannelId: "lounge", + isConnectedToVoice: true, + isAFK: true, + cameraEnabled: true, + cameraStreamID: "camera-a", + screenShareEnabled: true, + screenShareVideoStreamID: "share-video-a", + screenShareAudioStreamID: "share-audio-a", + isServerMuted: false, + isServerDeafened: true, + activity: "Playing something", + status: "in_voice", + lastSeen: new Date(0), + accessToken: "eyJhbGciOiJIUzI1NiJ9.alice.token", + permissions: new Set(["view_members"]), + latencyStats: { estimatedOneWayMs: 1, networkRttMs: 2, jitterMs: 3, codec: "opus", bitrateKbps: 64 }, +}; + +describe("publicClientRecord", () => { + it("copies exactly the public fields", () => { + assert.deepEqual(Object.keys(publicClientRecord(everything, "lounge")).sort(), PUBLIC_FIELDS); + }); + + it("puts none of the rest on the wire", () => { + const wire = JSON.stringify(publicClientRecord(everything, "lounge")); + for (const secret of [everything.accessToken, everything.grytUserId, "view_members", "opus", "Playing something"]) { + assert.equal(wire.includes(secret), false, `${secret} reached the wire`); + } + }); + + it("passes each public value through as it is", () => { + const record: Record = publicClientRecord(everything, "lounge"); + for (const field of PUBLIC_FIELDS) { + assert.equal(record[field], (everything as Record)[field], field); + } + }); + + it("takes the room it is handed rather than the record's", () => { + assert.equal(publicClientRecord(everything, "").voiceChannelId, ""); + }); +}); + +describe("publicClientList", () => { + const dm = directConversationId("alice-server-id", "bob-server-id"); + const clientsInfo: Clients = { + "sock-alice": everything, + "sock-joining": { ...everything, serverUserId: "temp_sock-joining" }, + "sock-bob": { ...everything, serverUserId: "bob-server-id", voiceChannelId: dm }, + "sock-staff": { ...everything, serverUserId: "staff-server-id", voiceChannelId: "staff-room" }, + }; + + it("keys by socket id and leaves out a connection that has not joined", () => { + assert.deepEqual(Object.keys(publicClientList(clientsInfo)).sort(), ["sock-alice", "sock-bob", "sock-staff"]); + }); + + it("blanks a DM call's room for everybody", () => { + assert.equal(publicClientList(clientsInfo)["sock-bob"].voiceChannelId, ""); + assert.equal(publicClientList(clientsInfo)["sock-alice"].voiceChannelId, "lounge"); + }); + + it("blanks a channel the recipient cannot see and keeps one they can", () => { + const list = publicClientList(clientsInfo, new Set(["lounge"])); + assert.equal(list["sock-alice"].voiceChannelId, "lounge"); + assert.equal(list["sock-staff"].voiceChannelId, ""); + assert.equal(list["sock-bob"].voiceChannelId, ""); + }); + + it("copies nothing but the public fields for anybody", () => { + for (const record of Object.values(publicClientList(clientsInfo, new Set(["lounge"])))) { + assert.deepEqual(Object.keys(record).sort(), PUBLIC_FIELDS); + } + }); +}); + +const SRC = join(__dirname, "..", ".."); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + if (statSync(full).isDirectory()) return sourceFiles(full); + return name.endsWith(".ts") && !name.endsWith(".test.ts") ? [full] : []; + }); +} + +function read(path: string): string { + return readFileSync(join(SRC, path), "utf8"); +} + +/** The body between a function's own braces, found by counting them. */ +function functionBody(text: string, name: string): string { + const start = text.indexOf(`export function ${name}(`); + assert.ok(start >= 0, `${name} is gone`); + let parens = 0; + let open = -1; + for (let i = start; i < text.length; i++) { + if (text[i] === "(") parens++; + if (text[i] === ")") parens--; + if (text[i] === "{" && parens === 0) { + open = i; + break; + } + } + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === "{") depth++; + if (text[i] === "}" && --depth === 0) return text.slice(open + 1, i); + } + throw new Error(`${name} never closes`); +} + +describe("who builds these payloads", () => { + it("emits server:clients from one file, and only what the allowlist built", () => { + const emitters = sourceFiles(SRC) + .filter((f) => readFileSync(f, "utf8").includes('"server:clients"')) + .map((f) => relative(SRC, f).split(sep).join("/")); + assert.deepEqual(emitters, ["socket/utils/clients.ts"]); + + const text = read("socket/utils/clients.ts"); + const payloads = [...text.matchAll(/emit\("server:clients",\s*([^;]+)\);/g)].map((m) => m[1].trim()); + assert.equal(payloads.length, 3, `expected the two room emits and the per-socket one, found ${payloads.length}`); + for (const payload of payloads) { + const built = payload.startsWith("publicClientList(") || text.includes(`const ${payload} = publicClientList(`); + assert.ok(built, `server:clients sends \`${payload}\`, which publicClientList did not build`); + } + }); + + it("fills server:details.clients from the same allowlist", () => { + const text = read("socket/utils/server.ts"); + const fields = [...text.matchAll(/^\s*clients:\s*(.+?),?\s*$/gm)].map((m) => m[1]); + assert.deepEqual(fields, ["publicClientList(clientsInfo, visibleToThem)"]); + }); + + it("builds each record from named fields, with no spread and no secret", () => { + const text = read("socket/utils/clients.ts"); + const record = functionBody(text, "publicClientRecord").trim(); + assert.match(record, /^return \{[^{}]*\};$/, "publicClientRecord is no longer one object literal"); + assert.equal(record.includes("..."), false, "publicClientRecord spreads something into the record"); + for (const secret of ["accessToken", "grytUserId", "permissions", "latencyStats"]) { + assert.equal(record.includes(secret), false, `publicClientRecord names ${secret}`); + } + + const list = functionBody(text, "publicClientList"); + assert.equal(list.includes("..."), false, "publicClientList spreads something into the list"); + assert.match(list, /list\[clientId\] = publicClientRecord\(client, room\);/); + assert.equal(list.match(/\breturn\b/g)?.length, 1, "publicClientList can return before it builds the list"); + }); + + it("spreads no connection record anywhere a payload is made", () => { + for (const file of ["socket/utils/clients.ts", "socket/utils/server.ts"]) { + assert.doesNotMatch( + read(file), + /\.\.\.(?:clientsInfo\[|(?:client|clientInfo|ci|info)\b)/, + `${file} spreads a connection record`, + ); + } + }); +}); diff --git a/src/socket/utils/server.ts b/src/socket/utils/server.ts index 36c277e3..7f80f522 100644 --- a/src/socket/utils/server.ts +++ b/src/socket/utils/server.ts @@ -1,6 +1,7 @@ import consola from "consola"; import { Server, Socket } from "socket.io"; import { Clients } from "../../types"; +import { publicClientList } from "./clients"; import type { JoinPolicy, RoleDefinitionRecord } from "../../db/interfaces"; import { FALLBACK_ROLE_ID, PERMISSIONS } from "../../constants/permissions"; import { getEffectiveStanding } from "../../services/permissions"; @@ -220,6 +221,8 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in // We still emit `channels` for backward compatibility (derived from sidebar items). let sidebar_items: { id: string; kind: string; position: number; channelId?: string; spacerHeight?: number; label?: string; parentItemId?: string }[] = []; let channels: { id: string; name: string; type: string; description?: string; requirePushToTalk?: boolean; disableRnnoise?: boolean; maxBitrate?: number; eSportsMode?: boolean; textInVoice?: boolean; layout?: "chat" | "forum"; automated?: boolean; forumTags?: { id: string; name: string; emoji?: string | null; color?: string | null }[]; permissionScopeId?: string | null; canSend?: boolean; canJoin?: boolean }[] = []; + // Empty until read, so a failed read names nobody's voice channel. + let visibleToThem: ReadonlySet = new Set(); try { // Seeding creates the first channels here, and a cache holding the empty // list from a moment ago filters every one of them out of the first join. @@ -241,6 +244,7 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in postableChannelIds(client.serverUserId, client.grytUserId), joinableChannelIds(client.serverUserId, client.grytUserId), ]); + visibleToThem = visible; const items = allItems.filter((it) => it.kind !== "channel" || !it.channel_id || visible.has(it.channel_id)); /* Without `parentItemId` the client has folders it cannot fill. A folder is @@ -321,16 +325,6 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in })); } - // Filter to only include registered users (those with real serverUserId, not temp IDs) - const registeredClients: Clients = {}; - Object.entries(clientsInfo).forEach(([clientId, client]) => { - // Only include clients who have been properly registered in the database - // (i.e., have a real serverUserId that doesn't start with "temp_") - if (client.serverUserId && !client.serverUserId.startsWith('temp_')) { - registeredClients[clientId] = client; - } - }); - let cfgName = process.env.SERVER_NAME || "Unknown Server"; let cfgDesc = process.env.SERVER_DESCRIPTION || "A Gryt server"; let cfgIconUrl: string | null = null; @@ -369,7 +363,8 @@ export async function sendServerDetails(socket: Socket, clientsInfo: Clients, in sfu_hosts: sfuHosts, stun_hosts: stunHosts, voice_capacity_max: voiceSeatLimit, - clients: registeredClients, // Only send registered users, not temporary connections + // The same builder as `server:clients`, so this cannot carry anybody's token. + clients: publicClientList(clientsInfo, visibleToThem), sidebar_items, channels, server_info: {