diff --git a/.changes/driver-arg-eq-separator.md b/.changes/driver-arg-eq-separator.md new file mode 100644 index 000000000000..98b985a1d633 --- /dev/null +++ b/.changes/driver-arg-eq-separator.md @@ -0,0 +1,5 @@ +--- +"tauri-driver": patch +--- + +Support `eq-separator` for `tauri-driver`. diff --git a/.changes/enforce-acl-remote-origins.md b/.changes/enforce-acl-remote-origins.md new file mode 100644 index 000000000000..eff8fab21a24 --- /dev/null +++ b/.changes/enforce-acl-remote-origins.md @@ -0,0 +1,5 @@ +--- +'tauri': 'patch:sec' +--- + +Enforce ACL checks for IPC requests from remote origins even when no `AppManifest` is configured. Previously, custom (non-plugin) commands bypassed ACL entirely without an `AppManifest`, allowing any origin to invoke them. Now, remote origins are always subject to ACL resolution, and can only reach custom commands if an explicit `remote` capability has been granted. diff --git a/.changes/expose-mobile-monitor-apis.md b/.changes/expose-mobile-monitor-apis.md new file mode 100644 index 000000000000..76ba0b68e184 --- /dev/null +++ b/.changes/expose-mobile-monitor-apis.md @@ -0,0 +1,5 @@ +--- +"tauri": patch:enhance +--- + +Expose the monitor (display) APIs on mobile. diff --git a/.changes/fix-request-permission-android.md b/.changes/fix-request-permission-android.md new file mode 100644 index 000000000000..a9a55642548a --- /dev/null +++ b/.changes/fix-request-permission-android.md @@ -0,0 +1,5 @@ +--- +"tauri": patch:bug +--- + +Fix crash when using the requestPermission API on Android. diff --git a/.changes/tauri-sec-localhost-suffix.md b/.changes/tauri-sec-localhost-suffix.md new file mode 100644 index 000000000000..c86f36352ae2 --- /dev/null +++ b/.changes/tauri-sec-localhost-suffix.md @@ -0,0 +1,6 @@ +--- +tauri: patch:sec +--- + +Correctly handle .localhost suffix in local origins on Windows and Android to fix a security issue that made tauri think remote websites that started with a registered scheme were local websites. +For example, when registering an `app` custom protocol, Tauri would think `http://app.evil.com/` would be a local URL on Windows/Android. diff --git a/crates/tauri-driver/Cargo.toml b/crates/tauri-driver/Cargo.toml index c25b57feeac0..fbaacf841692 100644 --- a/crates/tauri-driver/Cargo.toml +++ b/crates/tauri-driver/Cargo.toml @@ -24,7 +24,7 @@ hyper-util = { version = "0.1", features = [ "server", "tokio", ] } -pico-args = "0.5" +pico-args = { version = "0.5", features = ["eq-separator"] } serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros"] } diff --git a/crates/tauri/mobile/android-codegen/TauriActivity.kt b/crates/tauri/mobile/android-codegen/TauriActivity.kt index d4c2cbaad95f..251c41e3598e 100644 --- a/crates/tauri/mobile/android-codegen/TauriActivity.kt +++ b/crates/tauri/mobile/android-codegen/TauriActivity.kt @@ -8,6 +8,7 @@ package {{package}} import android.content.Intent import android.content.res.Configuration +import android.os.Bundle import app.tauri.plugin.PluginManager import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -33,6 +34,11 @@ object TauriLifecycleObserver : DefaultLifecycleObserver { abstract class TauriActivity : WryActivity() { override val handleBackNavigation: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + PluginManager.onActivityCreate(this) + } + fun getPluginManager(): PluginManager { return PluginManager } diff --git a/crates/tauri/src/test/mod.rs b/crates/tauri/src/test/mod.rs index 9161f097951d..a1428c390127 100644 --- a/crates/tauri/src/test/mod.rs +++ b/crates/tauri/src/test/mod.rs @@ -216,7 +216,11 @@ pub fn mock_app() -> App { /// cmd: "ping".into(), /// callback: tauri::ipc::CallbackFn(0), /// error: tauri::ipc::CallbackFn(1), -/// url: "http://tauri.localhost".parse().unwrap(), +/// url: if cfg!(any(windows, target_os = "android")) { +/// "http://tauri.localhost" +/// } else { +/// "tauri://localhost" +/// }.parse().unwrap(), /// body: tauri::ipc::InvokeBody::default(), /// headers: Default::default(), /// invoke_key: tauri::test::INVOKE_KEY.to_string(), @@ -275,7 +279,11 @@ pub fn assert_ipc_response< /// cmd: "ping".into(), /// callback: tauri::ipc::CallbackFn(0), /// error: tauri::ipc::CallbackFn(1), -/// url: "http://tauri.localhost".parse().unwrap(), +/// url: if cfg!(any(windows, target_os = "android")) { +/// "http://tauri.localhost" +/// } else { +/// "tauri://localhost" +/// }.parse().unwrap(), /// body: tauri::ipc::InvokeBody::default(), /// headers: Default::default(), /// invoke_key: tauri::test::INVOKE_KEY.to_string(), diff --git a/crates/tauri/src/webview/mod.rs b/crates/tauri/src/webview/mod.rs index c30b8f040563..b89d3c95d0e8 100644 --- a/crates/tauri/src/webview/mod.rs +++ b/crates/tauri/src/webview/mod.rs @@ -1724,14 +1724,14 @@ tauri::Builder::default() // so we check using the first part of the domain #[cfg(any(windows, target_os = "android"))] let local = { - let protocol_url = self.manager().tauri_protocol_url(uses_https); - let maybe_protocol = current_url + let scheme = scheme == self.manager().tauri_protocol_url(uses_https).scheme(); + let protocol = current_url .domain() - .and_then(|d| d .split_once('.')) - .unwrap_or_default() - .0; + .and_then(|d| d.strip_suffix(".localhost")) + .map(|protocol| protocols.contains_key(protocol)) + .unwrap_or_default(); - protocols.contains_key(maybe_protocol) && scheme == protocol_url.scheme() + scheme && protocol }; local @@ -1816,8 +1816,11 @@ tauri::Builder::default() (plugin, command) }); - // we only check ACL on plugin commands or if the app defined its ACL manifest - if (plugin_command.is_some() || has_app_acl_manifest) + // Check ACL on plugin commands, when the app defined its ACL manifest, + // or when the request comes from a non-local (remote) origin. This + // ensures remote content can never reach custom commands unless an + // explicit `remote` capability has been configured for them. + if (plugin_command.is_some() || has_app_acl_manifest || !is_local) // TODO: Remove this special check in v3 && request.cmd != crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND && invoke.acl.is_none() @@ -2351,25 +2354,132 @@ impl ResolvedScope { #[cfg(test)] mod tests { + use url::Url; + + fn test_webview_window() -> crate::WebviewWindow { + use crate::test::{mock_builder, mock_context, noop_assets}; + + // Create a mock app with proper context + let app = mock_builder().build(mock_context(noop_assets())).unwrap(); + + // Create a webview window + crate::WebviewWindowBuilder::new(&app, "test", crate::WebviewUrl::default()) + .build() + .unwrap() + } + #[test] fn webview_is_send_sync() { crate::test_utils::assert_send::(); crate::test_utils::assert_sync::(); } - #[cfg(target_os = "macos")] #[test] - fn test_webview_window_has_set_simple_fullscreen_method() { + fn tauri_protocol_is_local() { + let webview = test_webview_window().webview; + + #[cfg(all(not(windows), not(target_os = "android")))] + assert!(webview.is_local_url(&Url::parse("tauri://localhost/").unwrap())); + + #[cfg(any(windows, target_os = "android"))] + assert!(webview.is_local_url(&Url::parse("https://tauri.localhost/").unwrap())); + } + + // On Windows/Android, custom protocols are served as `https://.localhost/`. + // We ensure only `.localhost` domains are accepted to prevent a subdomain being able to + // impersonate a protocol name. + #[cfg(any(windows, target_os = "android"))] + #[test] + fn windows_custom_protocol_rejects_spoofed_domain() { use crate::test::{mock_builder, mock_context, noop_assets}; - // Create a mock app with proper context + let app = mock_builder() + .register_uri_scheme_protocol("myproto", |_, _| { + http::Response::builder().body(Vec::new()).unwrap() + }) + .build(mock_context(noop_assets())) + .unwrap(); + let webview = crate::WebviewWindowBuilder::new(&app, "test", crate::WebviewUrl::default()) + .build() + .unwrap() + .webview; + + let url = |s| Url::parse(s).unwrap(); + + // Legitimate Windows custom protocol URL + assert!(webview.is_local_url(&url("https://myproto.localhost/"))); + + // Attacker domain that starts with a registered protocol name — must NOT be local. + assert!(!webview.is_local_url(&url("https://myproto.evil.com/"))); + + // Subdomain of .localhost with unregistered name — must NOT be local + assert!(!webview.is_local_url(&url("https://notregistered.localhost/"))); + } + + /// Custom (non-plugin) commands must be rejected when the IPC request + /// originates from a remote URL, even when no `AppManifest` has been + /// configured. Only local (bundled) origins should be able to reach + /// custom commands. + #[test] + fn remote_origin_blocked_for_custom_commands_without_app_manifest() { + use crate::test::{mock_builder, mock_context, noop_assets, INVOKE_KEY}; + use crate::webview::InvokeRequest; + let app = mock_builder().build(mock_context(noop_assets())).unwrap(); - // Get or create a webview window - let webview_window = - crate::WebviewWindowBuilder::new(&app, "test", crate::WebviewUrl::default()) - .build() - .unwrap(); + let webview = crate::WebviewWindowBuilder::new(&app, "main", Default::default()) + .build() + .unwrap(); + + // Request from a remote origin for a custom (non-plugin) command + // - should be rejected even without an AppManifest. + let remote_result = crate::test::get_ipc_response( + &webview, + InvokeRequest { + cmd: "any_custom_command".into(), + callback: crate::ipc::CallbackFn(0), + error: crate::ipc::CallbackFn(1), + url: "https://evil.com".parse().unwrap(), + body: crate::ipc::InvokeBody::default(), + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ); + assert!( + remote_result.is_err(), + "custom command should be rejected from a remote origin" + ); + + // Same command from the local origin - should NOT be rejected by the + // remote-origin guard (it may still fail because the command doesn't + // exist, but the error message will be different). + let local_result = crate::test::get_ipc_response( + &webview, + InvokeRequest { + cmd: "any_custom_command".into(), + callback: crate::ipc::CallbackFn(0), + error: crate::ipc::CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: crate::ipc::InvokeBody::default(), + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ); + // The local request should either succeed or fail for a reason OTHER + // than "not allowed from remote context". + if let Err(e) = &local_result { + let msg = e.to_string(); + assert!( + !msg.contains("not allowed from remote context"), + "local origin should not be blocked by the remote-origin guard, got: {msg}" + ); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn test_webview_window_has_set_simple_fullscreen_method() { + let webview_window = test_webview_window(); // This should compile if set_simple_fullscreen exists let result = webview_window.set_simple_fullscreen(true); diff --git a/crates/tauri/src/window/plugin.rs b/crates/tauri/src/window/plugin.rs index 4fba9e72e9cd..01a95f034767 100644 --- a/crates/tauri/src/window/plugin.rs +++ b/crates/tauri/src/window/plugin.rs @@ -54,8 +54,8 @@ mod commands { use super::*; use crate::{ command, sealed::ManagerBase, utils::config::WindowConfig, window::Color, - window::WindowBuilder, AppHandle, PhysicalPosition, PhysicalSize, Position, Size, Theme, - Window, + window::WindowBuilder, AppHandle, Monitor, PhysicalPosition, PhysicalSize, Position, Size, + Theme, Window, }; #[command(root = "crate")] @@ -102,6 +102,21 @@ mod commands { setter!(set_size_constraints, WindowSizeConstraints); setter!(set_theme, Option); setter!(set_enabled, bool); + + getter!(current_monitor, Option); + getter!(primary_monitor, Option); + getter!(available_monitors, Vec); + + #[command(root = "crate")] + pub async fn monitor_from_point( + window: Window, + label: Option, + x: f64, + y: f64, + ) -> crate::Result> { + let window = get_window(window, label)?; + window.monitor_from_point(x, y) + } } #[cfg(desktop)] @@ -112,7 +127,7 @@ mod desktop_commands { use super::*; use crate::{ command, utils::config::WindowEffectsConfig, window::ProgressBarState, CursorIcon, Manager, - Monitor, PhysicalPosition, Position, UserAttentionType, Webview, + PhysicalPosition, Position, UserAttentionType, Webview, }; getter!(is_fullscreen, bool); @@ -122,9 +137,6 @@ mod desktop_commands { getter!(is_maximizable, bool); getter!(is_minimizable, bool); getter!(is_closable, bool); - getter!(current_monitor, Option); - getter!(primary_monitor, Option); - getter!(available_monitors, Vec); getter!(cursor_position, PhysicalPosition); getter!(is_always_on_top, bool); @@ -217,17 +229,6 @@ mod desktop_commands { } Ok(()) } - - #[command(root = "crate")] - pub async fn monitor_from_point( - window: Window, - label: Option, - x: f64, - y: f64, - ) -> crate::Result> { - let window = get_window(window, label)?; - window.monitor_from_point(x, y) - } } /// Initializes the plugin. @@ -291,6 +292,10 @@ pub fn init() -> TauriPlugin { commands::set_enabled, commands::set_background_color, commands::set_theme, + commands::current_monitor, + commands::primary_monitor, + commands::monitor_from_point, + commands::available_monitors, #[cfg(desktop)] desktop_commands::is_fullscreen, #[cfg(desktop)] desktop_commands::is_minimized, @@ -299,10 +304,7 @@ pub fn init() -> TauriPlugin { #[cfg(desktop)] desktop_commands::is_maximizable, #[cfg(desktop)] desktop_commands::is_minimizable, #[cfg(desktop)] desktop_commands::is_closable, - #[cfg(desktop)] desktop_commands::current_monitor, - #[cfg(desktop)] desktop_commands::primary_monitor, - #[cfg(desktop)] desktop_commands::monitor_from_point, - #[cfg(desktop)] desktop_commands::available_monitors, + #[cfg(desktop)] desktop_commands::cursor_position, #[cfg(desktop)] desktop_commands::is_always_on_top, // setters