From 2269a1b498176f5129bf1ca82b65e4cc93859d25 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Thu, 24 Sep 2026 17:12:46 +0530 Subject: [PATCH 1/6] security: add shared rate limits for expensive API operations --- src/middlewares/rateLimiters.ts | 41 +++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/src/middlewares/rateLimiters.ts b/src/middlewares/rateLimiters.ts index 36fc9b2..58410aa 100644 --- a/src/middlewares/rateLimiters.ts +++ b/src/middlewares/rateLimiters.ts @@ -1,16 +1,39 @@ import rateLimit from "express-rate-limit"; +import { redisService } from "../chess/RedisService.js"; + +type RateLimitPlan = { + windowMs: number; + limit: number; + keyPrefix: string; +}; + +const envInt = (name: string, fallback: number): number => { + const value = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(value) && value > 0 ? value : fallback; +}; -/** - * Auth endpoints (signup/login/google) were previously unprotected. Each - * request does real bcrypt work (cost factor 10, deliberately slow) and/or a - * DB write, so a burst of concurrent requests here is the most CPU/DB - * expensive thing this API can be asked to do. This limits abuse/brute-force - * without affecting normal usage. - */ export const authRateLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - limit: 20, // 20 attempts per IP per window + windowMs: 15 * 60 * 1000, + limit: envInt("AUTH_RATE_LIMIT", 20), standardHeaders: true, legacyHeaders: false, message: { message: "Too many auth requests — please try again later." }, }); + +const redisLimiter = (plan: RateLimitPlan) => async (req: any, res: any, next: any) => { + const userId = req.user?.userId; + const ip = req.ip || req.socket?.remoteAddress || "unknown"; + const identity = userId ? `user:${userId}` : `ip:${ip}`; + const result = await redisService.rateLimit(`${plan.keyPrefix}:${identity}`, plan.limit, plan.windowMs); + res.setHeader("X-RateLimit-Limit", plan.limit); + res.setHeader("X-RateLimit-Remaining", result.remaining); + if (!result.allowed) { + res.setHeader("Retry-After", Math.max(1, Math.ceil(result.retryAfterMs / 1000))); + return res.status(429).json({ message: "Too many requests — please try again later." }); + } + return next(); +}; + +export const analysisRateLimiter = redisLimiter({ windowMs: 60 * 1000, limit: envInt("ANALYSIS_RATE_LIMIT", 10), keyPrefix: "analysis" }); +export const aiRateLimiter = redisLimiter({ windowMs: 60 * 1000, limit: envInt("AI_RATE_LIMIT", 20), keyPrefix: "ai" }); +export const puzzleAttemptRateLimiter = redisLimiter({ windowMs: 60 * 1000, limit: envInt("PUZZLE_ATTEMPT_RATE_LIMIT", 30), keyPrefix: "puzzle-attempt" }); From 660d87f3aa6180a425cc33daae6e0680d52c3be4 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Thu, 24 Sep 2026 17:12:54 +0530 Subject: [PATCH 2/6] security: add atomic websocket connection limits --- src/chess/RedisService.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/chess/RedisService.ts b/src/chess/RedisService.ts index b8a2b14..bcf23c9 100644 --- a/src/chess/RedisService.ts +++ b/src/chess/RedisService.ts @@ -273,6 +273,34 @@ class RedisService { // ── Cross-instance pub/sub ────────────────────────────────────────────────── /** Deliver `message` to whichever instance (if any) holds playerId's live socket. */ + async tryAcquireWsConnection(key: string, limit: number, ttlSeconds: number): Promise { + try { + await this.waitReady(); + const redisKey = `ws:connections:${key}`; + const count = await this.client!.incr(redisKey); + if (count === 1) await this.client!.expire(redisKey, ttlSeconds); + if (count <= limit) return true; + + const after = await this.client!.decr(redisKey); + if (after <= 0) await this.client!.del(redisKey); + return false; + } catch (err) { + console.error("Redis tryAcquireWsConnection failed (failing closed):", err); + return false; + } + } + + async releaseWsConnection(key: string): Promise { + try { + await this.waitReady(); + const redisKey = `ws:connections:${key}`; + const count = await this.client!.decr(redisKey); + if (count <= 0) await this.client!.del(redisKey); + } catch (err) { + console.error("Redis releaseWsConnection failed:", err); + } + } + async publishToPlayer(playerId: string, gameId: string, message: unknown): Promise { if (!this.client) return; // no Redis = single instance, nothing to relay try { From 5805113661fdd3201cd66dc2e4471249b8c2194c Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Thu, 24 Sep 2026 17:13:04 +0530 Subject: [PATCH 3/6] security: cap websocket connections per user and IP --- src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 2babf92..7a9002a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import AuthRouter from "./routes/authRoutes.js"; import { GameManager } from "./chess/GameManager.js"; import { verifyToken } from "./utils/jwt.js"; import { isFrontendRoute } from "./frontendRegistry.js"; +import { redisService } from "./chess/RedisService.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -90,6 +91,9 @@ const server = createServer(app); const wss = new WebSocketServer({ server }); const gameManager = new GameManager(); +const WS_MAX_PER_IP = Number.parseInt(process.env.WS_MAX_CONNECTIONS_PER_IP ?? "20", 10); +const WS_MAX_PER_USER = Number.parseInt(process.env.WS_MAX_CONNECTIONS_PER_USER ?? "5", 10); +const WS_CONNECTION_TTL_SECONDS = 2 * 60 * 60; console.log( `Game routing instance id: ${gameManager.id}` + (process.env.REDIS_HOST @@ -97,8 +101,12 @@ console.log( : " (Redis disabled — matches/reconnects only work within THIS single instance)") ); -wss.on("connection", (ws: WebSocket, req) => { +wss.on("connection", async (ws: WebSocket, req) => { let dbUserId: number | null = null; + const forwardedFor = req.headers["x-forwarded-for"]; + const ip = (Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor?.split(",")[0])?.trim() + || req.socket.remoteAddress + || "unknown"; try { const { query } = parse(req.url || "", true); From 2ae02a5a2b9db7cd5636de42e37e7b836ff60397 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Thu, 24 Sep 2026 17:13:09 +0530 Subject: [PATCH 4/6] security: reject invalid websocket authentication tokens From 40ba1f15b02ae3c540a710b3b12a85809316cda5 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Thu, 24 Sep 2026 17:13:20 +0530 Subject: [PATCH 5/6] security: rate limit analysis AI and puzzle endpoints --- src/routes/authRoutes.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts index 1e3989d..4d581eb 100644 --- a/src/routes/authRoutes.ts +++ b/src/routes/authRoutes.ts @@ -1,7 +1,12 @@ import { Router } from "express"; import AuthController from "../controllers/authController.js"; import AuthMiddleware from "../middlewares/authMiddleware.js"; -import { authRateLimiter } from "../middlewares/rateLimiters.js"; +import { + authRateLimiter, + analysisRateLimiter, + aiRateLimiter, + puzzleAttemptRateLimiter, +} from "../middlewares/rateLimiters.js"; import GameController from "../controllers/gameController.js"; import AnalysisController from "../controllers/analysisController.js"; import AIController from "../controllers/aiController.js"; @@ -15,24 +20,18 @@ router.post("/auth/login", authRateLimiter, AuthController.login); router.post("/auth/google", authRateLimiter, AuthController.googleLogin); router.get("/auth/me", AuthMiddleware.authenticate, AuthController.getMe); -// Chess game history — protected routes router.get("/games", AuthMiddleware.authenticate, GameController.getMyGames); router.get("/games/:gameId", AuthMiddleware.authenticate, GameController.getGameById); -// Stockfish analysis — protected, computationally heavy, cached -router.post("/games/:gameId/analyze", AuthMiddleware.authenticate, AnalysisController.analyze); +router.post("/games/:gameId/analyze", AuthMiddleware.authenticate, analysisRateLimiter, AnalysisController.analyze); +router.post("/games/:gameId/ai-analyze", AuthMiddleware.authenticate, aiRateLimiter, AIController.generateAnalysis); +router.post("/games/:gameId/ai-chat", AuthMiddleware.authenticate, aiRateLimiter, AIController.chat); +router.delete("/games/:gameId/ai-session", AuthMiddleware.authenticate, aiRateLimiter, AIController.clearSession); -// AI narrative analysis & chat — powered by OpenRouter -router.post("/games/:gameId/ai-analyze", AuthMiddleware.authenticate, AIController.generateAnalysis); -router.post("/games/:gameId/ai-chat", AuthMiddleware.authenticate, AIController.chat); -router.delete("/games/:gameId/ai-session", AuthMiddleware.authenticate, AIController.clearSession); - -// Puzzles — rating-matched tactics training (Glicko-2 rated) router.get("/puzzles/next", AuthMiddleware.authenticate, PuzzleController.getNext); router.get("/puzzles/me", AuthMiddleware.authenticate, PuzzleController.getMyStats); -router.post("/puzzles/:puzzleId/attempt", AuthMiddleware.authenticate, PuzzleController.submitAttempt); +router.post("/puzzles/:puzzleId/attempt", AuthMiddleware.authenticate, puzzleAttemptRateLimiter, PuzzleController.submitAttempt); -// Leaderboard — public router.get("/leaderboard", LeaderboardController.getLeaderboard); export default router; From 3930c7f09184e2d0950fd90909e3b7d7905e95b7 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Thu, 24 Sep 2026 17:13:27 +0530 Subject: [PATCH 6/6] security: enforce websocket authentication and connection limits --- src/index.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7a9002a..8e7a0e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -120,11 +120,35 @@ wss.on("connection", async (ws: WebSocket, req) => { console.log("Anonymous WS connection (no token provided)"); } } catch { - console.log("WS connection with invalid token — treating as guest"); + console.log("WS connection rejected — invalid token"); + ws.close(1008, "Invalid authentication token"); + return; + } + + const identityKey = dbUserId !== null ? `user:${dbUserId}` : `ip:${ip}`; + const maxConnections = dbUserId !== null ? WS_MAX_PER_USER : WS_MAX_PER_IP; + + if (!Number.isFinite(maxConnections) || maxConnections <= 0) { + ws.close(1013, "WebSocket connections temporarily unavailable"); + return; + } + + const acquired = await redisService.tryAcquireWsConnection( + identityKey, + maxConnections, + WS_CONNECTION_TTL_SECONDS + ); + + if (!acquired) { + ws.close(1008, "Too many WebSocket connections"); + return; } gameManager.addUser(ws, dbUserId); - ws.on("close", () => gameManager.removeUser(ws)); + ws.on("close", () => { + gameManager.removeUser(ws); + void redisService.releaseWsConnection(identityKey); + }); }); const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;