diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f95eeee..60d9868 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -125,6 +125,7 @@ interface Session { overLimitStrikes: number; roomsCreated: number; lastCatchUpAt: number; + hotseatReports: number; } function underRateLimit(s: Session): boolean { @@ -177,7 +178,7 @@ wss.on("connection", (socket) => { const session: Session = { socket, playerId: null, roomId: null, token: null, claimFails: 0, bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, - roomsCreated: 0, lastCatchUpAt: 0, + roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0, }; sessions.add(session); send(socket, { type: "welcome", game: "wizwar" }); @@ -298,6 +299,8 @@ wss.on("connection", (socket) => { break; } case "hotseatReport": { + // A device finishes a handful of games at most; a firehose is abuse. + if (++session.hotseatReports > 20) return; const id = String(msg.id ?? "").slice(0, 64); const stage = msg.stage === "finished" ? "finished" : msg.stage === "started" ? "started" : null; if (!id || !stage) return send(socket, { type: "error", message: "bad report" }); diff --git a/packages/server/src/stats.ts b/packages/server/src/stats.ts index e285b7d..381fd61 100644 --- a/packages/server/src/stats.ts +++ b/packages/server/src/stats.ts @@ -121,24 +121,37 @@ export interface HotseatReport { winReason?: string; } +/** NaN-proof clamp: anything non-finite becomes the fallback. */ +function clamp(v: unknown, lo: number, hi: number, fallback: number): number { + const x = Math.floor(Number(v)); + return Number.isFinite(x) ? Math.min(hi, Math.max(lo, x)) : fallback; +} + +/** Unauthenticated pings must not grow the dedupe ledger without bound. */ +const MAX_HOTSEAT_ENTRIES = 50_000; + /** Anonymous count-only pings from hotseat tables. Deduped by client id. */ export function recordHotseat(r: HotseatReport): void { if (!loaded) loadStats(); const key = `hs-${r.id}`; + if (!data.roomStages[key] && + Object.keys(data.roomStages).filter((k) => k.startsWith("hs-")).length >= MAX_HOTSEAT_ENTRIES) { + return; // the ledger is full of strangers; stop counting new hotseat tables + } let dirty = false; const begin = () => { data.hotseatGames++; data.gamesCreated++; data.gamesStarted++; - data.fullestTable = Math.max(data.fullestTable, Math.min(6, Math.max(2, r.players ?? 2))); + data.fullestTable = Math.max(data.fullestTable, clamp(r.players, 2, 6, 2)); if (!data.firstGameAt) data.firstGameAt = new Date().toISOString(); data.roomStages[key] = "started"; dirty = true; }; if (!data.roomStages[key] && (r.stage === "started" || r.stage === "finished")) begin(); if (r.stage === "finished" && data.roomStages[key] !== "finished") { - const commands = Math.min(10_000, Math.max(0, Math.floor(r.commands ?? 0))); - const minutes = Math.min(24 * 60, Math.max(0, Math.floor(r.minutes ?? 0))); + const commands = clamp(r.commands, 0, 10_000, 0); + const minutes = clamp(r.minutes, 0, 24 * 60, 0); data.gamesFinished++; data.commandsPlayed += commands; data.minutesAtTable += minutes;