mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-07 01:26:46 +00:00
Server-side WebSocket message rate limiting & size enforcement (#3424)
## Description: * Adds ClientMsgRateLimiter — a per-client token-bucket rate limiter that gates all incoming WebSocket messages. Returns "ok", "limit" (drop), or "kick" based on the violation type. * Intent messages are capped at 500 bytes each (they are stored in turn history for the game duration, so oversized intents accumulate in server RAM). Violations kick the client. * Winner messages bypass the byte rate limit (they include stats for all players and can be 100s of KB) but are strictly capped at one per client — a second winner message kicks the client. * All other messages go through the standard per-second (10/s) and per-minute (150/min) rate limits. Violations drop the message; byte budget exhaustion kicks the client. * WebSocket maxPayload set to 2 MB on game workers. Invalid (unparseable) messages now immediately kick the client rather than being silently dropped. Unit tests added for all rate limiting behaviors. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
+36
-24
@@ -26,7 +26,7 @@ import {
|
||||
import { createPartialGameRecord, getClanTag } from "../core/Util";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { IntentRateLimiter } from "./IntentRateLimiter";
|
||||
import { ClientMsgRateLimiter } from "./ClientMsgRateLimiter";
|
||||
export enum GamePhase {
|
||||
Lobby = "LOBBY",
|
||||
Active = "ACTIVE",
|
||||
@@ -35,12 +35,13 @@ export enum GamePhase {
|
||||
|
||||
const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session";
|
||||
const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator";
|
||||
const KICK_REASON_RATE_LIMIT = "kick_reason.rate_limit";
|
||||
const KICK_REASON_TOO_MUCH_DATA = "kick_reason.too_much_data";
|
||||
const KICK_REASON_INVALID_MESSAGE = "kick_reason.invalid_message";
|
||||
|
||||
export class GameServer {
|
||||
private sentDesyncMessageClients = new Set<ClientID>();
|
||||
|
||||
private intentRateLimiter = new IntentRateLimiter();
|
||||
private intentRateLimiter = new ClientMsgRateLimiter();
|
||||
|
||||
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
|
||||
|
||||
@@ -315,31 +316,48 @@ export class GameServer {
|
||||
client.ws.removeAllListeners("message");
|
||||
client.ws.on("message", async (message: string) => {
|
||||
try {
|
||||
const bytes = Buffer.byteLength(message, "utf8");
|
||||
if (bytes > 2000) {
|
||||
this.log.warn(`Intent message too large, kicking client`, {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(message);
|
||||
} catch (e) {
|
||||
this.log.warn(`Failed to parse client message JSON, kicking`, {
|
||||
clientID: client.clientID,
|
||||
bytes,
|
||||
error: String(e),
|
||||
});
|
||||
this.kickClient(client.clientID, KICK_REASON_RATE_LIMIT);
|
||||
this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const parsed = ClientMessageSchema.safeParse(JSON.parse(message));
|
||||
const parsed = ClientMessageSchema.safeParse(json);
|
||||
if (!parsed.success) {
|
||||
const error = z.prettifyError(parsed.error);
|
||||
this.log.warn(`Failed to parse client message ${error}`, {
|
||||
this.log.warn(`Failed to parse client message, kicking`, {
|
||||
clientID: client.clientID,
|
||||
error: z.prettifyError(parsed.error),
|
||||
});
|
||||
client.ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
error,
|
||||
message: `Server could not parse message from client: ${message}`,
|
||||
} satisfies ServerErrorMessage),
|
||||
);
|
||||
this.kickClient(client.clientID, KICK_REASON_INVALID_MESSAGE);
|
||||
return;
|
||||
}
|
||||
const clientMsg = parsed.data;
|
||||
const bytes = Buffer.byteLength(message, "utf8");
|
||||
const rateResult = this.intentRateLimiter.check(
|
||||
client.clientID,
|
||||
clientMsg.type,
|
||||
bytes,
|
||||
);
|
||||
if (rateResult === "kick") {
|
||||
this.log.warn(`Client rate limit exceeded, kicking`, {
|
||||
clientID: client.clientID,
|
||||
type: clientMsg.type,
|
||||
});
|
||||
this.kickClient(client.clientID, KICK_REASON_TOO_MUCH_DATA);
|
||||
return;
|
||||
}
|
||||
if (rateResult === "limit") {
|
||||
this.log.warn(`Client message rate limit exceeded, dropping`, {
|
||||
clientID: client.clientID,
|
||||
type: clientMsg.type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
switch (clientMsg.type) {
|
||||
case "rejoin": {
|
||||
// Client is already connected, no auth required, send start game message if game has started
|
||||
@@ -670,12 +688,6 @@ export class GameServer {
|
||||
}
|
||||
|
||||
private addIntent(intent: StampedIntent) {
|
||||
if (!this.intentRateLimiter.tryConsume(intent.clientID)) {
|
||||
this.log.warn(`Intent rate limit exceeded`, {
|
||||
clientID: intent.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.intents.push(intent);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user