From 27023ea58e6a1df162487e67ecf02fa071fe5954 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Tue, 23 Jun 2026 00:45:19 +0200 Subject: [PATCH] feat(server): raise event rate limit to 50 and name rate-limit constants Raise the per-socket event budget from 30 to 50 per 10s to give legitimate bursts (episode joins, force-sync, ACK flurries) more headroom. Extract the previously inline connection/event/health/admin windows and limits into named constants (CONNECTION_RATE_LIMIT, EVENT_RATE_LIMIT, *_WINDOW_MS). Tests now derive their thresholds from the exported constants instead of hardcoding them. Co-Authored-By: Claude Opus 4.8 --- scripts/test-rate-limiter.mjs | 14 +++++++++----- server/rate-limiter.js | 28 ++++++++++++++++++---------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/scripts/test-rate-limiter.mjs b/scripts/test-rate-limiter.mjs index d37dab1..8cd4dd7 100644 --- a/scripts/test-rate-limiter.mjs +++ b/scripts/test-rate-limiter.mjs @@ -15,7 +15,9 @@ import { roomListCooldowns, rateLimitDenied, startRateLimitCleanup, - stopRateLimitCleanup + stopRateLimitCleanup, + CONNECTION_RATE_LIMIT, + EVENT_RATE_LIMIT } from '../server/rate-limiter.js'; // Helper: mock io for cleanup @@ -31,8 +33,9 @@ function reset() { // --- checkConnectionRate --- reset(); assert.equal(checkConnectionRate('1.1.1.1'), true, 'first connection allowed'); -for (let i = 0; i < 9; i++) checkConnectionRate('1.1.1.1'); -assert.equal(checkConnectionRate('1.1.1.1'), false, '11th connection blocked'); +// Exhaust the rest of the budget (first call above counted as 1). +for (let i = 0; i < CONNECTION_RATE_LIMIT - 1; i++) checkConnectionRate('1.1.1.1'); +assert.equal(checkConnectionRate('1.1.1.1'), false, `connection beyond ${CONNECTION_RATE_LIMIT}/window blocked`); assert.equal(rateLimitDenied.connections, 1, 'denial counter incremented'); reset(); @@ -41,8 +44,9 @@ assert.equal(checkConnectionRate('2.2.2.2'), true, 'separate IP independent'); // --- checkEventRate --- reset(); assert.equal(checkEventRate('sock1'), true, 'first event allowed'); -for (let i = 0; i < 29; i++) checkEventRate('sock1'); -assert.equal(checkEventRate('sock1'), false, '31st event blocked'); +// Exhaust the rest of the budget (first call above counted as 1). +for (let i = 0; i < EVENT_RATE_LIMIT - 1; i++) checkEventRate('sock1'); +assert.equal(checkEventRate('sock1'), false, `event beyond ${EVENT_RATE_LIMIT}/window blocked`); assert.equal(rateLimitDenied.events, 1); reset(); diff --git a/server/rate-limiter.js b/server/rate-limiter.js index be0662c..69bcf83 100644 --- a/server/rate-limiter.js +++ b/server/rate-limiter.js @@ -7,6 +7,14 @@ export const ROOM_LIST_COOLDOWN_MS = 10000; export const HEALTH_RATE_LIMIT_PER_MINUTE = 10; export const ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE = 5; +// --- Connection & event budgets (formerly inline magic numbers) --- +export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP per window +export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute +export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window +export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds +export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute +export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute + export const connectionCounts = new Map(); // ip -> { count, resetTime } export const failedAuthAttempts = new Map(); // Map export const eventCounts = new Map(); // socketId -> { count, resetTime } @@ -72,30 +80,30 @@ export function recordAuthFailure(ip, roomId) { export function checkConnectionRate(ip) { const now = Date.now(); - const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + 60000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } + const entry = connectionCounts.get(ip) || { count: 0, resetTime: now + CONNECTION_RATE_WINDOW_MS }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CONNECTION_RATE_WINDOW_MS; } entry.count++; connectionCounts.set(ip, entry); - if (entry.count <= 10) return true; + if (entry.count <= CONNECTION_RATE_LIMIT) return true; rateLimitDenied.connections++; return false; } export function checkEventRate(socketId) { const now = Date.now(); - const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + 10000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 10000; } + const entry = eventCounts.get(socketId) || { count: 0, resetTime: now + EVENT_RATE_WINDOW_MS }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + EVENT_RATE_WINDOW_MS; } entry.count++; eventCounts.set(socketId, entry); - if (entry.count <= 30) return true; + if (entry.count <= EVENT_RATE_LIMIT) return true; rateLimitDenied.events++; return false; } export function checkHealthRate(ip) { const now = Date.now(); - const entry = healthCounts.get(ip) || { count: 0, resetTime: now + 60000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } + const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + HEALTH_RATE_WINDOW_MS; } entry.count++; healthCounts.set(ip, entry); if (entry.count <= HEALTH_RATE_LIMIT_PER_MINUTE) return true; @@ -105,8 +113,8 @@ export function checkHealthRate(ip) { export function checkAdminMetricsAuthRate(ip) { const now = Date.now(); - const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + 60000 }; - if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + 60000; } + const entry = adminMetricsAuthCounts.get(ip) || { count: 0, resetTime: now + ADMIN_METRICS_AUTH_WINDOW_MS }; + if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + ADMIN_METRICS_AUTH_WINDOW_MS; } entry.count++; adminMetricsAuthCounts.set(ip, entry); if (entry.count <= ADMIN_METRICS_AUTH_RATE_LIMIT_PER_MINUTE) return true;