Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changes/driver-arg-eq-separator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tauri-driver": patch
---

Support `eq-separator` for `tauri-driver`.
5 changes: 5 additions & 0 deletions .changes/enforce-acl-remote-origins.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changes/expose-mobile-monitor-apis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tauri": patch:enhance
---

Expose the monitor (display) APIs on mobile.
5 changes: 5 additions & 0 deletions .changes/fix-request-permission-android.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"tauri": patch:bug
---

Fix crash when using the requestPermission API on Android.
6 changes: 6 additions & 0 deletions .changes/tauri-sec-localhost-suffix.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion crates/tauri-driver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
6 changes: 6 additions & 0 deletions crates/tauri/mobile/android-codegen/TauriActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
12 changes: 10 additions & 2 deletions crates/tauri/src/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,11 @@ pub fn mock_app() -> App<MockRuntime> {
/// 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(),
Expand Down Expand Up @@ -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(),
Expand Down
142 changes: 126 additions & 16 deletions crates/tauri/src/webview/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -2351,25 +2354,132 @@ impl<T: ScopeObject> ResolvedScope<T> {

#[cfg(test)]
mod tests {
use url::Url;

fn test_webview_window() -> crate::WebviewWindow<crate::test::MockRuntime> {
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::<super::Webview>();
crate::test_utils::assert_sync::<super::Webview>();
}

#[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://<name>.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);
Expand Down
44 changes: 23 additions & 21 deletions crates/tauri/src/window/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -102,6 +102,21 @@ mod commands {
setter!(set_size_constraints, WindowSizeConstraints);
setter!(set_theme, Option<Theme>);
setter!(set_enabled, bool);

getter!(current_monitor, Option<Monitor>);
getter!(primary_monitor, Option<Monitor>);
getter!(available_monitors, Vec<Monitor>);

#[command(root = "crate")]
pub async fn monitor_from_point<R: Runtime>(
window: Window<R>,
label: Option<String>,
x: f64,
y: f64,
) -> crate::Result<Option<Monitor>> {
let window = get_window(window, label)?;
window.monitor_from_point(x, y)
}
}

#[cfg(desktop)]
Expand All @@ -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);
Expand All @@ -122,9 +137,6 @@ mod desktop_commands {
getter!(is_maximizable, bool);
getter!(is_minimizable, bool);
getter!(is_closable, bool);
getter!(current_monitor, Option<Monitor>);
getter!(primary_monitor, Option<Monitor>);
getter!(available_monitors, Vec<Monitor>);
getter!(cursor_position, PhysicalPosition<f64>);
getter!(is_always_on_top, bool);

Expand Down Expand Up @@ -217,17 +229,6 @@ mod desktop_commands {
}
Ok(())
}

#[command(root = "crate")]
pub async fn monitor_from_point<R: Runtime>(
window: Window<R>,
label: Option<String>,
x: f64,
y: f64,
) -> crate::Result<Option<Monitor>> {
let window = get_window(window, label)?;
window.monitor_from_point(x, y)
}
}

/// Initializes the plugin.
Expand Down Expand Up @@ -291,6 +292,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
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,
Expand All @@ -299,10 +304,7 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
#[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
Expand Down
Loading