diff --git a/src/server/Worker.ts b/src/server/Worker.ts index e72276819..497904334 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -3,6 +3,7 @@ import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; import ipAnonymize from "ip-anonymize"; +import { RateLimiter } from "limiter"; import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; @@ -289,6 +290,11 @@ export async function startWorker() { : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing forwarded || req.socket.remoteAddress || "unknown"; + if (!getWsIpLimiter(ip).tryRemoveTokens(1)) { + ws.close(1008, "Rate limit exceeded"); + return; + } + try { // Parse and handle client messages const parsed = ClientMessageSchema.safeParse( @@ -609,3 +615,21 @@ function generateGameIdForWorker(): GameID | null { log.warn(`Failed to generate game ID for worker ${workerId}`); return null; } + +// Per-IP rate limiter for pre-join WebSocket messages. +// Prevents unauthenticated connections from spamming messages +// (e.g. pings) before joining a game. +const wsIpLimiters = new Map(); +function getWsIpLimiter(ip: string): RateLimiter { + let limiter = wsIpLimiters.get(ip); + if (!limiter) { + limiter = new RateLimiter({ + tokensPerInterval: 5, + interval: "second", + }); + wsIpLimiters.set(ip, limiter); + } + return limiter; +} +// Clean up stale IP limiters every 10 minutes +setInterval(() => wsIpLimiters.clear(), 10 * 60 * 1000);