统一移动下载协议阻断清单

将移动 WebView 禁止下载协议提升到共享 HostBridge 契约

让移动壳导航拦截和注入脚本复用共享协议清单

增加移动壳门禁和测试防止协议清单回退到本地字面量

补充宿主壳方案文档和共享决策记录
This commit is contained in:
2026-06-19 14:27:34 +08:00
parent c14d699ee0
commit b6b1e21211
8 changed files with 62 additions and 16 deletions
+15 -4
View File
@@ -570,6 +570,10 @@ const sharedEvents = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
const sharedBlockedDownloadProtocols = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
);
const sharedMobileBaseCapabilities = extractStringArrayExport(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
@@ -1083,13 +1087,11 @@ for (const snippet of [
for (const snippet of [
'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
'HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
'mobileWebViewBlockedDownloadProtocolMapScript',
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT',
'MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT',
"'blob:'",
"'data:'",
"'file:'",
"'filesystem:'",
'shouldBlockMobileWebViewDownloadUrl',
'shouldBlockMobileWebViewNavigationRequest',
"target.closest('a')",
@@ -1109,6 +1111,15 @@ for (const snippet of [
}
}
if (
webViewPolicySource.includes("['blob:'") ||
webViewPolicySource.includes("'filesystem:'")
) {
throw new Error(
'mobile shell WebView blocked download protocols must come from shared contract',
);
}
for (const snippet of [
'parseMobileWebViewHistoryStateMessage',
'genarrative.mobile.historyState',
@@ -2,6 +2,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
import {
BLOCK_WEBVIEW_DOWNLOAD_SCRIPT,
MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT,
@@ -34,6 +35,17 @@ describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => {
expect(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT.trim()).toMatch(/true;$/);
});
test('注入脚本复用共享下载协议阻断清单', () => {
for (const protocol of HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS) {
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain(
`${JSON.stringify(protocol)}: true`,
);
expect(shouldBlockMobileWebViewDownloadUrl(`${protocol}download-id`)).toBe(
true,
);
}
});
test('阻断嵌套元素触发的下载链接点击', () => {
document.body.innerHTML = `
<a download>
+11 -10
View File
@@ -1,11 +1,15 @@
import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
import { DEFAULT_MOBILE_SHELL_WEB_URL } from './url';
const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set([
'blob:',
'data:',
'file:',
'filesystem:',
]);
const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set<string>(
HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS,
);
function mobileWebViewBlockedDownloadProtocolMapScript() {
return HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS.map(
(protocol) => ` ${JSON.stringify(protocol)}: true`,
).join(',\n');
}
export type MobileWebViewNavigationRequest = {
url?: string | null;
@@ -36,10 +40,7 @@ export function shouldBlockMobileWebViewNavigationRequest(
export const BLOCK_WEBVIEW_DOWNLOAD_SCRIPT = `
(function() {
var blockedDownloadProtocols = {
'blob:': true,
'data:': true,
'file:': true,
'filesystem:': true
${mobileWebViewBlockedDownloadProtocolMapScript()}
};
function shouldBlockDownloadUrl(rawUrl) {
@@ -2639,7 +2639,7 @@
## 2026-06-18 移动壳 WebView 下载协议阻断
- 背景:移动壳已经通过 WebView 注入脚本阻断 `<a download>` 点击,并丢弃 iOS `onFileDownload` 事件;但 `blob:`、`data:`、`file:`、`filesystem:` 等下载协议导航仍可能在 `onShouldStartLoadWithRequest` 中进入普通同源 / 外链分流,脚本创建的下载链接也缺少行为级测试覆盖。
- 决策:`apps/mobile-shell/src/shell/webViewPolicy.ts` 统一承接移动壳下载策略,注入脚本阻断下载链接点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`ShellApp` 在同源 / 外链分流前调用 `shouldBlockMobileWebViewNavigationRequest(...)`,命中 `blob:`、`data:`、`file:` 或 `filesystem:` 直接拒绝,不进入带完整 HostBridge 的 WebView,也不交给系统外部应用。移动端文件保存仍只能走受控 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 是移动 WebView 禁止下载协议清单的唯一来源。`apps/mobile-shell/src/shell/webViewPolicy.ts` 统一承接移动壳下载策略,导航拦截和 WebView 注入脚本都必须复用该共享清单,阻断下载链接点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click;`ShellApp` 在同源 / 外链分流前调用 `shouldBlockMobileWebViewNavigationRequest(...)`,命中 `blob:`、`data:`、`file:` 或 `filesystem:` 直接拒绝,不进入带完整 HostBridge 的 WebView,也不交给系统外部应用。移动端文件保存仍只能走受控 `file.exportText`、`file.exportImage`、`file.exportAudio` HostBridge method。
- 影响范围:`apps/mobile-shell/src/shell/webViewPolicy.ts`、`apps/mobile-shell/src/shell/webViewPolicy.test.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run mobile-shell:test -- src/shell/webViewPolicy.test.ts`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。
@@ -271,7 +271,7 @@ GameBridge 禁止:
- Tauri 主 WebView 禁止默认下载落盘;桌面文件保存只能通过受控 HostBridge 导出能力进入系统保存对话框。
- Tauri 主 WebView 禁止默认打开 DevTools;不得通过配置或 Cargo feature 为分发壳启用浏览器检查器。
- RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。
- RN WebView 禁止网页自动下载、下载协议导航和 `<a download>` 直接落盘;移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。
- RN WebView 禁止网页自动下载、下载协议导航和 `<a download>` 直接落盘;禁止下载协议清单以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 为唯一来源,移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。
- Expo 移动壳的通知能力只覆盖即时本地通知;Android 包配置必须阻断重启后通知恢复和精确定时权限,前端代码不得注册 Expo push token、远程推送监听或通知响应跳转流程。
- AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。
@@ -47,6 +47,8 @@ Tauri 桌面壳启动时必须按 `label="main"` 解析 `tauri.conf.json` 主窗
宿主上下文 query 的字段名和值以 `packages/shared/src/contracts/hostBridge.ts` 为源。`HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY` 覆盖 H5 runtime 识别可读取的 `clientRuntime`、`clientType`、`miniProgramEnv`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;`HOST_BRIDGE_NATIVE_APP_QUERY_KEY`、`HOST_BRIDGE_NATIVE_APP_QUERY_KEYS` 与 `HOST_BRIDGE_NATIVE_APP_QUERY` 固定 Expo / Tauri 原生壳入口 query;`HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY` 固定微信 WebView 来源标记;`HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS` 固定 H5 页面内导航需要跨路径保留的宿主字段,必须同时覆盖微信小程序来源字段和原生壳 `hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion`、`hostCapabilities` 完整运行态字段。Expo 移动壳直接引用共享常量,Tauri Rust 和微信小程序 CommonJS 运行时镜像由 `npm run check:native-shells` 反查;H5 `getHostRuntime()` 和路由保留列表不得重新手写这些字段。
移动壳 WebView 下载协议阻断清单以 `packages/shared/src/contracts/hostBridge.ts` 的 `HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS` 为源。Expo 壳导航拦截和 WebView 注入脚本必须复用同一清单,命中后直接拒绝进入带完整 HostBridge 的 WebView;移动端文件保存只通过受控 `file.exportText`、`file.exportImage`、`file.exportAudio` 能力进入系统分享 / 保存面板。
## 首批能力
- `getHostRuntime()`:识别 `browser`、`wechat_mini_program`、`native_app`,并解析 `hostCapabilities` 能力声明;进入 `native_app` 后会通过真实 `host.getRuntime` 回读宿主 runtime 并缓存能力清单,未知能力会被丢弃。H5 业务只根据已声明或已回读的能力展示入口、发起宿主请求或走 fallback。
@@ -22,6 +22,7 @@ import {
HOST_BRIDGE_EVENTS,
HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES,
HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS,
HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS,
HOST_BRIDGE_RESPONSE_CACHE_MAX,
HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,
HOST_BRIDGE_SCANNER_TIMEOUT_MS,
@@ -53,6 +54,15 @@ describe('HostBridge shared contract helpers', () => {
expect(HOST_BRIDGE_TAURI_COMMAND).toBe('host_bridge_request');
});
test('固定移动壳 WebView 下载协议阻断清单', () => {
expect(HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS).toEqual([
'blob:',
'data:',
'file:',
'filesystem:',
]);
});
test('固定原生壳请求超时边界', () => {
expect(HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS).toBe(8000);
expect(HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS).toBe(60000);
@@ -411,6 +411,16 @@ export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [
export type HostBridgeExternalUrlProtocol =
(typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number];
export const HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = [
'blob:',
'data:',
'file:',
'filesystem:',
] as const;
export type HostBridgeMobileWebViewBlockedDownloadProtocol =
(typeof HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS)[number];
function hasHostBridgeControlCharacter(value: string) {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;