收口HostBridge请求信封校验

共享契约新增HostBridge method白名单和request id归一规则

Expo与Tauri壳在能力分发前拒绝非法id和未知method

补充两端测试、配置门禁、原生壳方案和共享决策记录
This commit is contained in:
2026-06-18 13:22:27 +08:00
parent 4c4682069b
commit 71b18002d4
9 changed files with 205 additions and 5 deletions
@@ -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',
+100 -2
View File
@@ -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<String> {
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<HostBridgeResponse> {
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<HostBridgeResponse, String> {
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();
@@ -649,6 +649,8 @@ for (const snippet of [
'normalizeHostBridgeExportFileName',
'normalizeHostBridgeClipboardText',
'base64Data',
'isHostBridgeMethod',
'normalizeHostBridgeRequestId',
'HOST_BRIDGE_RESPONSE_CACHE_MAX',
'completedHostBridgeResponses',
'inFlightHostBridgeResponses',
@@ -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({
+6 -2
View File
@@ -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<HostBridgeRequest>;
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));
}
@@ -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 replayExpo 和 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 iconExpo 移动壳启动页和 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,让壳边界绕过真实渠道契约。
@@ -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 回归。
@@ -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',
@@ -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<Payload = unknown> = {
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'