import type { ClientMessage, HostMessage, SignalMessage, SignalPayload } from "../types/net.js"; import { MAX_PLAYERS, PROTOCOL_VERSION } from "../types/net.js"; import { fail, isFiniteNumber, isRecord, isStringArray, ok, type ValidationResult } from "./common.js"; /** * Peer message validation. * * Guests are untrusted. Every `ClientMessage` the host acts on passes through * here first, and the validator *sanitises* rather than merely accepting: * movement is clamped to the unit square so a peer cannot claim a 1e9 input * vector and teleport, and identifiers are length-capped so a peer cannot * exhaust host memory with a single frame. */ /** Nothing legitimate approaches these; they exist to bound hostile input. */ const MAX_ID_LENGTH = 128; const MAX_CHAT_LENGTH = 280; const clampUnit = (value: number): number => (value < -1 ? -1 : value > 1 ? 1 : value); const isSaneId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH; export function validateClientMessage(input: unknown): ValidationResult { if (!isRecord(input)) return fail("message must be an object"); switch (input["t"]) { case "input": { const move = input["move"]; if (!isRecord(move) || !isFiniteNumber(move["x"]) || !isFiniteNumber(move["z"])) { return fail("input.move must be {x, z} finite numbers"); } if (!isFiniteNumber(input["tick"]) || input["tick"] < 0) { return fail("input.tick must be a non-negative number"); } if (!isFiniteNumber(input["yaw"])) return fail("input.yaw must be finite"); return ok({ t: "input", tick: Math.floor(input["tick"]), move: { x: clampUnit(move["x"]), z: clampUnit(move["z"]) }, run: input["run"] === true, yaw: input["yaw"], }); } case "interact": if (!isSaneId(input["interactableId"])) return fail("interact.interactableId invalid"); return ok({ t: "interact", interactableId: input["interactableId"] }); case "combine": if (!isSaneId(input["itemA"]) || !isSaneId(input["itemB"])) { return fail("combine requires two item ids"); } return ok({ t: "combine", itemA: input["itemA"], itemB: input["itemB"] }); case "chat": { const text = input["text"]; if (typeof text !== "string") return fail("chat.text must be a string"); return ok({ t: "chat", text: text.slice(0, MAX_CHAT_LENGTH) }); } default: return fail(`unknown client message type: ${String(input["t"])}`); } } /** * Guests validate host messages too. The host is authoritative over *game* * state, not over the guest's memory safety, and a compromised host should not * be able to crash the room. */ export function validateHostMessage(input: unknown): ValidationResult { if (!isRecord(input)) return fail("message must be an object"); switch (input["t"]) { case "snapshot": { if (!isFiniteNumber(input["tick"])) return fail("snapshot.tick must be finite"); if (!Array.isArray(input["players"])) return fail("snapshot.players must be an array"); if (input["players"].length > MAX_PLAYERS) return fail("snapshot.players exceeds MAX_PLAYERS"); for (const player of input["players"]) { if (!isRecord(player) || !isFiniteNumber(player["peerId"])) { return fail("snapshot.players entry missing peerId"); } const position = player["p"]; if ( !isRecord(position) || !isFiniteNumber(position["x"]) || !isFiniteNumber(position["y"]) || !isFiniteNumber(position["z"]) ) { return fail("snapshot.players entry has an invalid position"); } if (!isFiniteNumber(player["yaw"])) return fail("snapshot player yaw must be finite"); } return ok(input as unknown as HostMessage); } case "flags": if (!isStringArray(input["set"]) || !isStringArray(input["unset"])) { return fail("flags.set and flags.unset must be string arrays"); } return ok(input as unknown as HostMessage); case "despawn": if (!isStringArray(input["interactableIds"])) { return fail("despawn.interactableIds must be a string array"); } return ok(input as unknown as HostMessage); case "inventory": if (!isFiniteNumber(input["peerId"]) || !Array.isArray(input["items"])) { return fail("inventory requires peerId and items[]"); } return ok(input as unknown as HostMessage); case "notice": if (typeof input["text"] !== "string") return fail("notice.text must be a string"); return ok(input as unknown as HostMessage); case "chat": if (!isFiniteNumber(input["from"]) || typeof input["text"] !== "string") { return fail("chat requires from and text"); } return ok({ t: "chat", from: input["from"], text: input["text"].slice(0, MAX_CHAT_LENGTH) }); case "sync": if (typeof input["roomId"] !== "string") return fail("sync.roomId must be a string"); if (!isStringArray(input["flags"]) || !isStringArray(input["despawned"])) { return fail("sync.flags and sync.despawned must be string arrays"); } return ok(input as unknown as HostMessage); default: return fail(`unknown host message type: ${String(input["t"])}`); } } export function validateSignalMessage(input: unknown): ValidationResult { if (!isRecord(input)) return fail("message must be an object"); switch (input["t"]) { case "hello": if (typeof input["token"] !== "string") return fail("hello.token must be a string"); if (!isSaneId(input["lobbyCode"])) return fail("hello.lobbyCode invalid"); if (input["protocol"] !== PROTOCOL_VERSION) { return fail( `protocol mismatch: peer speaks ${String(input["protocol"])}, server speaks ${PROTOCOL_VERSION}`, ); } return ok({ t: "hello", token: input["token"], lobbyCode: input["lobbyCode"], protocol: PROTOCOL_VERSION, }); case "signal": { if (!isFiniteNumber(input["to"])) return fail("signal.to must be a peer id"); if (!isRecord(input["payload"])) return fail("signal.payload must be an object"); // `from` is assigned by the server from the authenticated socket, never // taken from the sender — otherwise a peer could impersonate another. return ok({ t: "signal", from: -1, to: input["to"], payload: input["payload"] as SignalPayload, }); } case "ready": return ok({ t: "ready", isReady: input["isReady"] === true }); default: return fail(`unknown signal message type: ${String(input["t"])}`); } }