diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index f798010fe..458915bb9 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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', + ); } diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 7f7160b80..554345ff1 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -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; }; diff --git a/apps/mobile-shell/src/shell/navigation.test.ts b/apps/mobile-shell/src/shell/navigation.test.ts index 335bfd6f8..c55222f0a 100644 --- a/apps/mobile-shell/src/shell/navigation.test.ts +++ b/apps/mobile-shell/src/shell/navigation.test.ts @@ -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'; diff --git a/apps/mobile-shell/src/shell/navigation.ts b/apps/mobile-shell/src/shell/navigation.ts index e6b769a9f..8b291f70d 100644 --- a/apps/mobile-shell/src/shell/navigation.ts +++ b/apps/mobile-shell/src/shell/navigation.ts @@ -1,5 +1,10 @@ import { normalizeHostBridgeExternalUrl } from '../../../../packages/shared/src/contracts/hostBridge'; +export type MobileShellExternalNavigator = { + canOpenURL: (url: string) => Promise; + openURL: (url: string) => Promise; +}; + 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, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 2aaacb857..4ad5254bb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。