收紧移动外链打开边界
将 WebView 外链打开收口到可测试 helper 要求移动壳外链先通过 canOpenURL 再 openURL 同步移动壳外链打开边界记录
This commit is contained in:
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
|
||||
|
||||
Reference in New Issue
Block a user