fix(platform): enforce atomic store input validation

This commit is contained in:
2026-09-05 02:57:07 +08:00
parent 7acd3ed96e
commit 4ffa59bf8e
3 changed files with 198 additions and 19 deletions
@@ -3,6 +3,7 @@ import {
hasOwn,
requireArray,
requireInteger,
requireNumber,
requireRecord,
requireString,
type ParsedLoginResponse,
@@ -21,6 +22,8 @@ interface RoomBuildResult {
readonly room: Extract<PlatformState['room'], { readonly kind: 'inside' }>;
}
const LOGIN_ROOM_FIELDS = ['roomcode', 'seat', 'roomtype', 'players'] as const;
function requirePositiveInteger(value: unknown, path: string): number {
const parsed = requireInteger(value, path);
if (parsed <= 0) throw new RangeError(`${path}: expected positive integer`);
@@ -55,6 +58,8 @@ function parseRoomPlayer(value: unknown, path: string): {
playerid: requirePositiveInteger(raw.playerid, `${path}.playerid`),
nickname: requireString(raw.nickname, `${path}.nickname`),
avatar: requireString(raw.avatar, `${path}.avatar`),
sex: requireInteger(raw.sex, `${path}.sex`),
bean: requireNumber(raw.bean, `${path}.bean`),
...(hasOwn(raw, 'ip') ? { ip: requireString(raw.ip, `${path}.ip`) } : {}),
isprepare,
onstate,
@@ -62,12 +67,67 @@ function parseRoomPlayer(value: unknown, path: string): {
return { player, isprepare, onstate };
}
function recursivelyFreeze<T>(value: T, seen = new WeakSet<object>()): T {
if (typeof value !== 'object' || value === null) return value;
if (seen.has(value)) return value;
seen.add(value);
for (const child of Object.values(value)) recursivelyFreeze(child, seen);
return Object.freeze(value);
function collectFreezableGraph(value: unknown): object[] {
const graph: object[] = [];
const seen = new WeakSet<object>();
const visit = (current: unknown): void => {
if (typeof current !== 'object' || current === null || seen.has(current)) return;
seen.add(current);
graph.push(current);
const prototype = Object.getPrototypeOf(current);
if (!Array.isArray(current) && prototype !== Object.prototype && prototype !== null) {
throw new TypeError('roomtype: expected JSON array or plain object container');
}
Object.isExtensible(current);
for (const key of Reflect.ownKeys(current)) {
const descriptor = Reflect.getOwnPropertyDescriptor(current, key);
if (descriptor === undefined) {
throw new TypeError('roomtype: unstable property structure');
}
visit(Reflect.get(current, key));
}
};
visit(value);
return graph;
}
function recursivelyFreeze<T>(value: T): T {
const graph = collectFreezableGraph(value);
for (let index = graph.length - 1; index >= 0; index -= 1) {
Object.freeze(graph[index]);
}
return value;
}
function assertSelfProfileMatches(
loginPlayer: PlatformPlayer,
roomPlayer: PlatformPlayer,
path: string,
): void {
for (const field of ['playerid', 'nickname', 'avatar', 'sex', 'bean'] as const) {
if (roomPlayer[field] !== loginPlayer[field]) {
throw new RangeError(`${path}.${field}: conflicts with authoritative login profile`);
}
}
if (roomPlayer.ip !== undefined && roomPlayer.ip !== loginPlayer.ip) {
throw new RangeError(`${path}.ip: conflicts with authoritative login profile`);
}
}
function requireMatchingState(
parsedState: unknown,
raw: Readonly<Record<string, unknown>>,
path: string,
): number {
const state = requireInteger(parsedState, `${path}.state`);
const rawState = requireInteger(raw.state, `${path}.raw.state`);
if (state !== rawState) {
throw new RangeError(`${path}.state: expected parsed state to equal raw.state`);
}
return state;
}
function buildRoom(
@@ -129,15 +189,21 @@ function buildRoom(
if (parsed.onstate === 1) offlineSeats.push(seat);
}
if (seatPlayerIds[selfSeat] !== selfPlayer.playerid) {
throw new RangeError('$.room.players: self seat must contain the authenticated player');
const selfRoomPlayer = parsedPlayers.get(selfPlayer.playerid);
if (seatPlayerIds[selfSeat] !== selfPlayer.playerid || selfRoomPlayer === undefined) {
throw new RangeError(`$.room.players[${selfSeat}].playerid: conflicts with authenticated player`);
}
assertSelfProfileMatches(selfPlayer, selfRoomPlayer, `$.room.players[${selfSeat}]`);
recursivelyFreeze(roomtype);
const entities: Record<number, PlatformPlayer> = {};
for (const [playerid, parsedPlayer] of parsedPlayers) {
const player = playerid === selfPlayer.playerid
? { ...selfPlayer, ...parsedPlayer }
? {
...selfPlayer,
isprepare: parsedPlayer.isprepare,
onstate: parsedPlayer.onstate,
}
: parsedPlayer;
entities[playerid] = Object.freeze(player);
}
@@ -217,7 +283,12 @@ export class PlatformStore {
applyLoginSuccess(parsed: ParsedLoginResponse): void {
const input = requireRecord(parsed, '$.login');
const state = requireInteger(input.state, '$.login.state');
const raw = requireRecord(input.raw, '$.login.raw');
const state = requireMatchingState(input.state, raw, '$.login');
const rawHasRoom = LOGIN_ROOM_FIELDS.some((field) => hasOwn(raw, field));
if (input.room === undefined || (input.room !== null) !== rawHasRoom) {
throw new TypeError('$.login.room: parsed room presence conflicts with raw room fields');
}
if (state !== 0) {
throw new RangeError('$.login.state: expected successful state 0');
}
@@ -225,7 +296,6 @@ export class PlatformStore {
if (playerid <= 0) {
throw new RangeError('$.login.playerid: expected positive integer');
}
const raw = requireRecord(input.raw, '$.login.raw');
if (requireInteger(raw.playerid, '$.login.raw.playerid') !== playerid) {
throw new RangeError('$.login.raw.playerid: expected the parsed playerid');
}
@@ -233,6 +303,8 @@ export class PlatformStore {
playerid,
nickname: requireString(raw.nickname, '$.login.raw.nickname'),
avatar: requireString(raw.avatar, '$.login.raw.avatar'),
sex: requireInteger(raw.sex, '$.login.raw.sex'),
bean: requireNumber(raw.bean, '$.login.raw.bean'),
ip: requireString(raw.ip, '$.login.raw.ip'),
});
const roomResult = input.room === null
@@ -253,10 +325,15 @@ export class PlatformStore {
replaceRoom(parsed: ParsedRoomResponse): void {
const input = requireRecord(parsed, '$.response');
if (requireInteger(input.state, '$.response.state') !== 0) {
const raw = requireRecord(input.raw, '$.response.raw');
const state = requireMatchingState(input.state, raw, '$.response');
if (input.room === undefined || (input.room !== null) !== (state === 0)) {
throw new TypeError('$.response.room: parsed room presence conflicts with raw state');
}
if (state !== 0) {
throw new RangeError('$.response.state: expected successful state 0');
}
if (input.room === null || input.room === undefined) {
if (input.room === null) {
throw new TypeError('$.response.room: expected parsed room snapshot');
}
const previous = this.getState();
@@ -268,7 +345,7 @@ export class PlatformStore {
if (selfPlayer === undefined) {
throw new Error('players.entities: missing authenticated player');
}
const roomResult = buildRoom(input.room, input.raw, selfPlayer);
const roomResult = buildRoom(input.room, raw, selfPlayer);
this.stateCell.value = Object.freeze({
app: previous.app,
players: roomResult.players,
@@ -13,6 +13,8 @@ export interface PlatformPlayer {
readonly playerid: number;
readonly nickname: string;
readonly avatar: string;
readonly sex: number;
readonly bean: number;
readonly ip?: string;
readonly isprepare?: number;
readonly onstate?: number;