games-development-ai/game-studio/scripts/inject-security.test.mjs
lili 1981a6be01
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(storage): 闭合 Agent 到 Runtime 的 OSS 游戏链路
增加不可变源码检出、两阶段 finalize/activate、Runtime 同源对象读取与 iframe 安全边界,并用 fail-closed CI 门固定契约。Git 仅提交平台实现、契约、迁移和 SoT,不包含具体游戏源码、素材、bundle 或验收证据。
2026-07-30 05:21:44 -07:00

336 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});