收口HostBridge入站消息来源

让Expo和Tauri注入的HostBridge消息显式携带当前窗口origin和source

H5原生HostBridge监听器拒绝非当前窗口或非当前origin的消息

补充伪造来源回包测试,防止sandbox消息结算宿主请求

更新原生壳方案和共享决策记录
This commit is contained in:
2026-06-18 12:43:53 +08:00
parent ce66b69ad8
commit 6cf1038ff3
8 changed files with 82 additions and 2 deletions
@@ -686,6 +686,8 @@ const requiredMainSnippets = [
'WindowEvent::Focused',
'WindowEvent::DragDrop',
'host_bridge_event_script',
'origin: window.location.origin',
'source: window',
'resolve_desktop_network_status',
'network.statusChanged',
'file.imageDropped',
+3 -1
View File
@@ -872,7 +872,7 @@ fn host_bridge_event_script(event: &str, payload: Value) -> Result<String, serde
let data_literal = serde_json::to_string(&data)?;
Ok(format!(
"window.dispatchEvent(new MessageEvent('message', {{ data: {} }})); true;",
"window.dispatchEvent(new MessageEvent('message', {{ data: {}, origin: window.location.origin, source: window }})); true;",
data_literal
))
}
@@ -1892,6 +1892,8 @@ mod tests {
.expect("event script");
assert!(script.contains("MessageEvent('message'"));
assert!(script.contains("origin: window.location.origin"));
assert!(script.contains("source: window"));
assert!(script.contains("GenarrativeHostBridge"));
assert!(script.contains("app.lifecycle"));
assert!(script.contains("\\\"state\\\":\\\"active\\\""));
+1 -1
View File
@@ -36,7 +36,7 @@ import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './src/mobileShellWebViewPolicy';
function buildHostBridgeMessageScript(message: unknown) {
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
JSON.stringify(message),
)} })); true;`;
)}, origin: window.location.origin, source: window })); true;`;
}
export default function App() {
@@ -486,6 +486,8 @@ for (const snippet of [
'subscribeMobileNetworkStatus',
'navigation.canGoBack',
'buildHostBridgeMessageScript',
'origin: window.location.origin',
'source: window',
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'SafeAreaProvider',
'SafeAreaView',
@@ -2390,3 +2390,10 @@
- 决策:`apps/mobile-shell/scripts/check-config.mjs` 同时检查移动壳包和根 H5 包的直接依赖;在真实发布通道、采集字段、用户授权、隐私披露、签名 / 回滚策略和团队发布流程落地前,两处都不得安装上述移动渠道 SDK。现有即时本地通知、系统分享、文件导入导出等真实宿主能力不受影响。
- 影响范围:移动壳配置检查、根依赖边界和 Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run typecheck``npm run check:encoding``git diff --check`
## 2026-06-18 原生 HostBridge 入站消息来源收口
- 背景:H5 主站会同时承载原生壳 HostBridge 和后续 AI H5 sandbox / GameBridge;如果 H5 侧只按 JSON envelope 识别 HostBridge response / eventsandbox iframe 可以构造同形 `postMessage` 干扰待处理宿主请求或伪造宿主事件。
- 决策:Expo 和 Tauri 注入给 H5 的 HostBridge response / event 统一带 `origin: window.location.origin``source: window``nativeAppHostBridge` listener 只接受无外部 source 或当前窗口 source 的消息,并拒绝非当前页面 origin。AI sandbox 后续继续使用独立 GameBridge allowlist,不允许直接结算 HostBridge 请求。
- 影响范围:`apps/mobile-shell/App.tsx``apps/desktop-shell/src-tauri/src/main.rs``src/services/host-bridge/nativeAppHostBridge.ts`、两端壳配置检查和 HostBridge 方案文档。
- 验证方式:`npm run check:native-shells``npm run test -- src/services/host-bridge/nativeAppHostBridge.test.ts src/services/host-bridge/hostBridge.test.ts``npm run typecheck``npm run check:encoding``git diff --check`
@@ -231,6 +231,7 @@ GameBridge 禁止:
- HostBridge request 必须校验 `bridge``version``id``method` 和 payload shape。
- 壳层只接受来自允许 origin / packaged asset 的消息。
- H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。
- 每个请求必须有超时,重复 `id` 不得重复执行支付、登录等非幂等动作。
- 能力按 `capabilities` / `hostCapabilities` 下发,H5 会过滤未知能力,并根据声明结果决定是否展示入口、发起宿主请求或走 fallback;进入 `native_app` 后主 App 会再通过真实 `host.getRuntime` 回读一次宿主 runtime 并缓存能力,用来补齐裁剪壳或旧入口 URL 缺少 `hostCapabilities` 的场景。不能只凭 `native_app` 宿主类型假设能力可用。
- 壳能力声明与两端壳测试必须通过 `npm run check:native-shells` 统一校验;排查单端问题时可再分别运行 `npm run mobile-shell:typecheck``npm run mobile-shell:test``npm run mobile-shell:config``npm run mobile-shell:export``npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle`。声明的 capability 必须存在于共享 HostBridge 白名单,壳 runtime 回包、H5 URL `hostCapabilities`、壳实现、Expo managed config、移动端 production bundle、桌面 release 构建入口和两端壳生产源码临时替身词扫描不得漂移。
@@ -345,6 +346,8 @@ GameBridge 禁止:
2026-06-18 追加:桌面壳 JS guest 依赖进入门禁。`apps/desktop-shell/package.json` 和根 H5 `package.json` 不得安装 `@tauri-apps/api` 或任何 `@tauri-apps/plugin-*` 包,避免生产前端绕过 `nativeAppHostBridge` 直接调用 Tauri JS 客户端 APITauri CLI 仍只作为构建工具留在 devDependencies,桌面系统能力继续由 Rust 侧 Cargo 插件和唯一 `host_bridge_request` command 承接。
2026-06-18 追加:原生壳注入消息来源进入门禁。Expo 和 Tauri 注入给 H5 的 HostBridge response / event 都显式带 `origin: window.location.origin``source: window`H5 `nativeAppHostBridge` listener 会忽略带非当前窗口 source 或非当前页面 origin 的 message。这样后续 AI sandbox iframe 即使能向父页面 `postMessage` 同形 envelope,也不能结算宿主请求或伪造宿主事件;GameBridge 继续走单独 allowlist。
2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*``ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp``apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。
2026-06-18 追加:桌面壳 release 构建烟测进入统一验收。`npm run check:native-shells` 会在 H5 HostBridge、Expo 壳和 Tauri 单测通过后执行 `npm run desktop-shell:build -- --no-bundle`,确认根 `dist` H5 资产、Tauri release 入口、受控命令白名单、图标和 Rust release 编译可以共同产出桌面二进制;该烟测不生成平台安装包,避免把 Linux 本机缺少的系统打包器误判为 HostBridge 回归。
@@ -29,6 +29,8 @@ function dispatchHostBridgeResponse(response: HostBridgeResponse) {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify(response),
origin: window.location.origin,
source: window,
}),
);
}
@@ -141,6 +143,45 @@ describe('nativeAppHostBridge', () => {
await assertion;
});
test('忽略非当前窗口来源伪造的 HostBridge 回包', async () => {
const channel = new MessageChannel();
const postMessage = vi.fn((message: string) => {
const request = JSON.parse(message) as HostBridgeRequest;
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: 'forged',
},
} satisfies HostBridgeResponse),
origin: window.location.origin,
source: channel.port1,
}),
);
dispatchHostBridgeResponse({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: request.id,
ok: true,
result: {
handled: 'native',
},
});
});
window.ReactNativeWebView = { postMessage };
await expect(requestNativeAppHostBridge('share.open')).resolves.toEqual({
handled: 'native',
});
channel.port1.close();
channel.port2.close();
});
test('订阅 React Native WebView 注入的宿主事件', () => {
window.ReactNativeWebView = {
postMessage: vi.fn(),
@@ -160,6 +201,8 @@ describe('nativeAppHostBridge', () => {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribe();
@@ -132,6 +132,23 @@ function dispatchNativeEvent(event: HostBridgeEvent) {
}
}
function isNativeInjectedMessageEvent(event: MessageEvent) {
const nativeWindow = resolveNativeWindow();
if (!nativeWindow) {
return false;
}
if (event.source && event.source !== nativeWindow) {
return false;
}
if (event.origin && event.origin !== nativeWindow.location.origin) {
return false;
}
return true;
}
function ensureNativeBridgeListener() {
const nativeWindow = resolveNativeWindow();
if (!nativeWindow || nativeBridgeListenerInstalled) {
@@ -139,6 +156,10 @@ function ensureNativeBridgeListener() {
}
nativeWindow.addEventListener('message', (event) => {
if (!isNativeInjectedMessageEvent(event)) {
return;
}
const value = parseNativeMessage(event.data);
if (isHostBridgeResponse(value)) {
settleNativeResponse(value);