diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index 92bf5ed59..03076fac1 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -705,6 +705,12 @@ const requiredMainSnippets = [ 'DownloadEvent::Requested { .. } => false', '.on_download(|_webview, event| should_allow_desktop_webview_download(&event))', 'NewWindowResponse::Deny', + 'HOST_BRIDGE_METHODS', + 'HOST_BRIDGE_REQUEST_ID_MAX_LENGTH', + 'normalize_request_id', + 'is_host_bridge_method', + 'invalid host bridge request id', + 'invalid host bridge method', 'HostBridgeReplayState', 'HostBridgeReplayReservation', 'HOST_BRIDGE_RESPONSE_CACHE_MAX', diff --git a/apps/desktop-shell/src-tauri/src/main.rs b/apps/desktop-shell/src-tauri/src/main.rs index 43b76172f..2f3a1afeb 100644 --- a/apps/desktop-shell/src-tauri/src/main.rs +++ b/apps/desktop-shell/src-tauri/src/main.rs @@ -24,6 +24,31 @@ use tauri_plugin_opener::OpenerExt; const HOST_BRIDGE_PROTOCOL: &str = "GenarrativeHostBridge"; const HOST_BRIDGE_VERSION: u8 = 1; +const HOST_BRIDGE_METHODS: [&str; 23] = [ + "host.getRuntime", + "appearance.getColorScheme", + "auth.requestLogin", + "payment.request", + "share.setTarget", + "share.open", + "navigation.openNativePage", + "app.reloadWebView", + "app.openExternalUrl", + "app.setTitle", + "app.setBadgeCount", + "network.status", + "clipboard.writeText", + "clipboard.readText", + "file.exportText", + "file.importText", + "file.exportImage", + "file.importImage", + "file.captureImage", + "file.importAudio", + "file.exportAudio", + "haptics.impact", + "notification.showLocal", +]; const WEB_APP_ORIGIN: &str = "https://app.genarrative.world"; const EXTERNAL_URL_PROTOCOLS: [&str; 4] = ["http:", "https:", "mailto:", "tel:"]; const EXPORT_TEXT_MAX_BYTES: usize = 5 * 1024 * 1024; @@ -39,6 +64,7 @@ const DESKTOP_NETWORK_CHECK_TIMEOUT_MS: u64 = 1200; const LOCAL_NOTIFICATION_TITLE_MAX_LENGTH: usize = 80; const LOCAL_NOTIFICATION_BODY_MAX_LENGTH: usize = 240; const CLIPBOARD_TEXT_MAX_LENGTH: usize = 100000; +const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH: usize = 120; const HOST_BRIDGE_RESPONSE_CACHE_MAX: usize = 128; const DESKTOP_TRAY_ID: &str = "genarrative-desktop-tray"; const TRAY_MENU_SHOW: &str = "show-main-window"; @@ -296,15 +322,54 @@ fn color_scheme_from_theme(theme: Theme) -> &'static str { } } +fn has_control_character(value: &str) -> bool { + value.chars().any(|character| { + let code_point = character as u32; + code_point <= 31 || code_point == 127 + }) +} + +fn normalize_request_id(raw_id: &str) -> Option { + let id = raw_id.trim(); + if id.is_empty() + || id.chars().count() > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + || has_control_character(id) + { + return None; + } + + Some(id.to_string()) +} + +fn is_host_bridge_method(method: &str) -> bool { + HOST_BRIDGE_METHODS.contains(&method) +} + fn validate_request(request: &HostBridgeRequest) -> Option { + let Some(request_id) = normalize_request_id(&request.id) else { + return Some(failed( + "invalid".to_string(), + "invalid_request", + "invalid host bridge request id", + )); + }; + if request.bridge != HOST_BRIDGE_PROTOCOL || request.version != HOST_BRIDGE_VERSION { return Some(failed( - request.id.clone(), + request_id, "invalid_request", "invalid host bridge envelope", )); } + if !is_host_bridge_method(&request.method) { + return Some(failed( + request_id, + "invalid_request", + "invalid host bridge method", + )); + } + None } @@ -1676,11 +1741,12 @@ async fn execute_host_bridge_request( async fn host_bridge_request( app: tauri::AppHandle, replay_state: tauri::State<'_, HostBridgeReplayState>, - request: HostBridgeRequest, + mut request: HostBridgeRequest, ) -> Result { if let Some(response) = validate_request(&request) { return Ok(response); } + request.id = normalize_request_id(&request.id).unwrap_or(request.id); let response = match replay_state.reserve(&request.id) { HostBridgeReplayReservation::Wait(slot) => HostBridgeReplayState::wait_for_response(slot), @@ -1903,6 +1969,38 @@ mod tests { assert_eq!(response.error.expect("error").code, "invalid_request"); } + #[test] + fn invalid_request_id_and_unknown_method_are_rejected() { + for id in ["", "request\n1"] { + let mut invalid = request("share.open"); + invalid.id = id.to_string(); + + let response = resolve_host_bridge_request(invalid); + + assert!(!response.ok); + assert_eq!(response.id, "invalid"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + } + + let mut oversized = request("share.open"); + oversized.id = "a".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH + 1); + let response = resolve_host_bridge_request(oversized); + assert!(!response.ok); + assert_eq!(response.id, "invalid"); + assert_eq!(response.error.expect("error").code, "invalid_request"); + + let mut multibyte_boundary = request("host.getRuntime"); + multibyte_boundary.id = "作".repeat(HOST_BRIDGE_REQUEST_ID_MAX_LENGTH); + let response = resolve_host_bridge_request(multibyte_boundary); + assert!(response.ok); + + let response = resolve_host_bridge_request(request("host.runArbitraryCommand")); + assert!(!response.ok); + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!(error.message, "invalid host bridge method"); + } + #[test] fn host_bridge_replay_state_reuses_first_response_for_duplicate_id() { let replay_state = HostBridgeReplayState::default(); diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index dfeee5c73..7ea7d8410 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -649,6 +649,8 @@ for (const snippet of [ 'normalizeHostBridgeExportFileName', 'normalizeHostBridgeClipboardText', 'base64Data', + 'isHostBridgeMethod', + 'normalizeHostBridgeRequestId', 'HOST_BRIDGE_RESPONSE_CACHE_MAX', 'completedHostBridgeResponses', 'inFlightHostBridgeResponses', diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts index 0b4a3e341..53c4c3a5d 100644 --- a/apps/mobile-shell/src/mobileHostBridge.test.ts +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -198,6 +198,21 @@ async function send(requestValue: HostBridgeRequest) { return response; } +async function sendRaw(requestValue: unknown) { + const responses: HostBridgeResponse[] = []; + + await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) => + responses.push(response), + ); + + const response = responses[0]; + if (!response) { + throw new Error('host bridge response missing'); + } + + return response; +} + function expectOk(response: HostBridgeResponse) { if (!response.ok) { throw new Error('expected ok host bridge response'); @@ -431,6 +446,31 @@ describe('handleMobileHostBridgeMessage', () => { }, ); + test('拒绝非法 request id 和未知 method', async () => { + const invalidId = await sendRaw({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request\n1', + method: 'share.open', + }); + const unknownMethod = await sendRaw({ + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request-unknown', + method: 'host.runArbitraryCommand', + }); + + expect(expectFailed(invalidId).error).toEqual({ + code: 'invalid_request', + message: 'invalid host bridge request', + }); + expect(expectFailed(unknownMethod).error).toEqual({ + code: 'invalid_request', + message: 'invalid host bridge request', + }); + expect(Share.share).not.toHaveBeenCalled(); + }); + test('app.reloadWebView 刷新移动壳当前 WebView', async () => { const reloadWebView = vi.fn(); configureMobileHostBridgeNavigation({ diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index ce00f2c25..7af479d35 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -37,12 +37,14 @@ import { type HostBridgeResponse, type HostBridgeTextMimeType, type NavigateNativePagePayload, + isHostBridgeMethod, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, normalizeHostBridgeColorScheme, normalizeHostBridgeExportFileName, normalizeHostBridgeExternalUrl, normalizeHostBridgeLocalNotification, + normalizeHostBridgeRequestId, type OpenExternalUrlPayload, type SetBadgeCountPayload, type ShareOpenPayload, @@ -204,11 +206,12 @@ function isHostBridgeRequest(value: unknown): value is HostBridgeRequest { } const candidate = value as Partial; + const requestId = normalizeHostBridgeRequestId(candidate.id); return ( candidate.bridge === HOST_BRIDGE_PROTOCOL && candidate.version === HOST_BRIDGE_VERSION && - typeof candidate.id === 'string' && - typeof candidate.method === 'string' + requestId !== null && + isHostBridgeMethod(candidate.method) ); } @@ -1040,6 +1043,7 @@ export async function handleMobileHostBridgeMessage( return; } + parsed.id = normalizeHostBridgeRequestId(parsed.id) ?? parsed.id; sendResponse(await resolveMobileHostBridgeResponse(parsed)); } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 932829143..f7aaa6c09 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -68,6 +68,7 @@ - 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。 - 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。 - 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。 +- 2026-06-18 HostBridge request envelope 校验:共享契约提供 `isHostBridgeMethod` 与 `normalizeHostBridgeRequestId`,Expo 壳直接复用,Tauri 壳镜像同一白名单和 id 规则;空 id、控制字符 id、超长 id 和未知 method 都必须在 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 - 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。 - 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,根级 `npm run check:native-shells` 也会统一扫描两端壳生产源码,防止把临时替身、占位文案或伪实现带进可分发壳。 - 2026-06-18 移动壳启动页与 adaptive icon:Expo 移动壳启动页和 Android adaptive icon 复用现有真实品牌图标 `apps/mobile-shell/assets/icon.png`,背景色固定为 H5 壳根背景 `#fffdf9`。该 PNG 是 1024x1024 RGBA 透明前景品牌资产,不新增占位图;配置检查会校验图标尺寸、透明像素、splash 和 adaptive icon 指向,避免后续换成非品牌或占位素材。 @@ -2392,6 +2393,13 @@ - 影响范围:`apps/mobile-shell/src/mobileHostBridge.ts`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-18 HostBridge request envelope 校验 + +- 背景:HostBridge 请求来自 H5 WebView / Tauri 注入通道,TypeScript 类型不能替代宿主运行时校验;空 id、控制字符 id、过长 id 或未知 method 如果进入 replay / 能力分发,可能污染缓存、绕过方法白名单或造成错误语义混乱。 +- 决策:共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 `HOST_BRIDGE_METHODS` 和 request id 规则。request id 归一后必须为 1-120 字符且不含控制字符;未知 method 在进入能力分发前返回 `invalid_request`,只有白名单内但当前壳未实现的登录 / 支付等 method 返回 `unsupported_method`。 +- 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`apps/mobile-shell/src/mobileHostBridge.ts`、`apps/desktop-shell/src-tauri/src/main.rs`、两端配置检查、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run check:native-shells`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-18 移动壳渠道 SDK 依赖收口 - 背景:Expo 移动壳运行时依赖可能从根安装树解析;如果只检查 `apps/mobile-shell/package.json`,根 H5 包仍可能直接引入 Expo Updates、Sentry、Firebase Analytics、PostHog、Amplitude、Segment、CodePush 等移动端发布通道、崩溃上报或 analytics SDK,让壳边界绕过真实渠道契约。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 3bfb49f0c..ebe4da1c5 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -229,7 +229,7 @@ GameBridge 禁止: ## 安全约束 -- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape。 +- HostBridge request 必须校验 `bridge`、`version`、`id`、`method` 和 payload shape;`id` 归一后必须是 1-120 字符且不含控制字符,`method` 必须来自共享白名单,未知 method 作为非法 request 拒绝。 - 壳层只接受来自允许 origin / packaged asset 的消息。 - H5 侧 HostBridge listener 只接收原生壳注入到当前窗口的 message;带有非当前窗口 `source` 或非当前页面 `origin` 的消息必须忽略,避免 AI sandbox iframe 或其它子上下文伪造 HostBridge response / event。 - 每个请求必须有超时,重复 `id` 不得重复执行支付、登录、系统分享、文件导入导出、本地通知等宿主副作用;Expo 和 Tauri 壳都必须按 request id 回放首次完成结果。 @@ -350,6 +350,8 @@ GameBridge 禁止: 2026-06-18 追加:HostBridge request id 进入宿主侧 replay 门禁。Expo 壳会缓存已完成响应并让进行中的同 id 请求共用同一执行结果;Tauri 壳在唯一 `host_bridge_request` command 外层通过 `HostBridgeReplayState` 对同 id 请求做等待 / 回放。重复 id 只返回首次结果,不会二次触发系统分享、外链、剪贴板、文件选择 / 保存、本地通知或窗口动作。 +2026-06-18 追加:HostBridge request envelope 校验收紧。共享契约提供 `isHostBridgeMethod` 和 `normalizeHostBridgeRequestId`;Expo 壳直接复用,Tauri 壳镜像同一 method 白名单和 request id 规则。空 id、控制字符 id、超长 id 和未知 method 都在进入 replay / 能力分发前返回 `invalid_request`,已知但当前壳未实现的登录 / 支付等 method 才返回 `unsupported_method`。 + 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 回归。 diff --git a/packages/shared/src/contracts/hostBridge.test.ts b/packages/shared/src/contracts/hostBridge.test.ts index fa90df065..20a9e8bd1 100644 --- a/packages/shared/src/contracts/hostBridge.test.ts +++ b/packages/shared/src/contracts/hostBridge.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest'; import { + isHostBridgeMethod, isHostBridgeCapability, normalizeHostBridgeBadgeCount, normalizeHostBridgeClipboardText, @@ -10,6 +11,7 @@ import { normalizeHostBridgeExternalUrl, normalizeHostBridgeLifecycleState, normalizeHostBridgeLocalNotification, + normalizeHostBridgeRequestId, } from './hostBridge'; describe('HostBridge shared contract helpers', () => { @@ -51,6 +53,10 @@ describe('HostBridge shared contract helpers', () => { }); test('识别 HostBridge 能力白名单', () => { + expect(isHostBridgeMethod('host.getRuntime')).toBe(true); + expect(isHostBridgeMethod('share.open')).toBe(true); + expect(isHostBridgeMethod('app.lifecycle')).toBe(false); + expect(isHostBridgeMethod('unknown.method')).toBe(false); expect(isHostBridgeCapability('appearance.getColorScheme')).toBe(true); expect(isHostBridgeCapability('share.open')).toBe(true); expect(isHostBridgeCapability('app.reloadWebView')).toBe(true); @@ -71,6 +77,14 @@ describe('HostBridge shared contract helpers', () => { expect(isHostBridgeCapability(null)).toBe(false); }); + test('归一化 HostBridge request id', () => { + expect(normalizeHostBridgeRequestId(' request-1 ')).toBe('request-1'); + expect(normalizeHostBridgeRequestId('')).toBeNull(); + expect(normalizeHostBridgeRequestId('request\n1')).toBeNull(); + expect(normalizeHostBridgeRequestId('a'.repeat(121))).toBeNull(); + expect(normalizeHostBridgeRequestId(null)).toBeNull(); + }); + test('归一化宿主剪贴板读取文本', () => { expect(normalizeHostBridgeClipboardText('作品号 PZ-1')).toEqual({ text: '作品号 PZ-1', diff --git a/packages/shared/src/contracts/hostBridge.ts b/packages/shared/src/contracts/hostBridge.ts index 2bb5eac33..460f52fa7 100644 --- a/packages/shared/src/contracts/hostBridge.ts +++ b/packages/shared/src/contracts/hostBridge.ts @@ -41,6 +41,13 @@ export const HOST_BRIDGE_METHODS = [ export type HostBridgeMethod = (typeof HOST_BRIDGE_METHODS)[number]; +export function isHostBridgeMethod(value: unknown): value is HostBridgeMethod { + return ( + typeof value === 'string' && + HOST_BRIDGE_METHODS.includes(value as HostBridgeMethod) + ); +} + export const HOST_BRIDGE_CAPABILITIES = [ ...HOST_BRIDGE_METHODS, 'host.events', @@ -78,6 +85,25 @@ export type HostBridgeRequest = { timeoutMs?: number; }; +export const HOST_BRIDGE_REQUEST_ID_MAX_LENGTH = 120; + +export function normalizeHostBridgeRequestId(rawId: unknown) { + if (typeof rawId !== 'string') { + return null; + } + + const id = rawId.trim(); + if ( + !id || + id.length > HOST_BRIDGE_REQUEST_ID_MAX_LENGTH || + hasHostBridgeControlCharacter(id) + ) { + return null; + } + + return id; +} + export type HostBridgeError = { code: | 'invalid_request'