test(studio): postMessage 桥双校验安全红线独立验证(12 例)

补 game-studio/.agent TODO「postMessage 双校验"拒非法 origin/schema"独立验证」(security-and-reliability §1.1 沙箱红线):
- src/host/bridge.security.test.mts:桩 window 捕获 handler + 合成 {origin,data} 事件直灌(无需 jsdom),断言双校验——
  合法 origin+信封→分发不拒;非法 origin→拒 origin_not_allowed 不分发;空白名单→默认拒所有;
  非对象/channel 不匹配/type 非法/direction 非法/缺 traceId/缺 payload/requestId 非串→各自 RejectReason 拒;dispose 后失活
- .mts 不在 tsconfig.app include(src/**/*.ts) 内 → 不入 vue-tsc -b 构建,单独 `node --import tsx --test` 跑(同 game-runtime node --test 范式)
- .agent 该 TODO 勾除并记跑法

验证:12/12 绿(tsx --test);`npm run build` 仍绿(测试件不入构建,已确认)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-17 15:46:55 -07:00
parent 0cede4de98
commit ca65aebe63
2 changed files with 144 additions and 1 deletions

View File

@ -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。
## 验证门铁律(本仓特有,勿踩)

View File

@ -0,0 +1,143 @@
/**
* bridge.security.test.mts 宿 postMessage 线
* game-studio/.agent TODOpostMessage "拒非法 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 前置好(构造期 addEventListenerbridge 模块级不碰 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<string, unknown> = {}): Record<string, unknown> {
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');
});