rate limit every socket message type, move ratelimiter into own class

This commit is contained in:
HF 2023-01-15 16:17:22 +01:00
parent a7c493913f
commit a7200ca4bd
4 changed files with 164 additions and 90 deletions

View File

@ -228,6 +228,15 @@ class SocketEvents extends EventEmitter {
this.emit('remChatChannel', userId, channelId);
}
/*
* trigger rate limit of ip
* @param ip
* @param blockTime in ms
*/
broadcastRateLimitTrigger(ip, blockTime) {
this.emit('rateLimitTrigger', ip, blockTime);
}
/*
* broadcast ranking list updates
* @param {

View File

@ -5,6 +5,7 @@ import WebSocket from 'ws';
import logger from '../core/logger';
import canvases from '../core/canvases';
import MassRateLimiter from '../utils/MassRateLimiter';
import Counter from '../utils/Counter';
import { getIPFromRequest, getHostFromRequest } from '../utils/ip';
import {
@ -33,27 +34,12 @@ import chatProvider, { ChatProvider } from '../core/ChatProvider';
import authenticateClient from './authenticateClient';
import drawByOffsets from '../core/draw';
import isIPAllowed from '../core/isAllowed';
import { HOUR } from '../core/constants';
import { checkCaptchaSolution } from '../data/redis/captcha';
const ipCounter = new Counter();
// key: ip: string
// value: [rlTimestamp, triggered]
const rateLimit = new Map();
setInterval(() => {
// clean old ratelimiter data
const now = Date.now();
const ips = [...rateLimit.keys()];
for (let i = 0; i < ips.length; i += 1) {
const ip = ips[i];
const limiter = rateLimit.get(ip);
if (limiter && now > limiter[0]) {
rateLimit.delete(ip);
}
}
}, 30 * 1000);
const rateLimiter = new MassRateLimiter(HOUR);
class SocketServer {
// WebSocket.Server
@ -179,50 +165,43 @@ class SocketServer {
});
});
socketEvents.on('rateLimitTrigger', (ip, blockTime) => {
rateLimiter.forceTrigger(ip, blockTime);
const amount = this.killAllWsByUerIp(ip);
if (amount) {
logger.warn(`Killed ${amount} connections for RateLimit`);
}
});
setInterval(this.onlineCounterBroadcast, 20 * 1000);
setInterval(this.checkHealth, 15 * 1000);
}
static async onRateLimitTrigger(ip, blockTime, reason) {
logger.warn(
`Client ${ip} triggered Socket-RateLimit by ${reason}.`,
);
socketEvents.broadcastRateLimitTrigger(ip, blockTime);
}
async handleUpgrade(request, socket, head) {
const { headers } = request;
const ip = getIPFromRequest(request);
// trigger proxycheck
isIPAllowed(ip);
/*
* rate limiter
* rate limit
*/
const now = Date.now();
const limiter = rateLimit.get(ip);
// rate limit socket requests
if (limiter && limiter[0] > now) {
/*
* reject if rate limiter triggered
*/
if (limiter[1]) {
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
socket.destroy();
return;
}
/*
* add +3s to limiter per connection attempt,
* trigger limiter if time is 60s in the future,
*/
limiter[0] += 3000;
if (limiter[0] > Date.now() + 60000) {
limiter[1] = true;
// block for 15min
limiter[0] += 1000 * 60 * 15;
const amount = this.killAllWsByUerIp(ip);
logger.warn(
// eslint-disable-next-line max-len
`Client ${ip} triggered Socket-RateLimit by connection attempts, killed ${amount} connections.`,
);
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
socket.destroy();
return;
}
} else {
rateLimit.set(ip, [now + 3000, false]);
const isLimited = rateLimiter.tick(
ip,
3000,
'connection attempts',
SocketServer.onRateLimitTrigger,
);
if (isLimited) {
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
socket.destroy();
return;
}
/*
* enforce CORS
@ -242,11 +221,7 @@ class SocketServer {
* Limiting socket connections per ip
*/
if (ipCounter.get(ip) > 50) {
/*
* setting rate limit to not allow reconnection within 15min,
* and kill all sockets of this IP
*/
rateLimit.set(ip, [now + 1000 * 60 * 15, true]);
rateLimiter.forceTrigger(ip, HOUR);
const amount = this.killAllWsByUerIp(ip);
logger.info(
`Client ${ip} has more than 50 connections open, killed ${amount}.`,
@ -449,10 +424,18 @@ class SocketServer {
}
async onTextMessage(text, ws) {
/*
* all client -> server text messages are
* chat messages in [message, channelId] format
*/
const { ip } = ws.user;
// rate limit
const isLimited = rateLimiter.tick(
ip,
1000,
'text message spam',
SocketServer.onRateLimitTrigger,
);
if (isLimited) {
return;
}
// ---
try {
const comma = text.indexOf(',');
if (comma === -1) {
@ -501,7 +484,7 @@ class SocketServer {
const [solution, captchaid] = val;
const ret = await checkCaptchaSolution(
solution,
user.ip,
ip,
false,
captchaid,
);
@ -520,28 +503,29 @@ class SocketServer {
async onBinaryMessage(buffer, ws) {
try {
const { ip } = ws.user;
const now = Date.now();
let limiter = rateLimit.get(ip);
if (limiter && limiter[0] > now) {
if (limiter[1]) {
return;
}
if (limiter[0] > Date.now() + 60000) {
limiter[1] = true;
limiter[0] += 1000 * 60 * 60;
const amount = this.killAllWsByUerIp(ip);
logger.warn(
// eslint-disable-next-line max-len
`Client ${ip} triggered Socket-RateLimit by binary requests, killed ${amount} connections`,
);
}
limiter[0] += 200;
} else {
limiter = [now + 200, false];
rateLimit.set(ip, limiter);
}
const opcode = buffer[0];
// rate limit
let limiterDeltaTime = 200;
let reason = 'socket spam';
if (opcode === REG_CHUNK_OP) {
limiterDeltaTime = 50;
reason = 'register chunk spam';
} else if (opcode === DEREG_CHUNK_OP) {
limiterDeltaTime = 10;
reason = 'deregister chunk spam';
}
const isLimited = rateLimiter.tick(
ip,
limiterDeltaTime,
reason,
SocketServer.onRateLimitTrigger,
);
if (isLimited) {
return;
}
// ----
switch (opcode) {
case PIXEL_UPDATE_OP: {
const { canvasId } = ws;
@ -569,7 +553,7 @@ class SocketServer {
);
if (retCode > 9 && retCode !== 13) {
limiter[0] += 800;
rateLimiter.add(ip, 800);
}
ws.send(dehydratePixelReturn(
@ -595,7 +579,6 @@ class SocketServer {
case REG_CHUNK_OP: {
const chunkid = hydrateRegChunk(buffer);
this.pushChunk(chunkid, ws);
limiter[0] -= 150;
break;
}
case REG_MCHUNKS_OP: {
@ -608,7 +591,6 @@ class SocketServer {
case DEREG_CHUNK_OP: {
const chunkid = hydrateDeRegChunk(buffer);
this.deleteChunk(chunkid, ws);
limiter[0] -= 190;
break;
}
case DEREG_MCHUNKS_OP: {

View File

@ -0,0 +1,88 @@
/*
* Rate Limiter for multiple clients per instance,
* Always has 1min burst time
* Once triggered, always have to wait
*/
class MassRateLimiter {
/*
* Map<identifier:
* [
* time: absolute timestamp (if 1min in the future: trigger),
* triggered: boolean if limit is triggered,
* ]
* >
*/
triggers;
/*
* blockTime time a client is blocked once limit is triggered
*/
blockTime;
constructor(blockTime) {
this.triggers = new Map();
this.blockTime = blockTime;
this.clearOld = this.clearOld.bind(this);
setInterval(this.clearOld, 60 * 1000);
}
clearOld() {
const now = Date.now();
const { triggers } = this;
[...triggers.keys()].forEach((identifier) => {
const limiter = triggers.get(identifier);
if (limiter && now > limiter[0]) {
triggers.delete(identifier);
}
});
}
/*
* tick the rate limit for one identifier
* @param identifier
* @param deltaTime by which to increase time
* @param reason string describing the tick
* @param onTrigger callback that gets called on trigger
* @return boolean if triggered
*/
tick(identifier, deltaTime, reason, onTrigger) {
const limiter = this.triggers.get(identifier);
const now = Date.now();
if (limiter && limiter[0] > now) {
if (limiter[1]) {
return true;
}
limiter[0] += deltaTime;
if (limiter[0] > now + 60000) {
limiter[1] = true;
limiter[0] += this.blockTime;
onTrigger(identifier, this.blockTime, reason);
return true;
}
} else {
this.triggers.set(
identifier,
[now + deltaTime, false],
);
}
return false;
}
/*
* force trigger rate limit
*/
forceTrigger(identifier, blockTime) {
this.triggers.set(
identifier,
[Date.now() + blockTime, true],
);
}
/*
* add to deltaTime without checking
*/
add(identifier, deltaTime) {
this.triggers.get(identifier)[0] += deltaTime;
}
}
export default MassRateLimiter;

View File

@ -1,10 +1,5 @@
/*
* rate limiter utils
*/
/*
* RateLimiter
* RateLimiter for a single client per instance
* @param ticksPerMin How many ticks per min are allowed
* @param burst Amount of ticks that are allowed before limiter kicks in
* @param onCooldown If we force to wait the whole burst time once the limit is reached