diff --git a/src/chat/chat.service.spec.ts b/src/chat/chat.service.spec.ts index 57afaa90..8bf632ef 100644 --- a/src/chat/chat.service.spec.ts +++ b/src/chat/chat.service.spec.ts @@ -28,6 +28,8 @@ describe("ChatService direct messages", () => { eval: jest.fn().mockResolvedValue([1, 1]), }; + const rcon = { connect: jest.fn(), send: jest.fn() }; + let service: ChatService; let acceptedFriendships: Array<[string, string]>; let role: string; @@ -153,6 +155,15 @@ describe("ChatService direct messages", () => { }; } + if (query.matches_by_pk?.server) { + return { + matches_by_pk: { + status: "Live", + server: { id: "server-1", plugin_runtime: "counterstrikesharp" }, + }, + }; + } + if (query.matches_by_pk) { return myMatches.includes(query.matches_by_pk.__args.id) ? { @@ -215,9 +226,12 @@ describe("ChatService direct messages", () => { staff = []; role = "user"; queries = []; + rcon.send.mockResolvedValue(undefined); + rcon.connect.mockResolvedValue(rcon); + service = new ChatService( logger as any, - { } as any, + { connect: rcon.connect } as any, hasuraService as any, postgres as any, { getConnection: () => redis } as any, @@ -388,6 +402,32 @@ describe("ChatService direct messages", () => { }); }); + // Match chat is relayed into the game server as an rcon command with the + // message inlined in quotes, so what a player types has to be unable to + // terminate that argument or that line. + describe("relaying to the game server", () => { + const relayed = async (message: string) => { + await service.sendChatToServer("m-1", message); + return rcon.send.mock.calls.at(-1)?.[0] as string; + }; + + it("sends the message as one quoted argument", async () => { + expect(await relayed("nice shot")).toBe('css_web_chat "nice shot"'); + }); + + it("flattens a multi line message onto one line", async () => { + expect(await relayed("top\nbottom")).toBe('css_web_chat "top bottom"'); + expect(await relayed("top\r\nbottom")).toBe('css_web_chat "top bottom"'); + }); + + it("strips quotes so the message cannot escape the argument", async () => { + expect(await relayed('x" ; quit ; say "')).not.toContain('"x"'); + expect(await relayed('x" ; quit ; say "')).toBe( + 'css_web_chat "x ; quit ; say"', + ); + }); + }); + describe("rosters", () => { it("resolves both parties of a conversation", async () => { expect( diff --git a/src/chat/chat.service.ts b/src/chat/chat.service.ts index 10491536..e75d5231 100644 --- a/src/chat/chat.service.ts +++ b/src/chat/chat.service.ts @@ -1417,6 +1417,18 @@ export class ChatService { } } + // The message is inlined into an rcon command inside quotes, so a quote ends + // the argument and a newline ends the command: either one turns the rest of + // what a player typed into console input. Chat in game is one line anyway. + private static oneRconArgument(message: string) { + return message + .replace(/[\r\n]+/g, " ") + .replace(/"/g, "") + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f]/g, "") + .trim(); + } + public async sendChatToServer(matchId: string, message: string) { try { const { matches_by_pk } = await this.hasuraService.query({ @@ -1452,7 +1464,9 @@ export class ChatService { ? "css_web_chat" : "sw_web_chat"; - return await rcon.send(`${command} "${message}"`); + return await rcon.send( + `${command} "${ChatService.oneRconArgument(message)}"`, + ); } catch (error) { this.logger.warn( `[${matchId}] unable to send match to server`,