增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
336 lines
11 KiB
JavaScript
336 lines
11 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import { readFileSync } from 'node:fs';
|
||
import { after, before, test } from 'node:test';
|
||
import { createServer } from 'vite';
|
||
|
||
let server;
|
||
let inject;
|
||
let bridgeModule;
|
||
let originalFetch;
|
||
const stagingEnv = readFileSync(new URL('../.env.staging', import.meta.url), 'utf8');
|
||
const trustedApiBase = stagingEnv.match(/^VITE_API_BASE=(.+)$/m)?.[1];
|
||
|
||
test('iframe 只允许脚本,不能同时授予同源权限', () => {
|
||
const playerSource = readFileSync(new URL('../src/host/GamePlayer.vue', import.meta.url), 'utf8');
|
||
assert.match(playerSource, /sandbox="allow-scripts"/);
|
||
assert.doesNotMatch(playerSource, /sandbox="[^"]*allow-same-origin/);
|
||
});
|
||
|
||
before(async () => {
|
||
originalFetch = globalThis.fetch;
|
||
server = await createServer({
|
||
root: new URL('..', import.meta.url).pathname,
|
||
mode: 'staging',
|
||
appType: 'custom',
|
||
logLevel: 'silent',
|
||
server: { middlewareMode: true },
|
||
});
|
||
inject = await server.ssrLoadModule('/src/host/inject.ts');
|
||
bridgeModule = await server.ssrLoadModule('/src/host/bridge.ts');
|
||
});
|
||
|
||
after(async () => {
|
||
globalThis.fetch = originalFetch;
|
||
await server?.close();
|
||
});
|
||
|
||
/**
|
||
* 用真实 HostBridge 驱动一条目标 iframe 消息,并收集分发与拒绝结果。
|
||
* 测试替身只承担浏览器事件入口,信封校验始终执行生产代码。
|
||
*/
|
||
function dispatchBridgeMessage(envelope) {
|
||
const originalWindow = globalThis.window;
|
||
const listeners = new Set();
|
||
const fakeWindow = {
|
||
location: { origin: 'http://host.test' },
|
||
addEventListener(type, handler) {
|
||
if (type === 'message') listeners.add(handler);
|
||
},
|
||
removeEventListener(type, handler) {
|
||
if (type === 'message') listeners.delete(handler);
|
||
},
|
||
};
|
||
const targetWindow = { postMessage() {} };
|
||
const accepted = [];
|
||
const rejected = [];
|
||
globalThis.window = fakeWindow;
|
||
const bridge = new bridgeModule.HostBridge({
|
||
targetWindow,
|
||
allowedOrigins: ['null'],
|
||
onMessage: (message) => accepted.push(message),
|
||
onReject: (reason) => rejected.push(reason),
|
||
});
|
||
try {
|
||
const handler = Array.from(listeners)[0];
|
||
handler({ origin: 'null', source: targetWindow, data: envelope });
|
||
return { accepted, rejected };
|
||
} finally {
|
||
bridge.dispose();
|
||
globalThis.window = originalWindow;
|
||
}
|
||
}
|
||
|
||
test('opaque iframe 消息只接受目标 contentWindow,拒绝其它 null-origin frame', () => {
|
||
const originalWindow = globalThis.window;
|
||
const listeners = new Set();
|
||
const fakeWindow = {
|
||
location: { origin: 'http://host.test' },
|
||
addEventListener(type, handler) {
|
||
if (type === 'message') listeners.add(handler);
|
||
},
|
||
removeEventListener(type, handler) {
|
||
if (type === 'message') listeners.delete(handler);
|
||
},
|
||
};
|
||
const targetWindow = { postMessage() {} };
|
||
const attackerWindow = { postMessage() {} };
|
||
const accepted = [];
|
||
const rejected = [];
|
||
globalThis.window = fakeWindow;
|
||
const bridge = new bridgeModule.HostBridge({
|
||
targetWindow,
|
||
allowedOrigins: ['null'],
|
||
onMessage: (message) => accepted.push(message),
|
||
onReject: (reason) => rejected.push(reason),
|
||
});
|
||
const envelope = {
|
||
channel: 'wanxiang-game-sdk', type: 'lifecycle', direction: 'game_to_host',
|
||
traceId: 'trace-1', payload: { event: 'game_loaded' },
|
||
};
|
||
try {
|
||
const handler = Array.from(listeners)[0];
|
||
handler({ origin: 'null', source: attackerWindow, data: envelope });
|
||
assert.deepEqual(accepted, []);
|
||
assert.deepEqual(rejected, ['source_not_target']);
|
||
|
||
handler({ origin: 'null', source: targetWindow, data: envelope });
|
||
assert.equal(accepted.length, 1);
|
||
} finally {
|
||
bridge.dispose();
|
||
globalThis.window = originalWindow;
|
||
}
|
||
});
|
||
|
||
test('宿主入站拒绝伪造的 host_to_game 方向', () => {
|
||
const result = dispatchBridgeMessage({
|
||
channel: 'wanxiang-game-sdk', type: 'lifecycle', direction: 'host_to_game',
|
||
traceId: 'trace-1', payload: { event: 'game_loaded' },
|
||
});
|
||
|
||
assert.deepEqual(result.accepted, []);
|
||
assert.deepEqual(result.rejected, ['bad_direction']);
|
||
});
|
||
|
||
test('宿主入站按消息类型拒绝畸形 payload', () => {
|
||
const invalidEnvelopes = [
|
||
{ type: 'lifecycle', payload: null },
|
||
{ type: 'lifecycle', payload: [] },
|
||
{ type: 'lifecycle', payload: { event: 'unknown_event' } },
|
||
{ type: 'telemetry', payload: { props: {} } },
|
||
{ type: 'error', payload: { message: 7 } },
|
||
{ type: 'ad', requestId: 'ad-1', payload: { slotId: 'slot-1', adType: 'video' } },
|
||
{ type: 'ad', payload: { slotId: 'slot-1', adType: 'rewarded' } },
|
||
{ type: 'pay', requestId: 'pay-1', payload: { orderId: 'order-1', amount: Number.NaN } },
|
||
{ type: 'pay', payload: { orderId: 'order-1', amount: 1 } },
|
||
{ type: 'storage', payload: { key: 7, value: '{}' } },
|
||
{ type: 'storage', payload: { key: 'save:g:v', value: {} } },
|
||
{ type: 'init', payload: { gameId: 'g', versionId: 'v', traceId: 'trace-1' } },
|
||
];
|
||
|
||
for (const candidate of invalidEnvelopes) {
|
||
const result = dispatchBridgeMessage({
|
||
channel: 'wanxiang-game-sdk', direction: 'game_to_host', traceId: 'trace-1',
|
||
...candidate,
|
||
});
|
||
assert.deepEqual(result.accepted, [], `畸形 ${candidate.type} 不应进入 GamePlayer`);
|
||
assert.deepEqual(result.rejected, ['bad_payload'], `畸形 ${candidate.type} 应由 schema 闸拒绝`);
|
||
}
|
||
});
|
||
|
||
test('宿主入站保留合法 lifecycle、telemetry、error、ad、pay 与 storage 消息', () => {
|
||
const validEnvelopes = [
|
||
{ type: 'lifecycle', payload: { event: 'game_end', data: { score: 10 } } },
|
||
{ type: 'telemetry', payload: { event: 'score_changed', props: { score: 10 } } },
|
||
{ type: 'error', payload: { message: 'boom', stack: 'line:1' } },
|
||
{ type: 'ad', requestId: 'ad-1', payload: { slotId: 'slot-1', adType: 'rewarded' } },
|
||
{ type: 'pay', requestId: 'pay-1', payload: { orderId: 'order-1', amount: 1 } },
|
||
{ type: 'storage', payload: { key: 'save:g:v', value: '{}' } },
|
||
{ type: 'storage', requestId: 'storage-1', payload: { key: 'save:g:v' } },
|
||
];
|
||
|
||
for (const candidate of validEnvelopes) {
|
||
const result = dispatchBridgeMessage({
|
||
channel: 'wanxiang-game-sdk', direction: 'game_to_host', traceId: 'trace-1',
|
||
...candidate,
|
||
});
|
||
assert.equal(result.accepted.length, 1, `合法 ${candidate.type} 应进入 GamePlayer`);
|
||
assert.deepEqual(result.rejected, []);
|
||
}
|
||
});
|
||
|
||
test('iframe CSP 只允许内联素材 URL,拒绝任意 HTTP 图片出站', () => {
|
||
const srcdoc = inject.buildIframeSrcdoc({
|
||
schemaVersion: '1.0', gameId: 'g', versionId: 'v', templateId: 't',
|
||
gameConfig: {}, assets: [],
|
||
manifest: { runtimeVersion: '1.0.0', entry: 'index.js', preloadPolicy: 'eager', bundleSize: 0, checksum: '0'.repeat(64) },
|
||
meta: { title: 'test', cover: '', ageRating: 'all' },
|
||
}, 'trace-1', 'g', 'v');
|
||
const csp = srcdoc.match(/Content-Security-Policy"\s+content="([^"]+)"/)?.[1] ?? '';
|
||
|
||
assert.match(csp, /img-src data: blob:/);
|
||
assert.doesNotMatch(csp, /img-src[^;]*https?:/);
|
||
});
|
||
|
||
test('manifest 拒绝任意绝对 URL,且不会向该 origin 发送平台头', async () => {
|
||
let calls = 0;
|
||
globalThis.fetch = async () => {
|
||
calls += 1;
|
||
return new Response(JSON.stringify({ manifest: {}, assets: [] }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
};
|
||
|
||
await assert.rejects(
|
||
inject.fetchAndVerifyManifest('https://attacker.invalid/app-api/runtime/package/2048/manifest'),
|
||
/不受信任的 Runtime API 地址/,
|
||
);
|
||
assert.equal(calls, 0);
|
||
});
|
||
|
||
test('manifest 拒绝受信 origin 之外的路径', async () => {
|
||
let calls = 0;
|
||
globalThis.fetch = async () => {
|
||
calls += 1;
|
||
return new Response('{}', { status: 200 });
|
||
};
|
||
|
||
await assert.rejects(
|
||
inject.fetchAndVerifyManifest('/app-api/system/user/profile'),
|
||
/不受信任的 Runtime API 地址/,
|
||
);
|
||
assert.equal(calls, 0);
|
||
});
|
||
|
||
test('素材代理拒绝任意绝对 URL 模板,且不会发送平台头', async () => {
|
||
let calls = 0;
|
||
globalThis.fetch = async () => {
|
||
calls += 1;
|
||
return new Response(new Uint8Array(), {
|
||
status: 200,
|
||
headers: { 'content-type': 'image/png', 'content-length': '0' },
|
||
});
|
||
};
|
||
const pkg = {
|
||
assets: [{
|
||
id: 'hero', type: 'image', url: 'https://old.invalid/hero.png',
|
||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||
bytes: 0, mime: 'image/png',
|
||
}],
|
||
};
|
||
|
||
await assert.rejects(
|
||
inject.materializeVerifiedAssets(
|
||
pkg,
|
||
'https://attacker.invalid/app-api/runtime/package/2048/assets/{sha256}',
|
||
),
|
||
/不受信任的 Runtime API 地址/,
|
||
);
|
||
assert.equal(calls, 0);
|
||
});
|
||
|
||
test('素材代理禁止跟随重定向并拒绝已重定向响应', async () => {
|
||
let requestOptions;
|
||
globalThis.fetch = async (_url, options) => {
|
||
requestOptions = options;
|
||
return {
|
||
ok: true,
|
||
redirected: true,
|
||
headers: new Headers({ 'content-type': 'image/png', 'content-length': '0' }),
|
||
};
|
||
};
|
||
const pkg = {
|
||
assets: [{
|
||
id: 'hero', type: 'image', url: 'https://old.invalid/hero.png',
|
||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||
bytes: 0, mime: 'image/png',
|
||
}],
|
||
};
|
||
|
||
await assert.rejects(
|
||
inject.materializeVerifiedAssets(
|
||
pkg,
|
||
'/app-api/runtime/package/2048/assets/{sha256}',
|
||
),
|
||
/素材请求失败:hero/,
|
||
);
|
||
assert.equal(requestOptions.redirect, 'error');
|
||
});
|
||
|
||
test('未下发素材代理模板时保留 GamePackage 原素材路径', async () => {
|
||
const pkg = {
|
||
assets: [{
|
||
id: 'hero', type: 'image', url: 'https://cdn.example/hero.png',
|
||
hash: 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
||
bytes: 0, mime: 'image/png',
|
||
}],
|
||
};
|
||
|
||
const resolved = await inject.materializeVerifiedAssets(pkg, undefined);
|
||
|
||
assert.equal(resolved, pkg);
|
||
assert.equal(resolved.assets[0].url, 'https://cdn.example/hero.png');
|
||
});
|
||
|
||
test('相对 Runtime manifest 路径仍可携带平台头读取', async () => {
|
||
let request;
|
||
globalThis.fetch = async (url, options) => {
|
||
request = { url, options };
|
||
return new Response(JSON.stringify({ manifest: {}, assets: [] }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
};
|
||
|
||
await inject.fetchAndVerifyManifest('/app-api/runtime/package/2048/manifest');
|
||
|
||
assert.equal(request.url, `${trustedApiBase}/app-api/runtime/package/2048/manifest`);
|
||
assert.equal(request.options.headers['tenant-id'], '1');
|
||
assert.equal(request.options.redirect, 'error');
|
||
});
|
||
|
||
test('manifest 即使收到已重定向成功响应也会在读取正文前拒绝', async () => {
|
||
let bodyRead = false;
|
||
globalThis.fetch = async () => ({
|
||
ok: true,
|
||
redirected: true,
|
||
status: 200,
|
||
async text() {
|
||
bodyRead = true;
|
||
return JSON.stringify({ manifest: {}, assets: [] });
|
||
},
|
||
});
|
||
|
||
await assert.rejects(
|
||
inject.fetchAndVerifyManifest('/app-api/runtime/package/2048/manifest'),
|
||
/manifest 请求禁止重定向/,
|
||
);
|
||
assert.equal(bodyRead, false);
|
||
});
|
||
|
||
test('受信 API origin 的绝对 Runtime manifest 地址仍可读取', async () => {
|
||
let requestUrl;
|
||
globalThis.fetch = async (url) => {
|
||
requestUrl = url;
|
||
return new Response(JSON.stringify({ manifest: {}, assets: [] }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
};
|
||
const manifestUrl = `${trustedApiBase}/app-api/runtime/package/2048/manifest?scene=preview`;
|
||
|
||
await inject.fetchAndVerifyManifest(manifestUrl);
|
||
|
||
assert.equal(requestUrl, manifestUrl);
|
||
});
|