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:
co-authored by
Claude Fable 5
parent
580af58424
commit
f62fcf2510
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user