移动壳阻断下载协议导航

补强 WebView 下载脚本和下载协议导航阻断

新增移动壳下载策略行为测试和配置门禁

同步宿主壳方案文档和团队决策记录
This commit is contained in:
2026-06-18 19:57:40 +08:00
parent ec36300d92
commit 686f52fcec
6 changed files with 286 additions and 22 deletions
+23 -1
View File
@@ -28,6 +28,8 @@ const hostBridgeSource = bridgeSourceFiles
.join('\n');
const urlPath = new URL('../src/shell/url.ts', import.meta.url);
const urlSource = fs.readFileSync(urlPath, 'utf8');
const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url);
const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8');
const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url);
const runtimeSource = fs.readFileSync(runtimePath, 'utf8');
const sharedContractPath = new URL(
@@ -733,6 +735,7 @@ for (const snippet of [
'origin: window.location.origin',
'source: window',
'BLOCK_WEBVIEW_DOWNLOAD_SCRIPT',
'shouldBlockMobileWebViewNavigationRequest',
'SafeAreaProvider',
'SafeAreaView',
'MOBILE_SHELL_SAFE_AREA_EDGES',
@@ -749,7 +752,8 @@ for (const snippet of [
'sharedCookiesEnabled={false}',
'webviewDebuggingEnabled={false}',
'injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}',
'onFileDownload={() => undefined}',
'handleBlockedFileDownload',
'onFileDownload={handleBlockedFileDownload}',
'setSupportMultipleWindows={false}',
]) {
if (!shellAppSource.includes(snippet)) {
@@ -757,6 +761,24 @@ for (const snippet of [
}
}
for (const snippet of [
'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS',
"'blob:'",
"'data:'",
"'file:'",
"'filesystem:'",
'shouldBlockMobileWebViewDownloadUrl',
'shouldBlockMobileWebViewNavigationRequest',
"target.closest('a')",
'event.stopImmediatePropagation()',
'window.open = function(url)',
'HTMLAnchorElement.prototype.click',
]) {
if (!webViewPolicySource.includes(snippet)) {
throw new Error(`mobile shell WebView policy missing ${snippet}`);
}
}
if (!appSource.includes("import ShellApp from './src/shell/ShellApp';")) {
throw new Error('mobile shell App must import the shell app facade');
}
+10 -2
View File
@@ -34,7 +34,10 @@ import {
buildMobileShellUrl,
resolveMobileShellBaseWebUrl,
} from './url';
import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './webViewPolicy';
import {
BLOCK_WEBVIEW_DOWNLOAD_SCRIPT,
shouldBlockMobileWebViewNavigationRequest,
} from './webViewPolicy';
function buildHostBridgeMessageScript(message: unknown) {
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(
@@ -175,6 +178,10 @@ export default function ShellApp() {
};
const handleShouldStartLoad = (request: { url: string }) => {
if (shouldBlockMobileWebViewNavigationRequest(request)) {
return false;
}
if (shouldOpenInMobileShellWebView(request.url, allowedWebOrigin)) {
return true;
}
@@ -201,6 +208,7 @@ export default function ShellApp() {
.then(injectNetworkStatusEvent)
.catch(() => undefined);
};
const handleBlockedFileDownload = () => undefined;
return (
<SafeAreaProvider>
@@ -224,7 +232,7 @@ export default function ShellApp() {
sharedCookiesEnabled={false}
webviewDebuggingEnabled={false}
injectedJavaScriptBeforeContentLoaded={BLOCK_WEBVIEW_DOWNLOAD_SCRIPT}
onFileDownload={() => undefined}
onFileDownload={handleBlockedFileDownload}
onMessage={handleMessage}
onContentProcessDidTerminate={reloadCurrentWebView}
onRenderProcessGone={reloadCurrentWebView}
+134 -10
View File
@@ -1,16 +1,140 @@
import { describe, expect, test } from 'vitest';
/* @vitest-environment jsdom */
import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT } from './webViewPolicy';
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
BLOCK_WEBVIEW_DOWNLOAD_SCRIPT,
shouldBlockMobileWebViewDownloadUrl,
shouldBlockMobileWebViewNavigationRequest,
} from './webViewPolicy';
describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => {
test('阻断 WebView 内的网页下载链接', () => {
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain(
"element.tagName === 'A'",
);
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain(
"element.hasAttribute('download')",
);
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain('event.preventDefault()');
const originalOpen = window.open;
const originalAnchorClick = HTMLAnchorElement.prototype.click;
beforeEach(() => {
document.body.innerHTML = '';
window.open = vi.fn(() => null) as typeof window.open;
HTMLAnchorElement.prototype.click = originalAnchorClick;
window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT);
});
afterEach(() => {
document.body.innerHTML = '';
window.open = originalOpen;
HTMLAnchorElement.prototype.click = originalAnchorClick;
vi.restoreAllMocks();
});
test('保留 WebView 注入脚本返回值', () => {
expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT.trim()).toMatch(/true;$/);
});
test('阻断嵌套元素触发的下载链接点击', () => {
document.body.innerHTML = `
<a href="/asset.png" download target="_blank">
<span id="nested-download-target">保存</span>
</a>
`;
const target = document.getElementById('nested-download-target');
expect(target).toBeTruthy();
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const allowed = target?.dispatchEvent(event);
expect(allowed).toBe(false);
expect(event.defaultPrevented).toBe(true);
});
test('放行普通同源页面链接点击', () => {
document.body.innerHTML = `
<a href="#section">
<span id="nested-page-target">打开</span>
</a>
`;
const target = document.getElementById('nested-page-target');
expect(target).toBeTruthy();
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
});
const allowed = target?.dispatchEvent(event);
expect(allowed).toBe(true);
expect(event.defaultPrevented).toBe(false);
});
test('阻断危险下载协议的窗口打开', () => {
const opened = window.open('blob:https://app.genarrative.world/file-id');
expect(opened).toBeNull();
});
test('阻断程序化下载链接点击', () => {
const originalClickSpy = vi.spyOn(
HTMLAnchorElement.prototype,
'click',
);
window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT);
const anchor = document.createElement('a');
anchor.href = 'data:text/plain;base64,SGVsbG8=';
anchor.download = 'hello.txt';
anchor.click();
expect(originalClickSpy).not.toHaveBeenCalled();
});
});
describe('shouldBlockMobileWebViewDownloadUrl', () => {
test('识别不能进入移动壳 WebView 的下载协议', () => {
expect(
shouldBlockMobileWebViewDownloadUrl(
'blob:https://app.genarrative.world/file-id',
),
).toBe(true);
expect(shouldBlockMobileWebViewDownloadUrl('data:text/plain,hello')).toBe(
true,
);
expect(shouldBlockMobileWebViewDownloadUrl('file:///tmp/export.png')).toBe(
true,
);
expect(
shouldBlockMobileWebViewDownloadUrl(
'filesystem:https://app.genarrative.world/temporary/export.png',
),
).toBe(true);
});
test('放行普通网页和系统外链协议', () => {
expect(
shouldBlockMobileWebViewDownloadUrl(
'https://app.genarrative.world/works/detail?work=PZ-1',
),
).toBe(false);
expect(shouldBlockMobileWebViewDownloadUrl('/creation/puzzle')).toBe(false);
expect(shouldBlockMobileWebViewDownloadUrl('mailto:hi@example.com')).toBe(
false,
);
expect(shouldBlockMobileWebViewDownloadUrl('tel:+12345678')).toBe(false);
});
});
describe('shouldBlockMobileWebViewNavigationRequest', () => {
test('在 WebView 导航前拦截下载协议', () => {
expect(
shouldBlockMobileWebViewNavigationRequest({
url: 'blob:https://app.genarrative.world/file-id',
}),
).toBe(true);
expect(
shouldBlockMobileWebViewNavigationRequest({
url: 'https://app.genarrative.world/creation/puzzle',
}),
).toBe(false);
});
});
+110 -7
View File
@@ -1,13 +1,116 @@
const MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS = new Set([
'blob:',
'data:',
'file:',
'filesystem:',
]);
export type MobileWebViewNavigationRequest = {
url?: string | null;
};
export function shouldBlockMobileWebViewDownloadUrl(
rawUrl: string | null | undefined,
) {
if (!rawUrl) {
return false;
}
try {
return MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS.has(
new URL(rawUrl, 'https://app.genarrative.world/').protocol,
);
} catch {
return false;
}
}
export function shouldBlockMobileWebViewNavigationRequest(
request: MobileWebViewNavigationRequest,
) {
return shouldBlockMobileWebViewDownloadUrl(request.url);
}
export const BLOCK_WEBVIEW_DOWNLOAD_SCRIPT = `
document.addEventListener('click', function(event) {
var element = event.target;
while (element && element !== document) {
if (element.tagName === 'A' && element.hasAttribute('download')) {
event.preventDefault();
(function() {
var blockedDownloadProtocols = {
'blob:': true,
'data:': true,
'file:': true,
'filesystem:': true
};
function shouldBlockDownloadUrl(rawUrl) {
if (typeof rawUrl !== 'string') {
return false;
}
try {
return blockedDownloadProtocols[new URL(rawUrl, window.location.href).protocol] === true;
} catch (error) {
return false;
}
element = element.parentNode;
}
}, true);
function findDownloadAnchor(target) {
if (!target) {
return null;
}
if (typeof target.closest === 'function') {
return target.closest('a');
}
var element = target;
while (element && element !== document) {
if (element.tagName === 'A') {
return element;
}
element = element.parentNode;
}
return null;
}
function shouldBlockAnchor(anchor) {
return Boolean(
anchor &&
(anchor.hasAttribute('download') || shouldBlockDownloadUrl(anchor.href))
);
}
function blockDownloadEvent(event) {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
return false;
}
document.addEventListener('click', function(event) {
if (shouldBlockAnchor(findDownloadAnchor(event.target))) {
return blockDownloadEvent(event);
}
}, true);
var originalOpen = window.open;
window.open = function(url) {
if (shouldBlockDownloadUrl(url)) {
return null;
}
return originalOpen.apply(window, arguments);
};
if (window.HTMLAnchorElement && HTMLAnchorElement.prototype.click) {
var originalAnchorClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function() {
if (shouldBlockAnchor(this)) {
return undefined;
}
return originalAnchorClick.apply(this, arguments);
};
}
})();
true;
`;
@@ -2506,3 +2506,10 @@
- 决策:新增 `apps/desktop-shell/src-tauri/src/shell/menu.rs` 注册 Tauri 应用菜单。应用菜单只复用宿主壳级显示主窗口、刷新主窗口和退出应用动作;编辑菜单和窗口菜单使用 Tauri 原生预定义项承接剪切、复制、粘贴、全选、最小化、最大化和关闭窗口。该能力不进入 HostBridge capability,不开放菜单 API、shell API 或任意窗口控制给 H5;菜单注册失败直接阻断启动,避免生产桌面壳缺少系统菜单仍静默运行。
- 影响范围:`apps/desktop-shell/src-tauri/src/main.rs``apps/desktop-shell/src-tauri/src/shell/menu.rs``apps/desktop-shell/src-tauri/src/shell/tray.rs``apps/desktop-shell/scripts/check-config.mjs``scripts/check-native-shells.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run desktop-shell:typecheck``npm run desktop-shell:test``npm run desktop-shell:build -- --no-bundle``npm run check:native-shells``npm run typecheck -- --pretty false``npm run check:encoding``git diff --check`
## 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。
- 影响范围:`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`
@@ -250,7 +250,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>` 直接落盘;移动端文件保存只能通过 `file.exportText``file.exportImage``file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。
- Expo 移动壳的通知能力只覆盖即时本地通知;Android 包配置必须阻断重启后通知恢复和精确定时权限,前端代码不得注册 Expo push token、远程推送监听或通知响应跳转流程。
- AI sandbox iframe 必须使用独立 CSP、`sandbox` 属性和单独 GameBridge allowlist。
@@ -416,7 +416,7 @@ GameBridge 禁止:
2026-06-18 追加:移动壳 WebView 原生安全开关显式收紧。`react-native-webview` 只加载同源主站入口,保留 JS 和 DOM storage 以运行现有 H5,但禁用 JS 自动开窗、多窗口、文件访问、file URL 跨源访问、HTTPS 页面加载 HTTP 混合内容、第三方 Cookie、共享 Cookie 和 WebView 远程调试;外链继续只允许 `http:``https:``mailto:``tel:` 离开 WebView 交给系统。`apps/mobile-shell/scripts/check-config.mjs``navigation.test.ts` 会覆盖这些壳边界,避免后续为单个页面调试把完整 HostBridge 暴露给外域页面。
2026-06-18 追加:移动壳 WebView 默认下载路径显式关闭。壳层在 WebView 注入脚本中阻断 `<a download>` 点击iOS `onFileDownload` 事件只丢弃不落盘,Android 包配置阻断外部存储读写、管理外部存储和请求安装包权限;H5 文本、图片、音频保存继续只能走 `file.exportText``file.exportImage``file.exportAudio` 的受控 HostBridge 导出能力。
2026-06-18 追加:移动壳 WebView 默认下载路径显式关闭。壳层在 WebView 注入脚本中阻断 `<a download>` 点击、危险下载协议链接、`window.open` 下载 URL 和程序化 anchor click`onShouldStartLoadWithRequest` 会在同源 / 外链分流前拒绝 `blob:``data:``file:``filesystem:` 导航,避免下载 URL 进入带完整 HostBridge 的 WebView 或交给系统外部应用;iOS `onFileDownload` 事件只丢弃不落盘,Android 包配置阻断外部存储读写、管理外部存储和请求安装包权限;H5 文本、图片、音频保存继续只能走 `file.exportText``file.exportImage``file.exportAudio` 的受控 HostBridge 导出能力。
2026-06-18 追加:移动壳 HostBridge 消息入口增加来源校验。`onMessage` 不只依赖导航拦截和 `originWhitelist`,还会读取 `event.nativeEvent.url`,只有同源主站页面才能进入 `handleMobileHostBridgeMessage``about:blank`、外域 URL、协议降级或危险协议页面发来的消息全部丢弃,不返回 HostBridge 错误细节。该校验与 `navigation.openNativePage` 共用同源规则,防止历史中间页或异常页面在带完整 HostBridge 的 WebView 中发起宿主能力请求。