Harden the hotseat tally endpoint

Security review of the accumulator commit found three holes, all in
the unauthenticated hotseat ping. Worst: Number(undefined) is NaN,
and NaN survives Math.min/max — one malformed report would have
poisoned commandsPlayed and friends permanently (NaN serializes to
null). All numeric fields now pass through a NaN-proof clamp with a
fallback. The dedupe ledger caps at 50k hotseat entries so spammed
random ids cannot grow stats.json without bound, and each connection
may deliver at most 20 reports — a real device finishes a handful of
games; a firehose is abuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Eric Wagoner
2026-08-16 11:41:20 -04:00
co-authored by Claude Fable 5
parent 580af58424
commit f62fcf2510
2 changed files with 20 additions and 4 deletions
+4 -1
View File
@@ -125,6 +125,7 @@ interface Session {
overLimitStrikes: number; overLimitStrikes: number;
roomsCreated: number; roomsCreated: number;
lastCatchUpAt: number; lastCatchUpAt: number;
hotseatReports: number;
} }
function underRateLimit(s: Session): boolean { function underRateLimit(s: Session): boolean {
@@ -177,7 +178,7 @@ wss.on("connection", (socket) => {
const session: Session = { const session: Session = {
socket, playerId: null, roomId: null, token: null, claimFails: 0, socket, playerId: null, roomId: null, token: null, claimFails: 0,
bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0, bucket: 30, lastRefill: Date.now(), overLimitStrikes: 0,
roomsCreated: 0, lastCatchUpAt: 0, roomsCreated: 0, lastCatchUpAt: 0, hotseatReports: 0,
}; };
sessions.add(session); sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" }); send(socket, { type: "welcome", game: "wizwar" });
@@ -298,6 +299,8 @@ wss.on("connection", (socket) => {
break; break;
} }
case "hotseatReport": { 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 id = String(msg.id ?? "").slice(0, 64);
const stage = msg.stage === "finished" ? "finished" : msg.stage === "started" ? "started" : null; const stage = msg.stage === "finished" ? "finished" : msg.stage === "started" ? "started" : null;
if (!id || !stage) return send(socket, { type: "error", message: "bad report" }); if (!id || !stage) return send(socket, { type: "error", message: "bad report" });
+16 -3
View File
@@ -121,24 +121,37 @@ export interface HotseatReport {
winReason?: string; 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. */ /** Anonymous count-only pings from hotseat tables. Deduped by client id. */
export function recordHotseat(r: HotseatReport): void { export function recordHotseat(r: HotseatReport): void {
if (!loaded) loadStats(); if (!loaded) loadStats();
const key = `hs-${r.id}`; 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; let dirty = false;
const begin = () => { const begin = () => {
data.hotseatGames++; data.hotseatGames++;
data.gamesCreated++; data.gamesCreated++;
data.gamesStarted++; 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(); if (!data.firstGameAt) data.firstGameAt = new Date().toISOString();
data.roomStages[key] = "started"; data.roomStages[key] = "started";
dirty = true; dirty = true;
}; };
if (!data.roomStages[key] && (r.stage === "started" || r.stage === "finished")) begin(); if (!data.roomStages[key] && (r.stage === "started" || r.stage === "finished")) begin();
if (r.stage === "finished" && data.roomStages[key] !== "finished") { if (r.stage === "finished" && data.roomStages[key] !== "finished") {
const commands = Math.min(10_000, Math.max(0, Math.floor(r.commands ?? 0))); const commands = clamp(r.commands, 0, 10_000, 0);
const minutes = Math.min(24 * 60, Math.max(0, Math.floor(r.minutes ?? 0))); const minutes = clamp(r.minutes, 0, 24 * 60, 0);
data.gamesFinished++; data.gamesFinished++;
data.commandsPlayed += commands; data.commandsPlayed += commands;
data.minutesAtTable += minutes; data.minutesAtTable += minutes;