fix(config): harden remote and identity validation

This commit is contained in:
2026-09-05 01:36:10 +08:00
parent d1ad63983b
commit 73cdf7d95c
7 changed files with 89 additions and 23 deletions
@@ -21,16 +21,21 @@ function validateIdentity(identity: Partial<ChannelIdentity>): ChannelIdentity {
throw new Error(`Invalid identity: missing ${missing.join(', ')}`); throw new Error(`Invalid identity: missing ${missing.join(', ')}`);
} }
for (const key of ['agentid', 'channelid', 'marketid'] as const) { 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`); 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') { if (typeof identity.gameid !== 'string') {
throw new Error('Invalid identity: gameid type'); 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'); 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; return identity as ChannelIdentity;
} }
@@ -1,5 +1,5 @@
import type { ConfigFetcher } from './remote-config.ts'; 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<Response>; type FetchImplementation = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
@@ -10,6 +10,9 @@ export class HttpConfigFetcher implements ConfigFetcher {
async fetch(gameserver: string, cacheBust: () => string = () => String(Date.now())): Promise<unknown> { async fetch(gameserver: string, cacheBust: () => string = () => String(Date.now())): Promise<unknown> {
const url = `${gameserver}?${cacheBust()}`; const url = `${gameserver}?${cacheBust()}`;
const response = await this.fetchImpl(url, { method: 'POST', body: '' }); 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(); const text = await response.text();
try { try {
return JSON.parse(text) as unknown; return JSON.parse(text) as unknown;
@@ -15,7 +15,12 @@ export class ConfigFetchError extends Error {}
export class ConfigParseError extends Error {} export class ConfigParseError extends Error {}
function normalizeWebSocketUrl(value: string): string { 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}`;
} }
/** /**
@@ -36,22 +36,3 @@ test('resolveIdentity:marketid=0 是有效值,应覆盖', () => {
const id = resolveIdentity([() => BUILD_IDENTITY, () => ({ marketid: 0 })]); const id = resolveIdentity([() => BUILD_IDENTITY, () => ({ marketid: 0 })]);
assert.equal(id.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,
);
});
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts'; import { HttpConfigFetcher } from '../../YouleNexus/assets/framework/config/remote-config-fetcher.ts';
import { resolveRuntimeConfig } from '../../YouleNexus/assets/framework/config/runtime-config.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 () => { test('HttpConfigFetcher:POST 空 body,并无条件以 ? 拼接 cache bust', async () => {
const calls: Array<{ input: string; init?: RequestInit }> = []; const calls: Array<{ input: string; init?: RequestInit }> = [];
@@ -45,3 +46,18 @@ test('HttpConfigFetcher:非法 JSON 显式抛 ConfigParseError', async () => {
/远程配置 JSON 解析失败/, /远程配置 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;
},
);
});
@@ -39,3 +39,16 @@ test('parseUrlServers:拒绝空字符串、空数组和非字符串数组项',
assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', ''] } }), /data\.urlserver/); assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', ''] } }), /data\.urlserver/);
assert.throws(() => parseUrlServers({ data: { urlserver: ['ok:1', 2] } }), /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,
);
}
});
@@ -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 () => { test('显式 local debug:direct 字段不靠 null 推断,完整服务器来自 profile', async () => {
const fetcher = returning(REMOTE); const fetcher = returning(REMOTE);
const result = await resolveRuntimeConfig({ const result = await resolveRuntimeConfig({