diff --git a/game-studio/.agent b/game-studio/.agent index 0568ed90..fce0bfe3 100644 --- a/game-studio/.agent +++ b/game-studio/.agent @@ -37,7 +37,7 @@ game-studio/ - [x] **4 个 contractGaps 已由契约 owner 在 Wave3 补齐**:publish 门禁明细(GAP-1 ProjectPublishRespVO) / GamePackage 取包端点(GAP-2 runtime manifestUrl) / ShareMeta versionId(GAP-3) / 遥测 snake→camel(GAP-4)。 - [x] **已 git 提交**(前端脊柱 `67ad750`)。 - [ ] **真实联调(staging:切 `VITE_API_BASE_URL` 接 mini-desktop 后端,5 条链路端到端走查)** ← 当前最高价值待办,见总账 §5「联调」。 -- [ ] 安全测试补:postMessage 双校验"拒非法 origin/schema"独立验证。 +- [x] 安全测试补:postMessage 双校验"拒非法 origin/schema"独立验证(`src/host/bridge.security.test.mts`,12 例覆盖全 RejectReason + 空白名单默认拒 + dispose 失活;跑:`node --import tsx --test src/host/bridge.security.test.mts`)。 - [ ] 可选:卡片点击热区扩大到整卡;mock 假数据品牌文案;srcdoc 生产收紧 origin。 ## 验证门铁律(本仓特有,勿踩) diff --git a/game-studio/src/host/bridge.security.test.mts b/game-studio/src/host/bridge.security.test.mts new file mode 100644 index 00000000..809414ae --- /dev/null +++ b/game-studio/src/host/bridge.security.test.mts @@ -0,0 +1,143 @@ +/** + * bridge.security.test.mts —— 宿主 postMessage 桥【双校验安全红线】独立验证 + * (game-studio/.agent TODO「postMessage 双校验"拒非法 origin/schema"独立验证」; + * security-and-reliability §1.1 沙箱安全红线:不可信边界先校 origin 再校 schema,任一不过即丢弃绝不分发)。 + * + * 运行:node --import tsx --test src/host/bridge.security.test.mts + * (.mts 不在 tsconfig.app include[src/**\/*.ts] 内,故不入 vue-tsc -b 构建,仅本测试单独跑——同 game-runtime node --test 范式) + * + * 验证手法:桩 window 捕获 message handler,合成 {origin,data} 事件直灌(无需 jsdom/真浏览器), + * 断言「合法→分发不拒 / 非法 origin|schema→拒且不分发」。覆盖全部 RejectReason + 空白名单默认拒 + dispose 失活。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// —— 桩 window:必须在 new HostBridge 前置好(构造期 addEventListener;bridge 模块级不碰 window)—— +let captured: ((ev: { origin: string; data: unknown }) => void) | null = null; +(globalThis as { window?: unknown }).window = { + addEventListener: (type: string, h: (ev: { origin: string; data: unknown }) => void) => { + if (type === 'message') captured = h; + }, + removeEventListener: () => {}, +}; + +const { HostBridge } = await import('./bridge.ts'); +const { SDK_CHANNEL } = await import('./contract.ts'); + +const ALLOWED = 'https://play.example.com'; + +/** 造一座桥 + 间谍收集 onMessage/onReject + 合成事件触发器 */ +function setup(allowedOrigins: string[] = [ALLOWED]) { + captured = null; + const delivered: unknown[] = []; + const rejected: { reason: string; origin: string }[] = []; + const bridge = new HostBridge({ + targetWindow: null, + allowedOrigins, + onMessage: (e) => delivered.push(e), + onReject: (reason, _raw, origin) => rejected.push({ reason, origin }), + }); + return { + bridge, + delivered, + rejected, + fire: (ev: { origin: string; data: unknown }) => captured!(ev), + }; +} + +/** 合法信封(可覆写字段造非法用例) */ +function envelope(over: Record = {}): Record { + return { + channel: SDK_CHANNEL, + type: 'lifecycle', + direction: 'game_to_host', + traceId: 't-1', + payload: { event: 'game_loaded' }, + ...over, + }; +} + +test('合法 origin + 合法信封 → 分发且不拒', () => { + const { fire, delivered, rejected } = setup(); + fire({ origin: ALLOWED, data: envelope() }); + assert.equal(delivered.length, 1, '合法消息应分发'); + assert.equal(rejected.length, 0, '合法消息不应被拒'); +}); + +test('非法 origin → 拒 origin_not_allowed 且不分发(第一道)', () => { + const { fire, delivered, rejected } = setup(); + fire({ origin: 'https://evil.example.com', data: envelope() }); + assert.equal(delivered.length, 0, '非法 origin 绝不分发'); + assert.equal(rejected[0]?.reason, 'origin_not_allowed'); +}); + +test('空白名单 → 默认拒所有 origin(安全默认拒绝)', () => { + const { fire, delivered, rejected } = setup([]); + fire({ origin: ALLOWED, data: envelope() }); + assert.equal(delivered.length, 0); + assert.equal(rejected[0]?.reason, 'origin_not_allowed'); +}); + +test('合法 origin + 非对象 data → 拒 not_object', () => { + const { fire, delivered, rejected } = setup(); + fire({ origin: ALLOWED, data: 'definitely-not-an-object' }); + assert.equal(delivered.length, 0); + assert.equal(rejected[0]?.reason, 'not_object'); +}); + +test('channel 不匹配(伪造/噪声)→ 拒 channel_mismatch', () => { + const { fire, delivered, rejected } = setup(); + fire({ origin: ALLOWED, data: envelope({ channel: 'evil-channel' }) }); + assert.equal(delivered.length, 0); + assert.equal(rejected[0]?.reason, 'channel_mismatch'); +}); + +test('type 非白名单(如注入 exec)→ 拒 bad_type', () => { + const { fire, delivered, rejected } = setup(); + fire({ origin: ALLOWED, data: envelope({ type: 'exec' }) }); + assert.equal(delivered.length, 0); + assert.equal(rejected[0]?.reason, 'bad_type'); +}); + +test('direction 非法 → 拒 bad_direction', () => { + const { fire, rejected } = setup(); + fire({ origin: ALLOWED, data: envelope({ direction: 'sideways' }) }); + assert.equal(rejected[0]?.reason, 'bad_direction'); +}); + +test('缺 traceId → 拒 bad_payload', () => { + const { fire, rejected } = setup(); + const e = envelope(); + delete e.traceId; + fire({ origin: ALLOWED, data: e }); + assert.equal(rejected[0]?.reason, 'bad_payload'); +}); + +test('缺 payload 字段 → 拒 bad_payload', () => { + const { fire, rejected } = setup(); + const e = envelope(); + delete e.payload; + fire({ origin: ALLOWED, data: e }); + assert.equal(rejected[0]?.reason, 'bad_payload'); +}); + +test('requestId 存在但非字符串 → 拒 bad_payload', () => { + const { fire, rejected } = setup(); + fire({ origin: ALLOWED, data: envelope({ requestId: 123 }) }); + assert.equal(rejected[0]?.reason, 'bad_payload'); +}); + +test('合法 requestId(字符串)随合法信封 → 分发', () => { + const { fire, delivered, rejected } = setup(); + fire({ origin: ALLOWED, data: envelope({ type: 'ad', requestId: 'r-1' }) }); + assert.equal(rejected.length, 0); + assert.equal(delivered.length, 1); +}); + +test('dispose 后任何消息均不再处理', () => { + const { bridge, fire, delivered, rejected } = setup(); + bridge.dispose(); + fire({ origin: ALLOWED, data: envelope() }); + assert.equal(delivered.length, 0, 'dispose 后不分发'); + assert.equal(rejected.length, 0, 'dispose 后直接 return(不走校验/不回调 onReject)'); +});