收紧原生壳桥接权限边界

移动壳 HostBridge 协议名和版本改为共享契约常量

移动壳配置检查锁定主动导航和 deep link 宿主上下文补写

桌面壳主窗口 capability 移除 Tauri core 默认权限

桌面壳配置检查拒绝 core 默认权限和插件权限外露

更新原生壳方案与共享决策记录
This commit is contained in:
2026-06-18 23:52:40 +08:00
parent 04d2498579
commit 9eee997731
10 changed files with 173 additions and 16 deletions
+46 -3
View File
@@ -1063,9 +1063,40 @@ for (const capability of sdkBackedCapabilities) {
}
const allowedPermissions = [
'core:default',
'allow-host-bridge-request',
];
const blockedCoreDefaultPermissions = [
'core:default',
'core:app:default',
'core:event:default',
'core:image:default',
'core:menu:default',
'core:path:default',
'core:resources:default',
'core:tray:default',
'core:webview:default',
'core:window:default',
];
const blockedCorePermissionPrefixes = [
'core:app:',
'core:event:',
'core:image:',
'core:menu:',
'core:path:',
'core:resources:',
'core:tray:',
'core:webview:',
'core:window:',
];
const blockedPluginPermissionPrefixes = [
'clipboard-manager:',
'deep-link:',
'dialog:',
'fs:',
'notification:',
'opener:',
'window-state:',
];
const sharedTauriCommand = extractTsStringConst(
sharedContractSource,
'HOST_BRIDGE_TAURI_COMMAND',
@@ -1300,8 +1331,20 @@ assertSameList(
allowedPermissions,
'desktop shell capability permissions',
);
if ((capability.permissions ?? []).some((permission) => permission.startsWith('notification:'))) {
throw new Error('desktop shell must not expose notification plugin commands to H5');
for (const permission of capability.permissions ?? []) {
if (blockedCoreDefaultPermissions.includes(permission)) {
throw new Error(`desktop shell must not expose ${permission} to H5`);
}
if (
blockedCorePermissionPrefixes.some((prefix) => permission.startsWith(prefix))
) {
throw new Error(`desktop shell must not expose Tauri core permission ${permission} to H5`);
}
if (
blockedPluginPermissionPrefixes.some((prefix) => permission.startsWith(prefix))
) {
throw new Error(`desktop shell must not expose plugin permission ${permission} to H5`);
}
}
assertSameList(
extractTauriBuildCommands(buildScript),
@@ -4,7 +4,6 @@
"description": "主窗口只开放 Genarrative 桌面宿主壳需要的受控命令。",
"windows": ["main"],
"permissions": [
"core:default",
"allow-host-bridge-request"
]
}
+74 -2
View File
@@ -28,6 +28,10 @@ const hostBridgeSource = bridgeSourceFiles
.join('\n');
const urlPath = new URL('../src/shell/url.ts', import.meta.url);
const urlSource = fs.readFileSync(urlPath, 'utf8');
const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url);
const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8');
const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url);
const navigationSource = fs.readFileSync(navigationPath, 'utf8');
const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url);
const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8');
const webViewHistoryPath = new URL('../src/shell/webViewHistory.ts', import.meta.url);
@@ -167,6 +171,17 @@ function extractStringConstExport(source, exportName) {
return match[1];
}
function extractNumberConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Number(match[1]);
}
function extractMobileBridgeHandledMethods(source) {
const match = source.match(
/async function dispatchMobileHostBridgeRequest[\s\S]*?switch \(request\.method\) \{([\s\S]*?)\n \}/,
@@ -464,6 +479,14 @@ const sharedMethods = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const sharedHostBridgeProtocol = extractStringConstExport(
sharedContractSource,
'HOST_BRIDGE_PROTOCOL',
);
const sharedHostBridgeVersion = extractNumberConstExport(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
const handledMobileMethods = extractMobileBridgeHandledMethods(dispatchSource);
const mobileCapabilities = extractStringArrayExport(
hostBridgeSource,
@@ -709,8 +732,8 @@ if (
throw new Error('mobile shell Android app link data must only bind https://app.genarrative.world');
}
if (appConfig.extra?.genarrativeHostBridgeVersion !== 1) {
throw new Error('mobile shell extra HostBridge version must be 1');
if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) {
throw new Error('mobile shell extra HostBridge version must match shared HostBridge version');
}
for (const snippet of [
@@ -718,6 +741,8 @@ for (const snippet of [
"Linking.addEventListener('url'",
'buildMobileShellUrlFromDeepLink',
'configureMobileHostBridgeNavigation',
'HOST_BRIDGE_PROTOCOL',
'HOST_BRIDGE_VERSION',
'shouldAcceptMobileShellHostBridgeMessage',
'webViewRef.current?.reload()',
'const reloadCurrentWebView = useCallback(() => {',
@@ -876,6 +901,40 @@ for (const snippet of [
}
}
for (const snippet of [
'buildMobileShellUrl(',
'HOST_BRIDGE_VERSION.toString()',
"url.searchParams.set('clientRuntime', 'native_app')",
"url.searchParams.set('hostShell', 'expo_mobile')",
"url.searchParams.set('hostPlatform', options.platform)",
"url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString())",
"url.searchParams.set('hostCapabilities', options.capabilities.join(','))",
]) {
if (!urlSource.includes(snippet)) {
throw new Error(`mobile shell host-context URL builder missing ${snippet}`);
}
}
for (const snippet of [
'resolveMobileShellBaseWebUrl(baseWebUrl)',
'resolveTargetPath(rawUrl, webOrigin)',
'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)',
]) {
if (!deepLinkSource.includes(snippet)) {
throw new Error(`mobile shell deep link host-context flow missing ${snippet}`);
}
}
for (const snippet of [
'resolveMobileShellWebViewUrl',
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
'new URL(rawUrl, allowedOrigin).toString()',
]) {
if (!navigationSource.includes(snippet)) {
throw new Error(`mobile shell native-page navigation policy missing ${snippet}`);
}
}
if (shellAppSource.includes('127.0.0.1:3000')) {
throw new Error(
'mobile shell ShellApp must not hard-code localhost as the default H5 URL',
@@ -1014,6 +1073,7 @@ for (const snippet of [
'inFlightHostBridgeResponses',
'resolveMobileHostBridgeResponse',
'rememberHostBridgeResponse',
'buildMobileShellUrl(webViewUrl, navigation.urlOptions)',
]) {
if (!hostBridgeSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge missing ${snippet}`);
@@ -1044,6 +1104,18 @@ if (
throw new Error('mobile shell HostBridge version must not be duplicated in app or bridge source');
}
if (shellAppSource.includes(`bridge: '${sharedHostBridgeProtocol}'`)) {
throw new Error('mobile shell event injection must use HOST_BRIDGE_PROTOCOL');
}
if (shellAppSource.includes(`version: ${sharedHostBridgeVersion}`)) {
throw new Error('mobile shell event injection must use HOST_BRIDGE_VERSION');
}
if (urlSource.includes(`bridgeVersion', '${sharedHostBridgeVersion}'`)) {
throw new Error('mobile shell URL builder must use HOST_BRIDGE_VERSION');
}
for (const capability of sdkBackedCapabilities) {
if (
shellAppSource.includes(`'${capability}'`) ||
@@ -4,10 +4,15 @@ import fs from 'node:fs';
const appConfigPath = new URL('../app.json', import.meta.url);
const packagePath = new URL('../package.json', import.meta.url);
const sharedContractPath = new URL(
'../../../packages/shared/src/contracts/hostBridge.ts',
import.meta.url,
);
const shellRoot = new URL('../', import.meta.url);
const appConfig = JSON.parse(fs.readFileSync(appConfigPath, 'utf8')).expo;
const packageConfig = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
const sharedContractSource = fs.readFileSync(sharedContractPath, 'utf8');
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = spawnSync(
@@ -81,6 +86,22 @@ function findPlugin(name) {
);
}
function extractNumberConstExport(source, exportName) {
const match = source.match(
new RegExp(`export const ${exportName}\\s*=\\s*(\\d+);`),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Number(match[1]);
}
const sharedHostBridgeVersion = extractNumberConstExport(
sharedContractSource,
'HOST_BRIDGE_VERSION',
);
assertEqual(expoConfig.name, 'Genarrative', 'name');
assertEqual(expoConfig.slug, 'genarrative-mobile-shell', 'slug');
assertEqual(expoConfig.scheme, 'genarrative', 'scheme');
@@ -105,7 +126,7 @@ if ('releaseChannel' in expoConfig || 'channel' in expoConfig) {
}
assertEqual(
expoConfig.extra?.genarrativeHostBridgeVersion,
1,
sharedHostBridgeVersion,
'HostBridge version',
);
+6 -2
View File
@@ -15,6 +15,10 @@ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import type { WebViewMessageEvent } from 'react-native-webview';
import { WebView } from 'react-native-webview';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
} from '../../../../packages/shared/src/contracts/hostBridge';
import {
configureMobileHostBridgeNavigation,
handleMobileHostBridgeMessage,
@@ -97,8 +101,8 @@ export default function ShellApp() {
const injectHostBridgeEvent = useCallback((event: string, payload: unknown) => {
webViewRef.current?.injectJavaScript(
buildHostBridgeMessageScript({
bridge: 'GenarrativeHostBridge',
version: 1,
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
event,
payload,
}),
+4 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import { HOST_BRIDGE_VERSION } from '../../../../packages/shared/src/contracts/hostBridge';
import {
DEFAULT_MOBILE_SHELL_WEB_URL,
buildMobileShellUrl,
@@ -25,7 +26,9 @@ describe('buildMobileShellUrl', () => {
expect(url.searchParams.get('hostShell')).toBe('expo_mobile');
expect(url.searchParams.get('hostPlatform')).toBe('ios');
expect(url.searchParams.get('hostVersion')).toBe('0.1.0');
expect(url.searchParams.get('bridgeVersion')).toBe('1');
expect(url.searchParams.get('bridgeVersion')).toBe(
HOST_BRIDGE_VERSION.toString(),
);
expect(url.searchParams.get('hostCapabilities')).toBe(
'host.getRuntime,share.open',
);
+5 -4
View File
@@ -1,6 +1,7 @@
import type {
HostBridgeCapability,
NativeHostPlatform,
import {
HOST_BRIDGE_VERSION,
type HostBridgeCapability,
type NativeHostPlatform,
} from '../../../../packages/shared/src/contracts/hostBridge';
export type MobileShellUrlOptions = {
@@ -58,7 +59,7 @@ export function buildMobileShellUrl(
url.searchParams.set('hostShell', 'expo_mobile');
url.searchParams.set('hostPlatform', options.platform);
url.searchParams.set('hostVersion', options.hostVersion);
url.searchParams.set('bridgeVersion', '1');
url.searchParams.set('bridgeVersion', HOST_BRIDGE_VERSION.toString());
url.searchParams.set('hostCapabilities', options.capabilities.join(','));
return url.toString();
}
@@ -1,5 +1,9 @@
import { describe, expect, test } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
} from '../../../../packages/shared/src/contracts/hostBridge';
import { parseMobileWebViewHistoryStateMessage } from './webViewHistory';
describe('parseMobileWebViewHistoryStateMessage', () => {
@@ -31,8 +35,8 @@ describe('parseMobileWebViewHistoryStateMessage', () => {
expect(
parseMobileWebViewHistoryStateMessage(
JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
method: 'host.getRuntime',
}),
),