fix(config): harden remote and identity validation
This commit is contained in:
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user