From 4cb9994139c3183d48a249615a1b6b72b96a8895 Mon Sep 17 00:00:00 2001 From: Sivert Date: Tue, 15 Sep 2026 22:40:05 +0200 Subject: [PATCH 1/2] Test that no member is sent another member's token (GRYT-1239) Two guests join through the real handshake. Then the test searches everything each of them was sent for the other's access token and grytUserId, and for secret-named fields in server:clients, server:details.clients and members:list. It also puts someone in voice with a camera, a screen share and a DM call, and covers the per-recipient path server:clients takes when a channel is gated. This fails on main. Both events copy the whole connection record, so every member gets every other member's token, grytUserId and permissions. Co-Authored-By: Claude Opus 5 --- src/socket/handlers/clientRecordLeaks.test.ts | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 src/socket/handlers/clientRecordLeaks.test.ts 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"); + }); +}); From eb1229dc42ebe67a11d10ee2850fba56f4e91269 Mon Sep 17 00:00:00 2001 From: Sivert Date: Tue, 15 Sep 2026 22:46:22 +0200 Subject: [PATCH 2/2] Build both member broadcasts from one allowlist (GRYT-1239) server:clients and server:details.clients each copied the whole connection record for every member and sent it to every other member. The record holds accessToken, grytUserId and permissions, so every verified socket got every other member's live access token. That token passes requireAuth as its owner for fifteen minutes, and token:refresh renews it. publicClientRecord names the sixteen fields another member may see, and publicClientList keys them by socket id and blanks a voice channel the recipient can't see. Both broadcasts build from it, so neither can carry a field that isn't on the list. The fields are the ones the web and mobile clients read: the voice, camera, screen share, mute and deafen state, plus serverUserId and nickname. A client still learns its own permissions and role from server:details.server_info, and its own token from server:joined and token:refreshed. None of that moved. members:list was already built from a separate allowlist and carries no token; the new property scan covers it too and it stays clean. voice:latency:update, voice:call:members and the plugin message bus send no member records. Consumers checked before trimming: web and desktop client, mobile, the bot and SDK, the addon and server plugin APIs, and the socket docs. Nothing read accessToken, grytUserId, permissions, latencyStats, color, status, lastSeen or activity off these records. Co-Authored-By: Claude Opus 5 --- src/socket/utils/clients.ts | 63 +++++-- src/socket/utils/publicClientList.test.ts | 191 ++++++++++++++++++++++ src/socket/utils/server.ts | 17 +- 3 files changed, 243 insertions(+), 28 deletions(-) create mode 100644 src/socket/utils/publicClientList.test.ts 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: {