From fdc6fc2264ef2cfcda5a774e93851e5e91dc1dd8 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 02:16:47 +0800 Subject: [PATCH] fix(protocol): reject invalid request contract values --- .../framework/protocol/contracts/index.ts | 2 + .../protocol/contracts/login-contract.ts | 32 ++++------ .../protocol/contracts/room-contracts.ts | 41 ++++++++---- .../protocol/contracts/validation.ts | 13 ++++ .../protocol/contracts.test.ts | 63 +++++++++++++++++-- 5 files changed, 113 insertions(+), 38 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts index 6c5a685..53264a3 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/index.ts @@ -2,10 +2,12 @@ export { hasOwn, optionalField, requireArray, + requireBoolean, requireInteger, requireNumber, requireRecord, requireString, + requireStringOrFiniteNumber, type JsonRecord, } from './validation.ts'; export { diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts index d17ff78..d6bd71f 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/login-contract.ts @@ -5,10 +5,12 @@ import { import { hasOwn, optionalField, + requireBoolean, requireInteger, requireNumber, requireRecord, requireString, + requireStringOrFiniteNumber, type JsonRecord, } from './validation.ts'; import { @@ -73,18 +75,6 @@ export interface LoginRequestPayload extends Readonly> { readonly machineroom: string; } -function requireStringOrNumber(value: unknown, path: string): string | number { - if (typeof value === 'string') return value; - return requireNumber(value, path); -} - -function requireBoolean(value: unknown, path: string): boolean { - if (typeof value !== 'boolean') { - throw new TypeError(`${path}: expected boolean, received ${value === null ? 'null' : typeof value}`); - } - return value; -} - export function buildLoginRequest( runtimeConfig: FirstSliceRuntimeConfig, account: LoginAccountIdentity, @@ -99,18 +89,18 @@ export function buildLoginRequest( if (location !== null) requireRecord(location, '$.device.location'); const data: Record = { - agentid: requireStringOrNumber(identity.agentid, '$.runtimeConfig.identity.agentid'), - gameid: requireStringOrNumber(identity.gameid, '$.runtimeConfig.identity.gameid'), + agentid: requireStringOrFiniteNumber(identity.agentid, '$.runtimeConfig.identity.agentid'), + gameid: requireStringOrFiniteNumber(identity.gameid, '$.runtimeConfig.identity.gameid'), openid: requireString(accountRecord.openid, '$.account.openid'), nickname: requireString(accountRecord.nickname, '$.account.nickname'), avatar: requireString(accountRecord.avatar, '$.account.avatar'), sex: requireInteger(accountRecord.sex, '$.account.sex'), province: requireString(accountRecord.province, '$.account.province'), city: requireString(accountRecord.city, '$.account.city'), - unionid: requireStringOrNumber(accountRecord.unionid, '$.account.unionid'), + unionid: requireStringOrFiniteNumber(accountRecord.unionid, '$.account.unionid'), version: requireNumber(identity.version, '$.runtimeConfig.identity.version'), - channelid: requireStringOrNumber(identity.channelid, '$.runtimeConfig.identity.channelid'), - marketid: requireStringOrNumber(identity.marketid, '$.runtimeConfig.identity.marketid'), + channelid: requireStringOrFiniteNumber(identity.channelid, '$.runtimeConfig.identity.channelid'), + marketid: requireStringOrFiniteNumber(identity.marketid, '$.runtimeConfig.identity.marketid'), location, machineid: requireString(deviceRecord.machineid, '$.device.machineid'), machineroom: requireString(deviceRecord.machineroom, '$.device.machineroom'), @@ -127,10 +117,12 @@ export function buildLoginRequest( const loginPlayerId = requireRecord(deviceRecord.loginPlayerId, '$.device.loginPlayerId'); if (requireBoolean(loginPlayerId.enabled, '$.device.loginPlayerId.enabled')) { - const cached = loginPlayerId.cachedPlayerId; - if (typeof cached === 'number' && Number.isInteger(cached) && cached > 0) { - data.playerid = cached; + const path = '$.device.loginPlayerId.cachedPlayerId'; + const cachedPlayerId = requireInteger(loginPlayerId.cachedPlayerId, path); + if (cachedPlayerId <= 0) { + throw new RangeError(`${path}: expected positive integer, received number`); } + data.playerid = cachedPlayerId; } return buildFirstSliceEnvelope('player_login', data as LoginRequestPayload); diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts index ebe647b..39d32da 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/room-contracts.ts @@ -4,10 +4,13 @@ import { } from '../first-slice-routes.ts'; import { hasOwn, + optionalField, requireArray, requireInteger, + requireNumber, requireRecord, requireString, + requireStringOrFiniteNumber, type JsonRecord, } from './validation.ts'; import type { FirstSliceRuntimeConfig } from './login-contract.ts'; @@ -63,18 +66,13 @@ function baseRoomAction( ): RoomActionPayload { const identity = requireRecord(runtimeConfig?.identity, '$.runtimeConfig.identity'); const roomRecord = requireRecord(room, '$.room'); - const agentid = identity.agentid; - const gameid = identity.gameid; - if (typeof agentid !== 'string' && typeof agentid !== 'number') { - throw new TypeError(`$.runtimeConfig.identity.agentid: expected string or number, received ${typeof agentid}`); - } - if (typeof gameid !== 'string' && typeof gameid !== 'number') { - throw new TypeError(`$.runtimeConfig.identity.gameid: expected string or number, received ${typeof gameid}`); - } return { - agentid, + agentid: requireStringOrFiniteNumber( + identity.agentid, + '$.runtimeConfig.identity.agentid', + ), playerid: requireInteger(roomRecord.playerid, '$.room.playerid'), - gameid, + gameid: requireStringOrFiniteNumber(identity.gameid, '$.runtimeConfig.identity.gameid'), roomcode: requireString(roomRecord.roomcode, '$.room.roomcode'), }; } @@ -124,8 +122,27 @@ export function buildJoinRoomRequest( location: environmentRecord.location, ip: requireString(environmentRecord.ip, '$.environment.ip'), }; - if (hasOwn(environmentRecord, 'vipMatch')) data.vipMatch = environmentRecord.vipMatch; - if (hasOwn(environmentRecord, 'match_id')) data.match_id = environmentRecord.match_id; + const vipMatch = optionalField( + environmentRecord, + 'vipMatch', + '$.environment', + (value, path): 1 => { + const parsed = requireNumber(value, path); + if (parsed !== 1) { + throw new RangeError(`${path}: expected number 1, received number`); + } + return 1; + }, + ); + if (vipMatch !== undefined) data.vipMatch = vipMatch; + + const matchId = optionalField( + environmentRecord, + 'match_id', + '$.environment', + requireStringOrFiniteNumber, + ); + if (matchId !== undefined) data.match_id = matchId; return buildFirstSliceEnvelope('self_join_room', data as JoinRoomPayload); } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts index 33193bb..699d2b6 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/protocol/contracts/validation.ts @@ -26,6 +26,11 @@ export function requireString(value: unknown, path: string): string { return value; } +export function requireBoolean(value: unknown, path: string): boolean { + if (typeof value !== 'boolean') return invalid(path, 'boolean', value); + return value; +} + export function requireNumber(value: unknown, path: string): number { if (typeof value !== 'number' || !Number.isFinite(value)) { return invalid(path, 'finite number', value); @@ -39,6 +44,14 @@ export function requireInteger(value: unknown, path: string): number { return number; } +export function requireStringOrFiniteNumber(value: unknown, path: string): string | number { + if (typeof value === 'string') return value; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return invalid(path, 'string or finite number', value); + } + return value; +} + export function requireArray(value: unknown, path: string): readonly unknown[] { if (!Array.isArray(value)) return invalid(path, 'array', value); return value; diff --git a/cocoscreator_projects/framework-tests/protocol/contracts.test.ts b/cocoscreator_projects/framework-tests/protocol/contracts.test.ts index 4bcda82..612afeb 100644 --- a/cocoscreator_projects/framework-tests/protocol/contracts.test.ts +++ b/cocoscreator_projects/framework-tests/protocol/contracts.test.ts @@ -97,12 +97,15 @@ test('buildLoginRequest 只在显式复用模式且缓存 ID 有效时加入 pla }); assert.equal(enabled.data.playerid, 430511); - for (const cachedPlayerId of [-1, 1.5, '430511', null]) { - const invalid = buildLoginRequest(runtimeConfig, account, { - ...device, - loginPlayerId: { enabled: true, cachedPlayerId }, - }); - assert.equal(owns(invalid.data, 'playerid'), false); + for (const cachedPlayerId of [-1, 0, 1.5, Number.NaN, Infinity, '430511', null, undefined]) { + const type = cachedPlayerId === null ? 'null' : typeof cachedPlayerId; + assert.throws( + () => buildLoginRequest(runtimeConfig, account, { + ...device, + loginPlayerId: { enabled: true, cachedPlayerId }, + }), + new RegExp(`\\$\\.device\\.loginPlayerId\\.cachedPlayerId.*${type}`), + ); } }); @@ -235,6 +238,20 @@ test('房间请求 builder 使用唯一 route,并仅发协议定义字段', () }); }); +test('房间请求拒绝 agentid/gameid 的非有限数值并报告来源路径', () => { + for (const field of ['agentid', 'gameid'] as const) { + for (const value of [Number.NaN, Infinity, -Infinity]) { + assert.throws( + () => buildPrepareRequest( + { identity: { ...runtimeConfig.identity, [field]: value } }, + { playerid: 430511, roomcode: '100001' }, + ), + new RegExp(`\\$\\.runtimeConfig\\.identity\\.${field}.*number`), + ); + } + } +}); + test('buildJoinRoomRequest 不凭空补 vipMatch 或 match_id', () => { const request = buildJoinRoomRequest( runtimeConfig, @@ -245,6 +262,40 @@ test('buildJoinRoomRequest 不凭空补 vipMatch 或 match_id', () => { assert.equal(owns(request.data, 'match_id'), false); }); +test('buildJoinRoomRequest 拒绝非法或 undefined vipMatch', () => { + for (const vipMatch of [undefined, 0, 2, '1', Number.NaN, Infinity]) { + const type = typeof vipMatch; + assert.throws( + () => buildJoinRoomRequest( + runtimeConfig, + { playerid: 430511, roomcode: '100001' }, + { location: null, ip: '127.0.0.1', vipMatch } as never, + ), + new RegExp(`\\$\\.environment\\.vipMatch.*${type}`), + ); + } +}); + +test('buildJoinRoomRequest 拒绝非法、undefined 或非有限 match_id', () => { + for (const match_id of [undefined, null, false, {}, Number.NaN, Infinity]) { + const type = match_id === null ? 'null' : Array.isArray(match_id) ? 'array' : typeof match_id; + assert.throws( + () => buildJoinRoomRequest( + runtimeConfig, + { playerid: 430511, roomcode: '100001' }, + { location: null, ip: '127.0.0.1', match_id } as never, + ), + new RegExp(`\\$\\.environment\\.match_id.*${type}`), + ); + } + + assert.equal(buildJoinRoomRequest( + runtimeConfig, + { playerid: 430511, roomcode: '100001' }, + { location: null, ip: '127.0.0.1', match_id: 7 }, + ).data.match_id, 7); +}); + test('座位事件 parser 校验实际消费字段并保留 raw', () => { const prepare = { seat: 1, deskwar: 1, extension: 'kept' }; assert.equal(parsePlayerPreparePayload(prepare).raw, prepare);