移动壳阻断下载协议导航
补强 WebView 下载脚本和下载协议导航阻断 新增移动壳下载策略行为测试和配置门禁 同步宿主壳方案文档和团队决策记录
This commit is contained in:
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 中发起宿主能力请求。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user