From 00b013978f3731d6e19aedfa85cd1dc2e31de2e8 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 1 Jun 2026 09:01:44 -0400 Subject: [PATCH] anti-abuse: cap concurrent sockets per IP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rate limiter caps new connections (10/min/IP) and game starts (15/min/IP), but one IP could still hold open unbounded sockets by trickling them in under the connection rate and keeping them alive โ€” hoarding spectator slots and server resources ahead of a public launch. Track live sockets per IP in ConnectionHandler and reject new ones past a cap (MAX_SOCKETS_PER_IP, default 10). Slots are freed on disconnect and reconciled against live sockets in the periodic cleanup so a missed disconnect can't wrongly lock out an IP. --- src/network/connectionHandler.js | 63 +++++++++++++++++++++++++++++- test/concurrentSocketCap.test.js | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 test/concurrentSocketCap.test.js diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index fb680f0..72d50a3 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -16,11 +16,45 @@ class ConnectionHandler { this.rateLimiter = rateLimiter; this.clientSocketMap = new Map(); - + + // Per-IP concurrent-socket cap. The rate limiter already caps NEW connections + // (10/min/IP) and game starts (15/min/IP), but nothing stopped one IP from + // *holding open* hundreds of sockets (trickle in under the rate limit, keep alive) + // to hoard server resources / spectator slots. Track live sockets per IP and reject + // beyond the cap. Default 10 โ€” generous for shared NATs / multi-tab, lethal to farms. + this.maxSocketsPerIp = parseInt(process.env.MAX_SOCKETS_PER_IP, 10) || 10; + this.socketsByIp = new Map(); // ip -> Set + this.socketIpMap = new Map(); // socketId -> ip (for O(1) disconnect cleanup) + // Memory leak prevention - cleanup old mappings this.mapCleanupInterval = setInterval(() => this.cleanupMappings(), 300000); // 5 minutes } + /** Register a live socket for its IP. @returns {boolean} false if the IP is at the cap. */ + _trackIpSocket(ip, socketId) { + if (!ip) return true; // can't attribute -> don't block + let set = this.socketsByIp.get(ip); + if (set && set.size >= this.maxSocketsPerIp && !set.has(socketId)) { + return false; + } + if (!set) { set = new Set(); this.socketsByIp.set(ip, set); } + set.add(socketId); + this.socketIpMap.set(socketId, ip); + return true; + } + + /** Drop a socket from its IP's live set. */ + _untrackIpSocket(socketId) { + const ip = this.socketIpMap.get(socketId); + if (!ip) return; + const set = this.socketsByIp.get(ip); + if (set) { + set.delete(socketId); + if (set.size === 0) this.socketsByIp.delete(ip); + } + this.socketIpMap.delete(socketId); + } + /** * Handle new socket connection with rate limiting and session management */ @@ -47,6 +81,19 @@ class ConnectionHandler { // Record the connection attempt await this.rateLimiter.recordAttempt(ip, 'connection:new', ip); + // Per-IP concurrent-socket cap (resource/hoarding protection). + if (!this._trackIpSocket(ip, socket.id)) { + if (this.debugManager.CONSOLE_LOGGING) { + console.log(`๐Ÿšซ Concurrent socket cap (${this.maxSocketsPerIp}) reached for IP ${ip}`); + } + socket.emit('rate_limited', { + action: 'connection', + message: 'Too many simultaneous connections from your network.' + }); + socket.disconnect(true); + return; + } + if (this.debugManager.CONSOLE_LOGGING) { console.log('A user connected'); console.log(socket.client.id); @@ -117,6 +164,9 @@ class ConnectionHandler { console.log('User disconnected', socket.client.id); } + // Release the per-IP concurrent-socket slot. + this._untrackIpSocket(socket.id); + // Clean up client socket mappings const clientId = this.clientSocketMap.get(socket.id); if (clientId) { @@ -324,6 +374,17 @@ class ConnectionHandler { } } + // Reconcile the per-IP concurrent-socket tracking against live sockets, in case a + // disconnect was ever missed (otherwise a leaked entry could wrongly cap an IP). + for (const [socketId, ip] of this.socketIpMap.entries()) { + if (!activeSocketIds.has(socketId)) { + const set = this.socketsByIp.get(ip); + if (set) { set.delete(socketId); if (set.size === 0) this.socketsByIp.delete(ip); } + this.socketIpMap.delete(socketId); + cleanedCount++; + } + } + if (this.debugManager.CONSOLE_LOGGING && cleanedCount > 0) { console.log(`๐Ÿงน ConnectionHandler cleanup: removed ${cleanedCount} stale mappings`); } diff --git a/test/concurrentSocketCap.test.js b/test/concurrentSocketCap.test.js new file mode 100644 index 0000000..d0d8f00 --- /dev/null +++ b/test/concurrentSocketCap.test.js @@ -0,0 +1,67 @@ +/** + * Per-IP concurrent-socket cap (anti-abuse / resource hoarding). + * + * The rate limiter caps NEW connections and game starts per minute, but nothing capped how + * many sockets a single IP could hold OPEN at once. ConnectionHandler now tracks live sockets + * per IP and refuses new ones past the cap; these tests exercise that bookkeeping directly. + */ + +const ConnectionHandler = require('../src/network/connectionHandler'); + +function makeHandler(maxPerIp) { + if (maxPerIp != null) process.env.MAX_SOCKETS_PER_IP = String(maxPerIp); + else delete process.env.MAX_SOCKETS_PER_IP; + return new ConnectionHandler({ + io: { sockets: { sockets: new Map() } }, + broadcastManager: { broadcastUserCount() {}, sendStatusUpdate() {} }, + debugManager: { CONSOLE_LOGGING: false, getCurrentBlockHeight: () => 1 }, + sessionManager: null, + rateLimiter: { getStats: () => ({}) } + }); +} + +describe('per-IP concurrent socket cap', () => { + let h; + afterEach(() => { if (h) h.shutdown(); h = null; delete process.env.MAX_SOCKETS_PER_IP; }); + + test('allows up to the cap, then rejects further sockets from the same IP', () => { + h = makeHandler(3); + expect(h._trackIpSocket('1.2.3.4', 's1')).toBe(true); + expect(h._trackIpSocket('1.2.3.4', 's2')).toBe(true); + expect(h._trackIpSocket('1.2.3.4', 's3')).toBe(true); + expect(h._trackIpSocket('1.2.3.4', 's4')).toBe(false); // over cap + }); + + test('a freed slot lets a new socket in', () => { + h = makeHandler(2); + expect(h._trackIpSocket('5.5.5.5', 'a')).toBe(true); + expect(h._trackIpSocket('5.5.5.5', 'b')).toBe(true); + expect(h._trackIpSocket('5.5.5.5', 'c')).toBe(false); + h._untrackIpSocket('a'); + expect(h._trackIpSocket('5.5.5.5', 'c')).toBe(true); + }); + + test('different IPs have independent budgets', () => { + h = makeHandler(1); + expect(h._trackIpSocket('1.1.1.1', 'x')).toBe(true); + expect(h._trackIpSocket('2.2.2.2', 'y')).toBe(true); // different IP, fine + expect(h._trackIpSocket('1.1.1.1', 'z')).toBe(false); + }); + + test('re-tracking the same socket id is idempotent (reconnect within same id)', () => { + h = makeHandler(1); + expect(h._trackIpSocket('9.9.9.9', 'same')).toBe(true); + expect(h._trackIpSocket('9.9.9.9', 'same')).toBe(true); // not double-counted + }); + + test('missing IP is never blocked (cannot attribute)', () => { + h = makeHandler(1); + expect(h._trackIpSocket(null, 's1')).toBe(true); + expect(h._trackIpSocket(undefined, 's2')).toBe(true); + }); + + test('defaults to 10 when MAX_SOCKETS_PER_IP unset', () => { + h = makeHandler(null); + expect(h.maxSocketsPerIp).toBe(10); + }); +});