收紧桌面壳顶层导航边界
桌面主窗口只允许打包资产和同源 H5 留壳 桌面外链与新窗口请求交给系统 opener 后拒绝留壳 同步宿主壳方案和共享决策记录
This commit is contained in:
@@ -524,6 +524,12 @@ const requiredMainSnippets = [
|
||||
'desktop_entry_url_with_platform',
|
||||
'desktop_window_config_with_runtime_platform',
|
||||
'desktop_window_config_with_runtime_platform(config)',
|
||||
'should_allow_desktop_webview_navigation',
|
||||
'desktop_external_navigation_url',
|
||||
'open_desktop_external_navigation',
|
||||
'.on_navigation(move |url|',
|
||||
'.on_new_window(move |url, _features|',
|
||||
'NewWindowResponse::Deny',
|
||||
];
|
||||
|
||||
assertSameList(
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::menu::{Menu, MenuItem};
|
||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||
use tauri::webview::NewWindowResponse;
|
||||
use tauri::DragDropEvent;
|
||||
use tauri::Manager;
|
||||
use tauri::Theme;
|
||||
@@ -283,6 +284,48 @@ fn normalize_external_url(raw_url: &str) -> Option<String> {
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
fn is_desktop_packaged_asset_url(url: &Url) -> bool {
|
||||
if url.scheme() == "tauri" {
|
||||
return true;
|
||||
}
|
||||
|
||||
url.scheme() == "https"
|
||||
&& url
|
||||
.host_str()
|
||||
.map(|host| host.ends_with(".localhost"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn should_allow_desktop_webview_navigation(url: &Url) -> bool {
|
||||
if is_desktop_packaged_asset_url(url) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if url.scheme() == "https" {
|
||||
let base_url = Url::parse(WEB_APP_ORIGIN).ok();
|
||||
return base_url
|
||||
.map(|base_url| url.origin() == base_url.origin())
|
||||
.unwrap_or(false);
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn desktop_external_navigation_url(url: &Url) -> Option<String> {
|
||||
if should_allow_desktop_webview_navigation(url) {
|
||||
return None;
|
||||
}
|
||||
|
||||
normalize_external_url(url.as_str())
|
||||
}
|
||||
|
||||
fn open_desktop_external_navigation(app: &tauri::AppHandle, url: &Url) {
|
||||
let Some(external_url) = desktop_external_navigation_url(url) else {
|
||||
return;
|
||||
};
|
||||
let _ = app.opener().open_url(external_url, None::<&str>);
|
||||
}
|
||||
|
||||
fn normalize_native_page_url(raw_url: &str) -> Option<Url> {
|
||||
let url = raw_url.trim();
|
||||
if url.is_empty() || url.chars().any(char::is_control) {
|
||||
@@ -1578,8 +1621,22 @@ fn main() {
|
||||
let window_config = app.config().app.windows.get(0).cloned();
|
||||
if let Some(config) = window_config {
|
||||
let config = desktop_window_config_with_runtime_platform(config);
|
||||
let window =
|
||||
tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?.build()?;
|
||||
let app_handle = app.handle().clone();
|
||||
let new_window_app_handle = app.handle().clone();
|
||||
let window = tauri::WebviewWindowBuilder::from_config(app.handle(), &config)?
|
||||
.on_navigation(move |url| {
|
||||
if should_allow_desktop_webview_navigation(url) {
|
||||
true
|
||||
} else {
|
||||
open_desktop_external_navigation(&app_handle, url);
|
||||
false
|
||||
}
|
||||
})
|
||||
.on_new_window(move |url, _features| {
|
||||
open_desktop_external_navigation(&new_window_app_handle, &url);
|
||||
NewWindowResponse::Deny
|
||||
})
|
||||
.build()?;
|
||||
register_desktop_window_close_events(&window, tray_registered);
|
||||
register_desktop_lifecycle_events(&window);
|
||||
let _ = emit_desktop_lifecycle_event(&window, "active", true, "created");
|
||||
@@ -1924,6 +1981,50 @@ mod tests {
|
||||
assert_eq!(normalize_external_url("/relative/path"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_webview_navigation_stays_on_packaged_or_same_origin_pages() {
|
||||
let packaged_url = Url::parse("tauri://localhost/index.html").expect("packaged url");
|
||||
assert!(should_allow_desktop_webview_navigation(&packaged_url));
|
||||
assert_eq!(desktop_external_navigation_url(&packaged_url), None);
|
||||
|
||||
let windows_packaged_url =
|
||||
Url::parse("https://tauri.localhost/index.html").expect("windows packaged url");
|
||||
assert!(should_allow_desktop_webview_navigation(
|
||||
&windows_packaged_url
|
||||
));
|
||||
assert_eq!(desktop_external_navigation_url(&windows_packaged_url), None);
|
||||
|
||||
let same_origin_url = Url::parse("https://app.genarrative.world/works/detail?work=PZ-1")
|
||||
.expect("same-origin url");
|
||||
assert!(should_allow_desktop_webview_navigation(&same_origin_url));
|
||||
assert_eq!(desktop_external_navigation_url(&same_origin_url), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_webview_navigation_sends_external_urls_to_system_only() {
|
||||
let external_url = Url::parse("https://example.com/path").expect("external url");
|
||||
assert!(!should_allow_desktop_webview_navigation(&external_url));
|
||||
assert_eq!(
|
||||
desktop_external_navigation_url(&external_url),
|
||||
Some("https://example.com/path".to_string())
|
||||
);
|
||||
|
||||
let mail_url = Url::parse("mailto:hi@example.com").expect("mail url");
|
||||
assert!(!should_allow_desktop_webview_navigation(&mail_url));
|
||||
assert_eq!(
|
||||
desktop_external_navigation_url(&mail_url),
|
||||
Some("mailto:hi@example.com".to_string())
|
||||
);
|
||||
|
||||
let unsafe_url = Url::parse("javascript:alert(1)").expect("unsafe url");
|
||||
assert!(!should_allow_desktop_webview_navigation(&unsafe_url));
|
||||
assert_eq!(desktop_external_navigation_url(&unsafe_url), None);
|
||||
|
||||
let file_url = Url::parse("file:///etc/passwd").expect("file url");
|
||||
assert!(!should_allow_desktop_webview_navigation(&file_url));
|
||||
assert_eq!(desktop_external_navigation_url(&file_url), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_page_url_normalization_allows_same_origin_routes() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
- 2026-06-18 Tauri 单实例:桌面壳启用 `tauri-plugin-single-instance` 并要求该插件最先注册;重复启动 App 时第二实例退出,只唤醒、取消最小化并聚焦已有主窗口,不把第二实例 argv / cwd / 深链内容作为事件透传给 H5。桌面深链后续如需接入,必须先定义受控 URL 归一和宿主边界,不能借单实例回调直接开放任意启动参数。
|
||||
- 2026-06-18 桌面壳安装包身份:Tauri 桌面壳的产品名固定为 `Genarrative`,应用 identifier 固定为 `world.genarrative.desktop`,Tauri 配置、`apps/desktop-shell/package.json` 与 Cargo package 版本统一为 `0.1.0`;Release 主窗口只加载打包的 `index.html` 和根 `dist` H5 资产,dev URL 只指向本机 Vite 调试入口。桌面壳 CSP 保持 `script-src 'self'`,不得加入 `unsafe-eval`、`tauri:` 或 `file:`,也不得在没有真实端点、签名密钥和发布流程前配置 updater;检查脚本会拒绝包身份、版本、CSP 或 updater 约束漂移。
|
||||
- 2026-06-18 桌面壳运行时平台 query:Tauri 静态配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值;Rust `setup` 手动创建主窗口前必须把入口 URL 改写为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致。第二实例参数、外部 deep link 或 H5 自报值不得覆盖该字段;桌面壳测试和配置检查会拒绝绕过该归一流程。
|
||||
- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。
|
||||
- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。
|
||||
- 2026-06-18 桌面壳 CSP 分层:Tauri release `csp` 不得包含 `http://127.0.0.1:*`、`ws://127.0.0.1:*` 或其它本机调试源,本机 Vite、HMR WebSocket 和开发 frame 只允许出现在 `devCsp`。桌面壳配置检查会同时拒绝 release CSP 混入本机调试源、dev CSP 缺失本机开发源,以及 release / dev CSP 加入 `unsafe-eval`、`tauri:` 或 `file:`。
|
||||
- 2026-06-18 壳生产代码禁用临时替身:Expo 与 Tauri 壳的生产源码和配置不得出现 mock / fake / placeholder / stub / TODO / FIXME 以及对应中文脚手架词;测试文件仍可使用 mock。两端壳配置检查会扫描生产入口、配置和壳实现,防止把临时替身、占位文案或伪实现带进可分发壳。
|
||||
|
||||
@@ -316,6 +316,8 @@ GameBridge 禁止:
|
||||
|
||||
2026-06-18 追加:桌面壳静态 Tauri 配置中的 `hostPlatform=unknown` 只作为跨平台构建模板值。Rust `setup` 手动创建主窗口前会把入口 URL 归一为当前 `macos` / `windows` / `linux`,保证 H5 首屏 query 与 `host.getRuntime` 回读的平台一致;不通过第二实例参数、外部 deep link 或 H5 自报值覆盖该平台字段。
|
||||
|
||||
2026-06-18 追加:桌面壳主 WebView 增加顶层导航边界。打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 可以继续留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航和 `window.open` 请求只交给系统 opener 后阻止留壳;`javascript:`、`file:` 等危险协议直接阻断。该规则不新增 HostBridge capability,也不开放 opener 插件 JS guest API,避免外域页面停留在带 `host_bridge_request` 权限的主 WebView 内。
|
||||
|
||||
2026-06-18 追加:桌面壳命令暴露面收紧为唯一受控入口。Tauri `build.rs` manifest、Rust `generate_handler!`、主窗口 capability 权限列表和本地自动生成权限目录都只能出现 `host_bridge_request`;如果本地构建残留了其它 command 的自动生成权限文件,`apps/desktop-shell/scripts/check-config.mjs` 会直接失败。后续接入新的桌面系统能力时仍先扩展 HostBridge method 和 Rust 内部分发,不新增可被 H5 直接 invoke 的 Tauri command,也不把插件 JS guest API 授权给主窗口。
|
||||
|
||||
2026-06-18 追加:桌面壳 release CSP 与 dev CSP 分离。Release `csp` 不再包含 `http://127.0.0.1:*` 或 `ws://127.0.0.1:*`,只允许打包资产、自身脚本、生产 HTTPS / WSS API、图片、媒体和 sandbox frame 所需来源;本地 Vite、HMR WebSocket 和开发 frame 只写入 Tauri `devCsp`。`apps/desktop-shell/scripts/check-config.mjs` 会拒绝 release CSP 混入本机调试源,也会校验 dev CSP 仍保留本机开发源。
|
||||
|
||||
Reference in New Issue
Block a user