From 5fb7f75f3d5bf138a4bfcdf8202a662c166f49a8 Mon Sep 17 00:00:00 2001 From: Evan Date: Fri, 13 Mar 2026 21:15:10 -0700 Subject: [PATCH] Server-side WebSocket message rate limiting & size enforcement (#3424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: * Adds ClientMsgRateLimiter — a per-client token-bucket rate limiter that gates all incoming WebSocket messages. Returns "ok", "limit" (drop), or "kick" based on the violation type. * Intent messages are capped at 500 bytes each (they are stored in turn history for the game duration, so oversized intents accumulate in server RAM). Violations kick the client. * Winner messages bypass the byte rate limit (they include stats for all players and can be 100s of KB) but are strictly capped at one per client — a second winner message kicks the client. * All other messages go through the standard per-second (10/s) and per-minute (150/min) rate limits. Violations drop the message; byte budget exhaustion kicks the client. * WebSocket maxPayload set to 2 MB on game workers. Invalid (unparseable) messages now immediately kick the client rather than being silently dropped. Unit tests added for all rate limiting behaviors. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/server/ClientMsgRateLimiter.ts | 69 +++++++++++++++++++++++ src/server/GameServer.ts | 60 ++++++++++++-------- src/server/IntentRateLimiter.ts | 32 ----------- src/server/Worker.ts | 5 +- src/server/WorkerLobbyService.ts | 5 +- tests/server/ClientMsgRateLimiter.test.ts | 67 ++++++++++++++++++++++ 6 files changed, 180 insertions(+), 58 deletions(-) create mode 100644 src/server/ClientMsgRateLimiter.ts delete mode 100644 src/server/IntentRateLimiter.ts create mode 100644 tests/server/ClientMsgRateLimiter.test.ts diff --git a/src/server/ClientMsgRateLimiter.ts b/src/server/ClientMsgRateLimiter.ts new file mode 100644 index 000000000..8e52a2028 --- /dev/null +++ b/src/server/ClientMsgRateLimiter.ts @@ -0,0 +1,69 @@ +import { RateLimiter } from "limiter"; +import { ClientID } from "../core/Schemas"; + +const INTENTS_PER_SECOND = 10; +const INTENTS_PER_MINUTE = 150; +const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client +const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small +export type RateLimitResult = "ok" | "limit" | "kick"; + +interface ClientBucket { + perSecond: RateLimiter; + perMinute: RateLimiter; + bytesPerMinute: RateLimiter; + hasSentWinnerMsg: boolean; +} + +export class ClientMsgRateLimiter { + private buckets = new Map(); + + check(clientID: ClientID, type: string, bytes: number): RateLimitResult { + const bucket = this.getOrCreate(clientID); + + // Winner message contains stats for all players and can be large (100s of KB). + // It bypasses the byte rate limit but is strictly limited to one per client. + if (type === "winner") { + if (bucket.hasSentWinnerMsg) return "kick"; + bucket.hasSentWinnerMsg = true; + return "ok"; + } + + // Intents are stored in turn history for the duration of the game, so + // oversized intents would accumulate and fill up server RAM. + if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick"; + + if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick"; + + if ( + !bucket.perSecond.tryRemoveTokens(1) || + !bucket.perMinute.tryRemoveTokens(1) + ) + return "limit"; + + return "ok"; + } + + private getOrCreate(clientID: ClientID): ClientBucket { + const existing = this.buckets.get(clientID); + if (existing) { + return existing; + } + const bucket = { + perSecond: new RateLimiter({ + tokensPerInterval: INTENTS_PER_SECOND, + interval: "second", + }), + perMinute: new RateLimiter({ + tokensPerInterval: INTENTS_PER_MINUTE, + interval: "minute", + }), + bytesPerMinute: new RateLimiter({ + tokensPerInterval: MAX_BYTES_PER_MINUTE, + interval: "minute", + }), + hasSentWinnerMsg: false, + }; + this.buckets.set(clientID, bucket); + return bucket; + } +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 300882775..49305432e 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -26,7 +26,7 @@ import { import { createPartialGameRecord, getClanTag } from "../core/Util"; import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; -import { IntentRateLimiter } from "./IntentRateLimiter"; +import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter"; export enum GamePhase { Lobby = "LOBBY", Active = "ACTIVE", @@ -35,12 +35,13 @@ export enum GamePhase { const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session"; const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator"; -const KICK_REASON_RATE_LIMIT = "kick_reason.rate_limit"; +const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data"; +const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message"; export class GameServer { private sentDesyncMessageClients = new Set(); - private intentRateLimiter = new IntentRateLimiter(); + private intentRateLimiter = new ClientMsgRateLimiter(); private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours @@ -315,31 +316,48 @@ export class GameServer { client.ws.removeAllListeners("message"); client.ws.on("message", async (message: string) => { try { - const bytes = Buffer.byteLength(message, "utf8"); - if (bytes > 2000) { - this.log.warn(`Intent message too large, kicking client`, { + let json: unknown; + try { + json = JSON.parse(message); + } catch (e) { + this.log.warn(`Failed to parse client message JSON, kicking`, { clientID: client.clientID, - bytes, + error: String(e), }); - this.kickClient(client.clientID, KICK_REASON_RATE_LIMIT); + this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE); return; } - const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); + const parsed = ClientMessageSchema.safeParse(json); if (!parsed.success) { - const error = z.prettifyError(parsed.error); - this.log.warn(`Failed to parse client message ${error}`, { + this.log.warn(`Failed to parse client message, kicking`, { clientID: client.clientID, + error: z.prettifyError(parsed.error), }); - client.ws.send( - JSON.stringify({ - type: "error", - error, - message: `Server could not parse message from client: ${message}`, - } satisfies ServerErrorMessage), - ); + this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE); return; } const clientMsg = parsed.data; + const bytes = Buffer.byteLength(message, "utf8"); + const rateResult = this.intentRateLimiter.check( + client.clientID, + clientMsg.type, + bytes, + ); + if (rateResult === "kick") { + this.log.warn(`Client rate limit exceeded, kicking`, { + clientID: client.clientID, + type: clientMsg.type, + }); + this.kickClient(client.clientID, KICK_REASON_TOO_MUCH_DATA); + return; + } + if (rateResult === "limit") { + this.log.warn(`Client message rate limit exceeded, dropping`, { + clientID: client.clientID, + type: clientMsg.type, + }); + return; + } switch (clientMsg.type) { case "rejoin": { // Client is already connected, no auth required, send start game message if game has started @@ -670,12 +688,6 @@ export class GameServer { } private addIntent(intent: StampedIntent) { - if (!this.intentRateLimiter.tryConsume(intent.clientID)) { - this.log.warn(`Intent rate limit exceeded`, { - clientID: intent.clientID, - }); - return; - } this.intents.push(intent); } diff --git a/src/server/IntentRateLimiter.ts b/src/server/IntentRateLimiter.ts deleted file mode 100644 index e96b59988..000000000 --- a/src/server/IntentRateLimiter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { RateLimiter } from "limiter"; -import { ClientID } from "../core/Schemas"; - -const INTENTS_PER_SECOND = 10; -const INTENTS_PER_MINUTE = 150; - -export class IntentRateLimiter { - private perSecond = new Map(); - private perMinute = new Map(); - - tryConsume(clientID: ClientID): boolean { - let second = this.perSecond.get(clientID); - if (!second) { - second = new RateLimiter({ - tokensPerInterval: INTENTS_PER_SECOND, - interval: "second", - }); - this.perSecond.set(clientID, second); - } - - let minute = this.perMinute.get(clientID); - if (!minute) { - minute = new RateLimiter({ - tokensPerInterval: INTENTS_PER_MINUTE, - interval: "minute", - }); - this.perMinute.set(clientID, minute); - } - - return second.tryRemoveTokens(1) && minute.tryRemoveTokens(1); - } -} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 2a2a0e46b..8e19c3474 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -48,7 +48,10 @@ export async function startWorker() { const app = express(); app.use(express.json({ limit: "5mb" })); const server = http.createServer(app); - const wss = new WebSocketServer({ noServer: true }); + const wss = new WebSocketServer({ + noServer: true, + maxPayload: 2 * 1024 * 1024, + }); const gm = new GameManager(config, log); diff --git a/src/server/WorkerLobbyService.ts b/src/server/WorkerLobbyService.ts index be0b49e01..ab8852968 100644 --- a/src/server/WorkerLobbyService.ts +++ b/src/server/WorkerLobbyService.ts @@ -19,7 +19,10 @@ export class WorkerLobbyService { private readonly gm: GameManager, private readonly log: typeof logger, ) { - this.lobbiesWss = new WebSocketServer({ noServer: true }); + this.lobbiesWss = new WebSocketServer({ + noServer: true, + maxPayload: 256 * 1024, + }); this.setupUpgradeHandler(); this.setupLobbiesWebSocket(); this.setupIPCListener(); diff --git a/tests/server/ClientMsgRateLimiter.test.ts b/tests/server/ClientMsgRateLimiter.test.ts new file mode 100644 index 000000000..ab70f5ef5 --- /dev/null +++ b/tests/server/ClientMsgRateLimiter.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { ClientMsgRateLimiter } from "../../src/server/ClientMsgRateLimiter"; + +const CLIENT_A = "clientA" as any; +const CLIENT_B = "clientB" as any; + +const SMALL = 100; +const LARGE = 501; // over MAX_INTENT_BYTES + +describe("ClientMsgRateLimiter", () => { + describe("intent messages", () => { + it("allows intents within limits", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + }); + + it("kicks on oversized intent", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick"); + }); + + it("limits when per-second count exceeded", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 10; i++) { + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + } + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("limit"); + }); + + it("rate limits are per client", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 10; i++) { + limiter.check(CLIENT_A, "intent", SMALL); + } + expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok"); + }); + }); + + describe("winner messages", () => { + it("allows first winner message", () => { + const limiter = new ClientMsgRateLimiter(); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok"); + }); + + it("kicks on second winner message", () => { + const limiter = new ClientMsgRateLimiter(); + limiter.check(CLIENT_A, "winner", 50000); + expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick"); + }); + + it("winner does not consume intent rate limit", () => { + const limiter = new ClientMsgRateLimiter(); + limiter.check(CLIENT_A, "winner", 50000); + expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok"); + }); + }); + + describe("other messages", () => { + it("applies rate limiting to other message types", () => { + const limiter = new ClientMsgRateLimiter(); + for (let i = 0; i < 10; i++) { + expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok"); + } + expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit"); + }); + }); +});