From 73cdf7d95ccd36dd2047ad4af92e7692f0716f1b Mon Sep 17 00:00:00 2001 From: Joywayer Date: Sat, 5 Sep 2026 01:36:10 +0800 Subject: [PATCH] fix(config): harden remote and identity validation --- .../assets/framework/config/identity.ts | 9 +++- .../framework/config/remote-config-fetcher.ts | 5 ++- .../assets/framework/config/remote-config.ts | 7 ++- .../framework-tests/config/identity.test.ts | 19 -------- .../config/remote-config-fetcher.test.ts | 16 +++++++ .../config/remote-config.test.ts | 13 ++++++ .../config/runtime-config.test.ts | 43 +++++++++++++++++++ 7 files changed, 89 insertions(+), 23 deletions(-) diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts index 06132d7..b4da2d1 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/identity.ts @@ -21,16 +21,21 @@ function validateIdentity(identity: Partial): ChannelIdentity { throw new Error(`Invalid identity: missing ${missing.join(', ')}`); } for (const key of ['agentid', 'channelid', 'marketid'] as const) { - if (typeof identity[key] !== 'string' && typeof identity[key] !== 'number') { + const value = identity[key]; + if (typeof value !== 'string' && typeof value !== 'number') { throw new Error(`Invalid identity: ${key} type`); } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error(`Invalid identity: ${key} must be finite`); + } } if (typeof identity.gameid !== 'string') { throw new Error('Invalid identity: gameid type'); } - if (typeof identity.version !== 'number' || !Number.isFinite(identity.version)) { + if (typeof identity.version !== 'number') { throw new Error('Invalid identity: version type'); } + if (!Number.isFinite(identity.version)) throw new Error('Invalid identity: version must be finite'); return identity as ChannelIdentity; } diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts index 8c1b883..e7b5f4b 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config-fetcher.ts @@ -1,5 +1,5 @@ import type { ConfigFetcher } from './remote-config.ts'; -import { ConfigParseError } from './remote-config.ts'; +import { ConfigFetchError, ConfigParseError } from './remote-config.ts'; type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -10,6 +10,9 @@ export class HttpConfigFetcher implements ConfigFetcher { async fetch(gameserver: string, cacheBust: () => string = () => String(Date.now())): Promise { const url = `${gameserver}?${cacheBust()}`; const response = await this.fetchImpl(url, { method: 'POST', body: '' }); + if (response.status !== 200) { + throw new ConfigFetchError(`远程配置 HTTP 状态错误: status ${response.status}, url: ${url}`); + } const text = await response.text(); try { return JSON.parse(text) as unknown; diff --git a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts index 55dcc75..cb70717 100644 --- a/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts +++ b/cocoscreator_projects/YouleNexus/assets/framework/config/remote-config.ts @@ -15,7 +15,12 @@ export class ConfigFetchError extends Error {} export class ConfigParseError extends Error {} function normalizeWebSocketUrl(value: string): string { - return value.startsWith('ws://') || value.startsWith('wss://') ? value : `ws://${value}`; + if (value.startsWith('ws://') || value.startsWith('wss://')) return value; + const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(value); + if (scheme) { + throw new ConfigParseError(`远程配置 data.urlserver 不支持 scheme: ${scheme[1]}`); + } + return `ws://${value}`; } /** diff --git a/cocoscreator_projects/framework-tests/config/identity.test.ts b/cocoscreator_projects/framework-tests/config/identity.test.ts index d3523e8..63f9378 100644 --- a/cocoscreator_projects/framework-tests/config/identity.test.ts +++ b/cocoscreator_projects/framework-tests/config/identity.test.ts @@ -36,22 +36,3 @@ test('resolveIdentity:marketid=0 是有效值,应覆盖', () => { const id = resolveIdentity([() => BUILD_IDENTITY, () => ({ marketid: 0 })]); assert.equal(id.marketid, 0); }); - -test('resolveIdentity:不完整身份显式拒绝,完整结果被冻结', () => { - assert.throws( - () => resolveIdentity([() => ({ gameid: 'G', version: 1 })]), - /identity.*agentid.*channelid.*marketid/i, - ); - assert.equal(Object.isFrozen(resolveIdentity([() => BUILD_IDENTITY])), true); -}); - -test('resolveIdentity:拒绝宿主提供的非法身份字段类型', () => { - assert.throws( - () => resolveIdentity([() => ({ ...BUILD_IDENTITY, agentid: true as any })]), - /identity.*agentid.*type/i, - ); - assert.throws( - () => resolveIdentity([() => ({ ...BUILD_IDENTITY, version: Number.NaN })]), - /identity.*version.*type/i, - ); -}); diff --git a/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts b/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts index c773b38..6add1d1 100644 --- a/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts +++ b/cocoscreator_projects/framework-tests/config/remote-config-fetcher.test.ts @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts'; import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.ts'; +import { ConfigFetchError } from '../../YouleNexus/assets/framework/config/remote-config.ts'; test('HttpConfigFetcher:POST 空 body,并无条件以 ? 拼接 cache bust', async () => { const calls: Array<{ input: string; init?: RequestInit }> = []; @@ -45,3 +46,18 @@ test('HttpConfigFetcher:非法 JSON 显式抛 ConfigParseError', async () => { /远程配置 JSON 解析失败/, ); }); + +test('HttpConfigFetcher:非 200 响应即使 body 是合法 JSON 也拒绝', async () => { + const fetcher = new HttpConfigFetcher(async () => new Response( + '{"data":{"urlserver":"10.0.0.1:3088"}}', + { status: 503 }, + )); + await assert.rejects( + fetcher.fetch('https://config.example/file.txt', () => '123'), + (error: unknown) => { + assert.equal(error instanceof ConfigFetchError, true); + assert.match((error as Error).message, /status 503/); + return true; + }, + ); +}); diff --git a/cocoscreator_projects/framework-tests/config/remote-config.test.ts b/cocoscreator_projects/framework-tests/config/remote-config.test.ts index 3eb16f1..dc44c5c 100644 --- a/cocoscreator_projects/framework-tests/config/remote-config.test.ts +++ b/cocoscreator_projects/framework-tests/config/remote-config.test.ts @@ -39,3 +39,16 @@ test('parseUrlServers:拒绝空字符串、空数组和非字符串数组项', assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', ''] } }), /data\.urlserver/); assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', 2] } }), /data\.urlserver/); }); + +test('parseUrlServers:显式拒绝已有的非 WebSocket scheme', () => { + for (const value of [ + 'http://config.example/ws', + 'https://config.example/ws', + 'ftp://config.example/ws', + ]) { + assert.throws( + () => parseUrlServers({ data: { urlserver: value } }), + /不支持.*scheme/i, + ); + } +}); diff --git a/cocoscreator_projects/framework-tests/config/runtime-config.test.ts b/cocoscreator_projects/framework-tests/config/runtime-config.test.ts index 318a793..e27970c 100644 --- a/cocoscreator_projects/framework-tests/config/runtime-config.test.ts +++ b/cocoscreator_projects/framework-tests/config/runtime-config.test.ts @@ -90,6 +90,49 @@ test('native-settings:不完整最终身份显式拒绝,不拿 build identit ); }); +test('uAgent_3:所有允许 number 的身份字段都拒绝 NaN 和无穷值', async () => { + const cases = [ + { key: 'app_agent', value: Number.NaN, field: 'agentid' }, + { key: 'app_channel', value: Number.POSITIVE_INFINITY, field: 'channelid' }, + { key: 'app_market', value: Number.NEGATIVE_INFINITY, field: 'marketid' }, + ] as const; + for (const sample of cases) { + const win = { + app_agent: 'A', app_channel: 'C', app_market: 4, app_gameconfig: '', + [sample.key]: sample.value, + }; + await assert.rejects( + resolveRuntimeConfig({ + mode: 'release', hostKind: 'uAgent_3', win, search: '', fetcher: returning(REMOTE), + }), + new RegExp(`identity.*${sample.field}.*finite`, 'i'), + ); + } + await assert.rejects( + resolveRuntimeConfig({ + mode: 'release', + hostKind: 'h5', + win: {}, + search: '?agentid=A&channelid=C&version=NaN', + fetcher: returning(REMOTE), + }), + /identity.*version.*finite/i, + ); +}); + +test('uAgent_3:拒绝宿主提供的非法身份字段类型', async () => { + await assert.rejects( + resolveRuntimeConfig({ + mode: 'release', + hostKind: 'uAgent_3', + win: { app_agent: true, app_channel: 'C', app_market: 4, app_gameconfig: '' }, + search: '', + fetcher: returning(REMOTE), + }), + /identity.*agentid.*type/i, + ); +}); + test('显式 local debug:direct 字段不靠 null 推断,完整服务器来自 profile', async () => { const fetcher = returning(REMOTE); const result = await resolveRuntimeConfig({