Harden seat transfer phrases (security review finding)
Three words from 32 was ~15 bits — enumerable within a code's lifetime against an unthrottled endpoint. Now: 64-word list, 4-word phrases (~24 bits / 16.7M combinations), a per-connection cap of 5 failed claims, and a global backstop that voids all pending codes and cools the endpoint after 30 failures in 10 minutes. Codes stay speakable; a brute force now gets ~35 guesses against 16.7M before the door closes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2184721ab4
commit
ce8b6a315b
@@ -36,6 +36,7 @@ interface Session {
|
|||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
playerId: PlayerId | null;
|
playerId: PlayerId | null;
|
||||||
roomId: string | null;
|
roomId: string | null;
|
||||||
|
claimFails: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions = new Set<Session>();
|
const sessions = new Set<Session>();
|
||||||
@@ -70,7 +71,7 @@ function broadcastRoomState(room: Room): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
wss.on("connection", (socket) => {
|
||||||
const session: Session = { socket, playerId: null, roomId: null };
|
const session: Session = { socket, playerId: null, roomId: null, claimFails: 0 };
|
||||||
sessions.add(session);
|
sessions.add(session);
|
||||||
send(socket, { type: "welcome", game: "wizwar" });
|
send(socket, { type: "welcome", game: "wizwar" });
|
||||||
|
|
||||||
@@ -142,8 +143,15 @@ wss.on("connection", (socket) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "claimTransfer": {
|
case "claimTransfer": {
|
||||||
|
if (session.claimFails >= 5) {
|
||||||
|
return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
|
||||||
|
}
|
||||||
const result = claimTransferCode(String(msg.code ?? ""));
|
const result = claimTransferCode(String(msg.code ?? ""));
|
||||||
if ("error" in result) return send(socket, { type: "error", message: result.error });
|
if ("error" in result) {
|
||||||
|
session.claimFails++;
|
||||||
|
return send(socket, { type: "error", message: result.error });
|
||||||
|
}
|
||||||
|
session.claimFails = 0;
|
||||||
send(socket, { type: "transferClaimed", seat: result });
|
send(socket, { type: "transferClaimed", seat: result });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,11 @@ const TRANSFER_WORDS = [
|
|||||||
"moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable",
|
"moss", "torch", "skull", "frost", "amber", "wisp", "cellar", "gable",
|
||||||
"onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror",
|
"onyx", "briar", "tome", "cinder", "gloom", "spiral", "hex", "mirror",
|
||||||
"portal", "quill", "shade", "tusk", "vault", "wyrm", "zephyr", "idol",
|
"portal", "quill", "shade", "tusk", "vault", "wyrm", "zephyr", "idol",
|
||||||
] as const;
|
"anvil", "bramble", "crypt", "dusk", "fable", "grotto", "hollow", "ivory",
|
||||||
|
"jinx", "keep", "lantern", "marsh", "nettle", "oath", "plume", "quartz",
|
||||||
|
"relic", "sconce", "talon", "umber", "vellum", "warden", "yarrow", "zeal",
|
||||||
|
"bastion", "chalice", "drake", "eaves", "fen", "gargoyle", "harrow", "imp",
|
||||||
|
] as const; // 64 words; 4-word phrases = 64^4 = ~16.7M combinations (~24 bits)
|
||||||
|
|
||||||
interface PendingTransfer {
|
interface PendingTransfer {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -212,17 +216,36 @@ export function makeTransferCode(room: Room, playerId: PlayerId): { code: string
|
|||||||
}
|
}
|
||||||
let code: string;
|
let code: string;
|
||||||
do {
|
do {
|
||||||
code = Array.from({ length: 3 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
|
code = Array.from({ length: 4 }, () => TRANSFER_WORDS[randomInt(TRANSFER_WORDS.length)]).join("-");
|
||||||
} while (transfers.has(code));
|
} while (transfers.has(code));
|
||||||
const expiresAt = now + TRANSFER_TTL_MS;
|
const expiresAt = now + TRANSFER_TTL_MS;
|
||||||
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
|
transfers.set(code, { roomId: room.id, name: playerId, token, expiresAt });
|
||||||
return { code, expiresAt };
|
return { code, expiresAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Brute-force backstop: too many failed claims globally voids every pending
|
||||||
|
// code (they cost nothing to re-mint) and cools the endpoint off.
|
||||||
|
let failedClaims = 0;
|
||||||
|
let failWindowStart = 0;
|
||||||
|
const FAIL_WINDOW_MS = 10 * 60 * 1000;
|
||||||
|
const FAIL_LIMIT = 30;
|
||||||
|
|
||||||
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
|
export function claimTransferCode(code: string): { roomId: string; name: PlayerId; token: string } | { error: string } {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - failWindowStart > FAIL_WINDOW_MS) {
|
||||||
|
failWindowStart = now;
|
||||||
|
failedClaims = 0;
|
||||||
|
}
|
||||||
|
if (failedClaims >= FAIL_LIMIT) {
|
||||||
|
return { error: "too many failed claims — transfers are cooling off, mint a fresh phrase" };
|
||||||
|
}
|
||||||
const normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
|
const normalized = code.trim().toLowerCase().replace(/\s+/g, "-");
|
||||||
const t = transfers.get(normalized);
|
const t = transfers.get(normalized);
|
||||||
if (!t || t.expiresAt < Date.now()) return { error: "that transfer phrase is unknown or has expired" };
|
if (!t || t.expiresAt < now) {
|
||||||
|
failedClaims++;
|
||||||
|
if (failedClaims >= FAIL_LIMIT) transfers.clear();
|
||||||
|
return { error: "that transfer phrase is unknown or has expired" };
|
||||||
|
}
|
||||||
transfers.delete(normalized); // one-time
|
transfers.delete(normalized); // one-time
|
||||||
const room = rooms.get(t.roomId);
|
const room = rooms.get(t.roomId);
|
||||||
if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" };
|
if (!room || room.tokens.get(t.name) !== t.token) return { error: "that seat no longer exists" };
|
||||||
|
|||||||
Reference in New Issue
Block a user