统一移动下载协议阻断清单
将移动 WebView 禁止下载协议提升到共享 HostBridge 契约 让移动壳导航拦截和注入脚本复用共享协议清单 增加移动壳门禁和测试防止协议清单回退到本地字面量 补充宿主壳方案文档和共享决策记录
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user