收紧原生壳桥接权限边界
移动壳 HostBridge 协议名和版本改为共享契约常量 移动壳配置检查锁定主动导航和 deep link 宿主上下文补写 桌面壳主窗口 capability 移除 Tauri core 默认权限 桌面壳配置检查拒绝 core 默认权限和插件权限外露 更新原生壳方案与共享决策记录
This commit is contained in:
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user