anti-abuse: cap concurrent sockets per IP

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.
This commit is contained in:
jwinterm
2026-06-01 09:01:44 -04:00
parent 038f47051f
commit 00b013978f
2 changed files with 129 additions and 1 deletions
+62 -1
View File
@@ -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<socketId>
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`);
}
+67
View File
@@ -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);
});
});