收紧移动外链打开边界

将 WebView 外链打开收口到可测试 helper

要求移动壳外链先通过 canOpenURL 再 openURL

同步移动壳外链打开边界记录
This commit is contained in:
2026-06-19 17:38:00 +08:00
parent 2c67cd595d
commit 8764e25550
5 changed files with 73 additions and 10 deletions
+17 -2
View File
@@ -1324,8 +1324,12 @@ for (const snippet of [
for (const snippet of [
'normalizeHostBridgeExternalUrl',
'MobileShellExternalNavigator',
'openMobileShellExternalNavigation',
'resolveMobileShellWebViewUrl',
'return normalizeHostBridgeExternalUrl(rawUrl)',
'navigator.canOpenURL(externalUrl)',
'navigator.openURL(externalUrl)',
'shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)',
'new URL(rawUrl, allowedOrigin).toString()',
]) {
@@ -1762,6 +1766,17 @@ if (!dispatchSource.includes('Linking.canOpenURL(url)')) {
throw new Error('mobile shell HostBridge external URL flow must check Linking.canOpenURL');
}
if (!shellAppSource.includes('Linking.canOpenURL(externalUrl)')) {
throw new Error('mobile shell WebView external navigation must check Linking.canOpenURL');
if (!shellAppSource.includes('openMobileShellExternalNavigation(Linking, request.url)')) {
throw new Error(
'mobile shell WebView external navigation must use the tested external navigation helper',
);
}
if (
shellAppSource.includes('Linking.canOpenURL(externalUrl)') ||
shellAppSource.includes('Linking.openURL(externalUrl)')
) {
throw new Error(
'mobile shell ShellApp must not inline external navigation Link handling',
);
}
+4 -7
View File
@@ -33,7 +33,7 @@ import {
normalizeMobileShellLoadFailure,
} from './loadFailure';
import {
resolveMobileShellExternalUrl,
openMobileShellExternalNavigation,
shouldAcceptMobileShellHostBridgeMessage,
shouldOpenInMobileShellWebView,
} from './navigation';
@@ -270,12 +270,9 @@ export default function ShellApp() {
return true;
}
const externalUrl = resolveMobileShellExternalUrl(request.url);
if (externalUrl) {
void Linking.canOpenURL(externalUrl)
.then((canOpen) => (canOpen ? Linking.openURL(externalUrl) : undefined))
.catch(() => undefined);
}
void openMobileShellExternalNavigation(Linking, request.url).catch(
() => undefined,
);
return false;
};
+33 -1
View File
@@ -1,7 +1,8 @@
import { describe, expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import { HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
import {
openMobileShellExternalNavigation,
resolveMobileShellExternalUrl,
resolveMobileShellWebViewUrl,
shouldAcceptMobileShellHostBridgeMessage,
@@ -68,6 +69,37 @@ describe('shouldOpenInMobileShellWebView', () => {
expect(resolveMobileShellExternalUrl('/relative/path')).toBeNull();
});
test('WebView 外链必须先确认系统能打开再离开壳', async () => {
const navigator = {
canOpenURL: vi.fn(async (url: string) => url.startsWith('https://')),
openURL: vi.fn(async () => undefined),
};
await expect(
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
).resolves.toBe(true);
expect(navigator.canOpenURL).toHaveBeenCalledWith('https://example.com/path');
expect(navigator.openURL).toHaveBeenCalledWith('https://example.com/path');
navigator.canOpenURL.mockClear();
navigator.openURL.mockClear();
await expect(
openMobileShellExternalNavigation(navigator, 'mailto:hi@example.com'),
).resolves.toBe(false);
expect(navigator.canOpenURL).toHaveBeenCalledWith('mailto:hi@example.com');
expect(navigator.openURL).not.toHaveBeenCalled();
navigator.canOpenURL.mockClear();
navigator.openURL.mockClear();
await expect(
openMobileShellExternalNavigation(navigator, 'javascript:alert(1)'),
).resolves.toBe(false);
expect(navigator.canOpenURL).not.toHaveBeenCalled();
expect(navigator.openURL).not.toHaveBeenCalled();
});
test('HostBridge 主动导航只解析同源网页目标', () => {
const allowedOrigin = 'https://app.genarrative.world';
+18
View File
@@ -1,5 +1,10 @@
import { normalizeHostBridgeExternalUrl } from '../../../../packages/shared/src/contracts/hostBridge';
export type MobileShellExternalNavigator = {
canOpenURL: (url: string) => Promise<boolean>;
openURL: (url: string) => Promise<unknown>;
};
export function shouldOpenInMobileShellWebView(
rawUrl: string,
allowedOrigin: string,
@@ -32,6 +37,19 @@ export function resolveMobileShellExternalUrl(rawUrl: string) {
return normalizeHostBridgeExternalUrl(rawUrl);
}
export async function openMobileShellExternalNavigation(
navigator: MobileShellExternalNavigator,
rawUrl: string,
) {
const externalUrl = resolveMobileShellExternalUrl(rawUrl);
if (!externalUrl || !(await navigator.canOpenURL(externalUrl))) {
return false;
}
await navigator.openURL(externalUrl);
return true;
}
export function shouldAcceptMobileShellHostBridgeMessage(
rawUrl: string,
allowedOrigin: string,
@@ -25,6 +25,7 @@
- 2026-06-18 外链协议白名单门禁:`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS``app.openExternalUrl` 唯一协议来源,当前只允许 `http:``https:``mailto:``tel:`;Expo 直接复用共享归一化逻辑,Tauri Rust 侧必须用 URL parser 镜像同一清单,根级 `npm run check:native-shells` 会拒绝共享契约与桌面壳协议清单漂移。
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:``https:``mailto:``tel:` 交给 `Linking.openURL``javascript:``file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
- 2026-06-19 移动壳 WebView 外链协议共源:`apps/mobile-shell/src/shell/navigation.ts` 的 WebView 外链离壳判断必须调用共享 `normalizeHostBridgeExternalUrl`,不得在 shell 层另写协议判断;`apps/mobile-shell/scripts/check-config.mjs` 会拒绝重新硬编码 `mailto:` / `tel:` / `javascript:` 等协议分支,`navigation.test.ts``HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 反查当前允许协议。
- 2026-06-19 移动壳 WebView 外链打开收口:Expo WebView 外链拦截统一调用 `openMobileShellExternalNavigation(Linking, request.url)`,该 helper 先复用共享外链协议 normalizer,再调用 `canOpenURL` 确认系统可处理,最后才 `openURL`;系统不能打开或 URL 被拒绝时只阻断留壳,不伪造成功也不把危险协议交给系统。`ShellApp` 不再内联 `Linking.canOpenURL` / `Linking.openURL` Promise 链,移动壳配置检查和 `navigation.test.ts` 会覆盖该顺序。
- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。
- 2026-06-19 桌面壳系统分享 URL 边界:Tauri `share.open` 写入系统剪贴板前同样只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。桌面壳配置检查会拒绝移除同源分享 URL 归一和协议相对 URL 拦截。
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities``openHostShare``writeHostClipboardText``requestHostHapticsImpact``setHostAppTitle``exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。