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:
parent
0cede4de98
commit
ca65aebe63
@ -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。
|
||||
|
||||
## 验证门铁律(本仓特有,勿踩)
|
||||
|
||||
143
game-studio/src/host/bridge.security.test.mts
Normal file
143
game-studio/src/host/bridge.security.test.mts
Normal file
@ -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<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)');
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user