mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +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:
@@ -0,0 +1,69 @@
|
||||
import { RateLimiter } from "limiter";
|
||||
import { ClientID } from "../core/Schemas";
|
||||
|
||||
const INTENTS_PER_SECOND = 10;
|
||||
const INTENTS_PER_MINUTE = 150;
|
||||
const MAX_BYTES_PER_MINUTE = 25 * 1024; // 25KB/min per client
|
||||
const MAX_INTENT_BYTES = 500; // intents are stored in turns, keep them small
|
||||
export type RateLimitResult = "ok" | "limit" | "kick";
|
||||
|
||||
interface ClientBucket {
|
||||
perSecond: RateLimiter;
|
||||
perMinute: RateLimiter;
|
||||
bytesPerMinute: RateLimiter;
|
||||
hasSentWinnerMsg: boolean;
|
||||
}
|
||||
|
||||
export class ClientMsgRateLimiter {
|
||||
private buckets = new Map<ClientID, ClientBucket>();
|
||||
|
||||
check(clientID: ClientID, type: string, bytes: number): RateLimitResult {
|
||||
const bucket = this.getOrCreate(clientID);
|
||||
|
||||
// Winner message contains stats for all players and can be large (100s of KB).
|
||||
// It bypasses the byte rate limit but is strictly limited to one per client.
|
||||
if (type === "winner") {
|
||||
if (bucket.hasSentWinnerMsg) return "kick";
|
||||
bucket.hasSentWinnerMsg = true;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
// Intents are stored in turn history for the duration of the game, so
|
||||
// oversized intents would accumulate and fill up server RAM.
|
||||
if (type === "intent" && bytes > MAX_INTENT_BYTES) return "kick";
|
||||
|
||||
if (!bucket.bytesPerMinute.tryRemoveTokens(bytes)) return "kick";
|
||||
|
||||
if (
|
||||
!bucket.perSecond.tryRemoveTokens(1) ||
|
||||
!bucket.perMinute.tryRemoveTokens(1)
|
||||
)
|
||||
return "limit";
|
||||
|
||||
return "ok";
|
||||
}
|
||||
|
||||
private getOrCreate(clientID: ClientID): ClientBucket {
|
||||
const existing = this.buckets.get(clientID);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const bucket = {
|
||||
perSecond: new RateLimiter({
|
||||
tokensPerInterval: INTENTS_PER_SECOND,
|
||||
interval: "second",
|
||||
}),
|
||||
perMinute: new RateLimiter({
|
||||
tokensPerInterval: INTENTS_PER_MINUTE,
|
||||
interval: "minute",
|
||||
}),
|
||||
bytesPerMinute: new RateLimiter({
|
||||
tokensPerInterval: MAX_BYTES_PER_MINUTE,
|
||||
interval: "minute",
|
||||
}),
|
||||
hasSentWinnerMsg: false,
|
||||
};
|
||||
this.buckets.set(clientID, bucket);
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { RateLimiter } from "limiter";
|
||||
import { ClientID } from "../core/Schemas";
|
||||
|
||||
const INTENTS_PER_SECOND = 10;
|
||||
const INTENTS_PER_MINUTE = 150;
|
||||
|
||||
export class IntentRateLimiter {
|
||||
private perSecond = new Map<ClientID, RateLimiter>();
|
||||
private perMinute = new Map<ClientID, RateLimiter>();
|
||||
|
||||
tryConsume(clientID: ClientID): boolean {
|
||||
let second = this.perSecond.get(clientID);
|
||||
if (!second) {
|
||||
second = new RateLimiter({
|
||||
tokensPerInterval: INTENTS_PER_SECOND,
|
||||
interval: "second",
|
||||
});
|
||||
this.perSecond.set(clientID, second);
|
||||
}
|
||||
|
||||
let minute = this.perMinute.get(clientID);
|
||||
if (!minute) {
|
||||
minute = new RateLimiter({
|
||||
tokensPerInterval: INTENTS_PER_MINUTE,
|
||||
interval: "minute",
|
||||
});
|
||||
this.perMinute.set(clientID, minute);
|
||||
}
|
||||
|
||||
return second.tryRemoveTokens(1) && minute.tryRemoveTokens(1);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@ export async function startWorker() {
|
||||
const app = express();
|
||||
app.use(express.json({ limit: "5mb" }));
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 2 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const gm = new GameManager(config, log);
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@ export class WorkerLobbyService {
|
||||
private readonly gm: GameManager,
|
||||
private readonly log: typeof logger,
|
||||
) {
|
||||
this.lobbiesWss = new WebSocketServer({ noServer: true });
|
||||
this.lobbiesWss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 256 * 1024,
|
||||
});
|
||||
this.setupUpgradeHandler();
|
||||
this.setupLobbiesWebSocket();
|
||||
this.setupIPCListener();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClientMsgRateLimiter } from "../../src/server/ClientMsgRateLimiter";
|
||||
|
||||
const CLIENT_A = "clientA" as any;
|
||||
const CLIENT_B = "clientB" as any;
|
||||
|
||||
const SMALL = 100;
|
||||
const LARGE = 501; // over MAX_INTENT_BYTES
|
||||
|
||||
describe("ClientMsgRateLimiter", () => {
|
||||
describe("intent messages", () => {
|
||||
it("allows intents within limits", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks on oversized intent", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "intent", LARGE)).toBe("kick");
|
||||
});
|
||||
|
||||
it("limits when per-second count exceeded", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
}
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("limit");
|
||||
});
|
||||
|
||||
it("rate limits are per client", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
limiter.check(CLIENT_A, "intent", SMALL);
|
||||
}
|
||||
expect(limiter.check(CLIENT_B, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("winner messages", () => {
|
||||
it("allows first winner message", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("ok");
|
||||
});
|
||||
|
||||
it("kicks on second winner message", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
limiter.check(CLIENT_A, "winner", 50000);
|
||||
expect(limiter.check(CLIENT_A, "winner", 50000)).toBe("kick");
|
||||
});
|
||||
|
||||
it("winner does not consume intent rate limit", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
limiter.check(CLIENT_A, "winner", 50000);
|
||||
expect(limiter.check(CLIENT_A, "intent", SMALL)).toBe("ok");
|
||||
});
|
||||
});
|
||||
|
||||
describe("other messages", () => {
|
||||
it("applies rate limiting to other message types", () => {
|
||||
const limiter = new ClientMsgRateLimiter();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("ok");
|
||||
}
|
||||
expect(limiter.check(CLIENT_A, "ping", 50)).toBe("limit");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user