rate limit every socket message type, move ratelimiter into own class
This commit is contained in:
parent
a7c493913f
commit
a7200ca4bd
|
@ -228,6 +228,15 @@ class SocketEvents extends EventEmitter {
|
||||||
this.emit('remChatChannel', userId, channelId);
|
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
|
* broadcast ranking list updates
|
||||||
* @param {
|
* @param {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import WebSocket from 'ws';
|
||||||
|
|
||||||
import logger from '../core/logger';
|
import logger from '../core/logger';
|
||||||
import canvases from '../core/canvases';
|
import canvases from '../core/canvases';
|
||||||
|
import MassRateLimiter from '../utils/MassRateLimiter';
|
||||||
import Counter from '../utils/Counter';
|
import Counter from '../utils/Counter';
|
||||||
import { getIPFromRequest, getHostFromRequest } from '../utils/ip';
|
import { getIPFromRequest, getHostFromRequest } from '../utils/ip';
|
||||||
import {
|
import {
|
||||||
|
@ -33,27 +34,12 @@ import chatProvider, { ChatProvider } from '../core/ChatProvider';
|
||||||
import authenticateClient from './authenticateClient';
|
import authenticateClient from './authenticateClient';
|
||||||
import drawByOffsets from '../core/draw';
|
import drawByOffsets from '../core/draw';
|
||||||
import isIPAllowed from '../core/isAllowed';
|
import isIPAllowed from '../core/isAllowed';
|
||||||
|
import { HOUR } from '../core/constants';
|
||||||
import { checkCaptchaSolution } from '../data/redis/captcha';
|
import { checkCaptchaSolution } from '../data/redis/captcha';
|
||||||
|
|
||||||
|
|
||||||
const ipCounter = new Counter();
|
const ipCounter = new Counter();
|
||||||
// key: ip: string
|
const rateLimiter = new MassRateLimiter(HOUR);
|
||||||
// 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);
|
|
||||||
|
|
||||||
|
|
||||||
class SocketServer {
|
class SocketServer {
|
||||||
// WebSocket.Server
|
// 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.onlineCounterBroadcast, 20 * 1000);
|
||||||
setInterval(this.checkHealth, 15 * 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) {
|
async handleUpgrade(request, socket, head) {
|
||||||
const { headers } = request;
|
const { headers } = request;
|
||||||
const ip = getIPFromRequest(request);
|
const ip = getIPFromRequest(request);
|
||||||
// trigger proxycheck
|
// trigger proxycheck
|
||||||
isIPAllowed(ip);
|
isIPAllowed(ip);
|
||||||
/*
|
/*
|
||||||
* rate limiter
|
* rate limit
|
||||||
*/
|
*/
|
||||||
const now = Date.now();
|
const isLimited = rateLimiter.tick(
|
||||||
const limiter = rateLimit.get(ip);
|
ip,
|
||||||
// rate limit socket requests
|
3000,
|
||||||
if (limiter && limiter[0] > now) {
|
'connection attempts',
|
||||||
/*
|
SocketServer.onRateLimitTrigger,
|
||||||
* reject if rate limiter triggered
|
);
|
||||||
*/
|
if (isLimited) {
|
||||||
if (limiter[1]) {
|
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
|
||||||
socket.write('HTTP/1.1 429 Too Many Requests\r\n\r\n');
|
socket.destroy();
|
||||||
socket.destroy();
|
return;
|
||||||
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]);
|
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* enforce CORS
|
* enforce CORS
|
||||||
|
@ -242,11 +221,7 @@ class SocketServer {
|
||||||
* Limiting socket connections per ip
|
* Limiting socket connections per ip
|
||||||
*/
|
*/
|
||||||
if (ipCounter.get(ip) > 50) {
|
if (ipCounter.get(ip) > 50) {
|
||||||
/*
|
rateLimiter.forceTrigger(ip, HOUR);
|
||||||
* setting rate limit to not allow reconnection within 15min,
|
|
||||||
* and kill all sockets of this IP
|
|
||||||
*/
|
|
||||||
rateLimit.set(ip, [now + 1000 * 60 * 15, true]);
|
|
||||||
const amount = this.killAllWsByUerIp(ip);
|
const amount = this.killAllWsByUerIp(ip);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Client ${ip} has more than 50 connections open, killed ${amount}.`,
|
`Client ${ip} has more than 50 connections open, killed ${amount}.`,
|
||||||
|
@ -449,10 +424,18 @@ class SocketServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
async onTextMessage(text, ws) {
|
async onTextMessage(text, ws) {
|
||||||
/*
|
const { ip } = ws.user;
|
||||||
* all client -> server text messages are
|
// rate limit
|
||||||
* chat messages in [message, channelId] format
|
const isLimited = rateLimiter.tick(
|
||||||
*/
|
ip,
|
||||||
|
1000,
|
||||||
|
'text message spam',
|
||||||
|
SocketServer.onRateLimitTrigger,
|
||||||
|
);
|
||||||
|
if (isLimited) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ---
|
||||||
try {
|
try {
|
||||||
const comma = text.indexOf(',');
|
const comma = text.indexOf(',');
|
||||||
if (comma === -1) {
|
if (comma === -1) {
|
||||||
|
@ -501,7 +484,7 @@ class SocketServer {
|
||||||
const [solution, captchaid] = val;
|
const [solution, captchaid] = val;
|
||||||
const ret = await checkCaptchaSolution(
|
const ret = await checkCaptchaSolution(
|
||||||
solution,
|
solution,
|
||||||
user.ip,
|
ip,
|
||||||
false,
|
false,
|
||||||
captchaid,
|
captchaid,
|
||||||
);
|
);
|
||||||
|
@ -520,28 +503,29 @@ class SocketServer {
|
||||||
async onBinaryMessage(buffer, ws) {
|
async onBinaryMessage(buffer, ws) {
|
||||||
try {
|
try {
|
||||||
const { ip } = ws.user;
|
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];
|
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) {
|
switch (opcode) {
|
||||||
case PIXEL_UPDATE_OP: {
|
case PIXEL_UPDATE_OP: {
|
||||||
const { canvasId } = ws;
|
const { canvasId } = ws;
|
||||||
|
@ -569,7 +553,7 @@ class SocketServer {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (retCode > 9 && retCode !== 13) {
|
if (retCode > 9 && retCode !== 13) {
|
||||||
limiter[0] += 800;
|
rateLimiter.add(ip, 800);
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.send(dehydratePixelReturn(
|
ws.send(dehydratePixelReturn(
|
||||||
|
@ -595,7 +579,6 @@ class SocketServer {
|
||||||
case REG_CHUNK_OP: {
|
case REG_CHUNK_OP: {
|
||||||
const chunkid = hydrateRegChunk(buffer);
|
const chunkid = hydrateRegChunk(buffer);
|
||||||
this.pushChunk(chunkid, ws);
|
this.pushChunk(chunkid, ws);
|
||||||
limiter[0] -= 150;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case REG_MCHUNKS_OP: {
|
case REG_MCHUNKS_OP: {
|
||||||
|
@ -608,7 +591,6 @@ class SocketServer {
|
||||||
case DEREG_CHUNK_OP: {
|
case DEREG_CHUNK_OP: {
|
||||||
const chunkid = hydrateDeRegChunk(buffer);
|
const chunkid = hydrateDeRegChunk(buffer);
|
||||||
this.deleteChunk(chunkid, ws);
|
this.deleteChunk(chunkid, ws);
|
||||||
limiter[0] -= 190;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case DEREG_MCHUNKS_OP: {
|
case DEREG_MCHUNKS_OP: {
|
||||||
|
|
88
src/utils/MassRateLimiter.js
Normal file
88
src/utils/MassRateLimiter.js
Normal 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;
|
|
@ -1,10 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* rate limiter utils
|
* RateLimiter for a single client per instance
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
/*
|
|
||||||
* RateLimiter
|
|
||||||
* @param ticksPerMin How many ticks per min are allowed
|
* @param ticksPerMin How many ticks per min are allowed
|
||||||
* @param burst Amount of ticks that are allowed before limiter kicks in
|
* @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
|
* @param onCooldown If we force to wait the whole burst time once the limit is reached
|
||||||
|
|
Loading…
Reference in New Issue
Block a user