From 686f52fcec9b387a5992264f24d82d4271ba9381 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 19:57:40 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=A3=B3=E9=98=BB=E6=96=AD?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=8D=8F=E8=AE=AE=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补强 WebView 下载脚本和下载协议导航阻断 新增移动壳下载策略行为测试和配置门禁 同步宿主壳方案文档和团队决策记录 --- apps/mobile-shell/scripts/check-config.mjs | 24 ++- apps/mobile-shell/src/shell/ShellApp.tsx | 12 +- .../src/shell/webViewPolicy.test.ts | 144 ++++++++++++++++-- apps/mobile-shell/src/shell/webViewPolicy.ts | 117 +++++++++++++- .../shared-memory/decision-log.md | 7 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- 6 files changed, 286 insertions(+), 22 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index af8cac38b..abc1c0c53 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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'); } diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index e857a929a..67c5aa1fc 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -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 ( @@ -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} diff --git a/apps/mobile-shell/src/shell/webViewPolicy.test.ts b/apps/mobile-shell/src/shell/webViewPolicy.test.ts index 227056779..bc695e61e 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.test.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.test.ts @@ -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 = ` + + 保存 + + `; + 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 = ` + + 打开 + + `; + 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); + }); }); diff --git a/apps/mobile-shell/src/shell/webViewPolicy.ts b/apps/mobile-shell/src/shell/webViewPolicy.ts index de76082b5..f8f6ecf38 100644 --- a/apps/mobile-shell/src/shell/webViewPolicy.ts +++ b/apps/mobile-shell/src/shell/webViewPolicy.ts @@ -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; `; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 871c56ad8..d2b5a3043 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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 注入脚本阻断 `` 点击,并丢弃 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`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index b92b7a3c7..fc39cb77a 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -250,7 +250,7 @@ GameBridge 禁止: - Tauri 主 WebView 禁止默认下载落盘;桌面文件保存只能通过受控 HostBridge 导出能力进入系统保存对话框。 - Tauri 主 WebView 禁止默认打开 DevTools;不得通过配置或 Cargo feature 为分发壳启用浏览器检查器。 - RN WebView 禁止打开任意 URL 后仍保留完整 HostBridge;跳外链只允许 `http:`、`https:`、`mailto:`、`tel:`,并使用系统浏览器或降级能力,危险协议直接阻断。 -- RN WebView 禁止网页自动下载和 `` 直接落盘;移动端文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等受控 HostBridge method 进入系统分享 / 保存面板。 +- RN WebView 禁止网页自动下载、下载协议导航和 `` 直接落盘;移动端文件保存只能通过 `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 注入脚本中阻断 `` 点击,iOS `onFileDownload` 事件只丢弃不落盘,Android 包配置阻断外部存储读写、管理外部存储和请求安装包权限;H5 文本、图片、音频保存继续只能走 `file.exportText`、`file.exportImage`、`file.exportAudio` 的受控 HostBridge 导出能力。 +2026-06-18 追加:移动壳 WebView 默认下载路径显式关闭。壳层在 WebView 注入脚本中阻断 `` 点击、危险下载协议链接、`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 中发起宿主能力请求。