diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c1da83299f..4976cfcfb2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -139,7 +139,12 @@ pub fn run() { #[cfg(target_os = "macos")] let builder = builder.plugin( tauri::plugin::Builder::<_, ()>::new("navigation-policy") - .on_navigation(|_, url| project_canvas_package::allow_webview_navigation(url)) + .on_navigation(|webview, url| { + project_canvas_package::allow_webview_navigation( + url, + webview.config().build.dev_url.as_ref(), + ) + }) .build(), ); let builder = builder diff --git a/desktop/src-tauri/src/project_canvas_package/mod.rs b/desktop/src-tauri/src/project_canvas_package/mod.rs index 57044230d2..10f11230af 100644 --- a/desktop/src-tauri/src/project_canvas_package/mod.rs +++ b/desktop/src-tauri/src/project_canvas_package/mod.rs @@ -532,13 +532,22 @@ fn validate_widget_in_data(data: &serde_json::Value, widget_id: &str) -> Result< } } -pub(crate) fn allow_webview_navigation(url: &tauri::Url) -> bool { +/// Decides whether the main webview may commit a top-level navigation. +/// +/// `dev_url` is the dev server origin the app was actually built against +/// (`webview.config().build.dev_url`). Every `just` desktop recipe derives a +/// per-worktree Vite port via `scripts/instance-env.sh`, so the origin cannot +/// be hardcoded — and because the dev server load *is* the initial navigation, +/// cancelling it leaves a blank window rather than a blocked link. +pub(crate) fn allow_webview_navigation(url: &tauri::Url, dev_url: Option<&tauri::Url>) -> bool { match url.scheme() { "about" => url.as_str() == "about:blank", "buzz-canvas" => url.host_str() == Some("localhost"), "tauri" => url.host_str() == Some("localhost"), + // Debug builds serve the frontend from a dev server; release builds + // have none, so plain http stays blocked there. "http" if cfg!(debug_assertions) => { - url.host_str() == Some("localhost") && url.port() == Some(1420) + dev_url.is_some_and(|dev_url| dev_url.origin() == url.origin()) } _ => false, } diff --git a/desktop/src-tauri/src/project_canvas_package/tests.rs b/desktop/src-tauri/src/project_canvas_package/tests.rs index 8a8c84b1a7..bbbdd723cf 100644 --- a/desktop/src-tauri/src/project_canvas_package/tests.rs +++ b/desktop/src-tauri/src/project_canvas_package/tests.rs @@ -640,19 +640,62 @@ fn protocol_security_policy_has_no_network_or_tauri_ipc_source() { #[test] fn native_navigation_policy_blocks_external_document_navigation() { assert!(super::allow_webview_navigation( - &"buzz-canvas://localhost/load/".parse().unwrap() + &"buzz-canvas://localhost/load/".parse().unwrap(), + None )); assert!(super::allow_webview_navigation( - &"tauri://localhost/".parse().unwrap() + &"tauri://localhost/".parse().unwrap(), + None )); assert!(super::allow_webview_navigation( - &"about:blank".parse().unwrap() + &"about:blank".parse().unwrap(), + None )); assert!(!super::allow_webview_navigation( - &"https://example.com/leak?snapshot=secret".parse().unwrap() + &"https://example.com/leak?snapshot=secret".parse().unwrap(), + None )); assert!(!super::allow_webview_navigation( - &"file:///tmp/secret".parse().unwrap() + &"file:///tmp/secret".parse().unwrap(), + None + )); +} + +// The dev server load is the webview's *initial* navigation, so a policy that +// does not recognise the configured origin opens a blank window. Every `just` +// desktop recipe derives a per-worktree Vite port, so the origin the app is +// launched on is never the `tauri.conf.json` default. +#[test] +fn native_navigation_policy_allows_the_configured_dev_server() { + let dev_url: tauri::Url = "http://localhost:30164".parse().unwrap(); + + assert!(super::allow_webview_navigation( + &"http://localhost:30164/".parse().unwrap(), + Some(&dev_url) + )); + assert!(super::allow_webview_navigation( + &"http://localhost:30164/index.html".parse().unwrap(), + Some(&dev_url) + )); +} + +#[test] +fn native_navigation_policy_blocks_other_localhost_origins() { + let dev_url: tauri::Url = "http://localhost:30164".parse().unwrap(); + + // Some other server on the loopback interface is not the frontend. + assert!(!super::allow_webview_navigation( + &"http://localhost:1420/".parse().unwrap(), + Some(&dev_url) + )); + assert!(!super::allow_webview_navigation( + &"http://127.0.0.1:30164/".parse().unwrap(), + Some(&dev_url) + )); + // Release builds have no dev server, so plain http stays blocked. + assert!(!super::allow_webview_navigation( + &"http://localhost:30164/".parse().unwrap(), + None )); }