diff --git a/.changes/remote-urls.md b/.changes/remote-urls.md new file mode 100644 index 000000000..0a9b3b8aa --- /dev/null +++ b/.changes/remote-urls.md @@ -0,0 +1,6 @@ +--- +"tauri": patch +"tauri-utils": patch +--- + +Added configuration to specify remote URLs allowed to access the IPC. diff --git a/core/tauri-build/src/lib.rs b/core/tauri-build/src/lib.rs index af52c3033..c15c47c29 100644 --- a/core/tauri-build/src/lib.rs +++ b/core/tauri-build/src/lib.rs @@ -339,7 +339,7 @@ pub fn try_build(attributes: Attributes) -> Result<()> { if target_triple.contains("darwin") { if let Some(version) = &config.tauri.bundle.macos.minimum_system_version { - println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET={}", version); + println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET={version}"); } } diff --git a/core/tauri-build/src/static_vcruntime.rs b/core/tauri-build/src/static_vcruntime.rs index 10485ce02..0d95f9a6d 100644 --- a/core/tauri-build/src/static_vcruntime.rs +++ b/core/tauri-build/src/static_vcruntime.rs @@ -54,5 +54,5 @@ fn override_msvcrt_lib() { f.write_all(bytes).unwrap(); } // Add the output directory to the native library path. - println!("cargo:rustc-link-search=native={}", out_dir); + println!("cargo:rustc-link-search=native={out_dir}"); } diff --git a/core/tauri-config-schema/schema.json b/core/tauri-config-schema/schema.json index 6f7391afd..2fee51980 100644 --- a/core/tauri-config-schema/schema.json +++ b/core/tauri-config-schema/schema.json @@ -163,6 +163,7 @@ }, "security": { "dangerousDisableAssetCspModification": false, + "dangerousRemoteDomainIpcAccess": [], "freezePrototype": false }, "updater": { @@ -415,6 +416,7 @@ "description": "Security configuration.", "default": { "dangerousDisableAssetCspModification": false, + "dangerousRemoteDomainIpcAccess": [], "freezePrototype": false }, "allOf": [ @@ -2603,6 +2605,14 @@ "$ref": "#/definitions/DisabledCspModificationKind" } ] + }, + "dangerousRemoteDomainIpcAccess": { + "description": "Allow external domains to send command to Tauri.\n\nBy default, external domains do not have access to `window.__TAURI__`, which means they cannot communicate with the commands defined in Rust. This prevents attacks where an externally loaded malicious or compromised sites could start executing commands on the user's device.\n\nThis configuration allows a set of external domains to have access to the Tauri commands. When you configure a domain to be allowed to access the IPC, all subpaths are allowed. Subdomains are not allowed.\n\n**WARNING:** Only use this option if you either have internal checks against malicious external sites or you can trust the allowed external sites. You application might be vulnerable to dangerous Tauri command related attacks otherwise.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/RemoteDomainAccessScope" + } } }, "additionalProperties": false @@ -2655,6 +2665,48 @@ } ] }, + "RemoteDomainAccessScope": { + "description": "External command access definition.", + "type": "object", + "required": [ + "domain", + "windows" + ], + "properties": { + "scheme": { + "description": "The URL scheme to allow. By default, all schemas are allowed.", + "type": [ + "string", + "null" + ] + }, + "domain": { + "description": "The domain to allow.", + "type": "string" + }, + "windows": { + "description": "The list of window labels this scope applies to.", + "type": "array", + "items": { + "type": "string" + } + }, + "plugins": { + "description": "The list of plugins that are allowed in this scope.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "enableTauriAPI": { + "description": "Enables access to the Tauri API.", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, "UpdaterConfig": { "description": "The Updater configuration object.\n\nSee more: https://tauri.app/v1/api/config#updaterconfig", "type": "object", diff --git a/core/tauri-runtime-wry/src/lib.rs b/core/tauri-runtime-wry/src/lib.rs index bf42ec7e2..9b780b40b 100644 --- a/core/tauri-runtime-wry/src/lib.rs +++ b/core/tauri-runtime-wry/src/lib.rs @@ -214,6 +214,7 @@ impl Context { impl Context { fn create_webview(&self, pending: PendingWindow>) -> Result>> { let label = pending.label.clone(); + let current_url = pending.current_url.clone(); let menu_ids = pending.menu_ids.clone(); let js_event_listeners = pending.js_event_listeners.clone(); let context = self.clone(); @@ -235,6 +236,7 @@ impl Context { }; Ok(DetachedWindow { label, + current_url, dispatcher, menu_ids, js_event_listeners, @@ -1985,6 +1987,7 @@ impl Runtime for Wry { fn create_window(&self, pending: PendingWindow) -> Result> { let label = pending.label.clone(); + let current_url = pending.current_url.clone(); let menu_ids = pending.menu_ids.clone(); let js_event_listeners = pending.js_event_listeners.clone(); let window_id = rand::random(); @@ -2011,6 +2014,7 @@ impl Runtime for Wry { Ok(DetachedWindow { label, + current_url, dispatcher, menu_ids, js_event_listeners, @@ -3040,7 +3044,7 @@ fn create_webview( mut window_builder, ipc_handler, label, - url, + current_url, menu_ids, js_event_listeners, .. @@ -3089,7 +3093,7 @@ fn create_webview( } let mut webview_builder = WebViewBuilder::new(window) .map_err(|e| Error::CreateWebview(Box::new(e)))? - .with_url(&url) + .with_url(current_url.lock().unwrap().as_str()) .unwrap() // safe to unwrap because we validate the URL beforehand .with_transparent(is_window_transparent) .with_accept_first_mouse(webview_attributes.accept_first_mouse); @@ -3124,6 +3128,7 @@ fn create_webview( webview_builder = webview_builder.with_ipc_handler(create_ipc_handler( context, label.clone(), + current_url, menu_ids, js_event_listeners, handler, @@ -3234,6 +3239,7 @@ fn create_webview( fn create_ipc_handler( context: Context, label: String, + current_url: Arc>, menu_ids: Arc>>, js_event_listeners: Arc>>>, handler: WebviewIpcHandler>, @@ -3242,6 +3248,7 @@ fn create_ipc_handler( let window_id = context.webview_id_map.get(&window.id()).unwrap(); handler( DetachedWindow { + current_url: current_url.clone(), dispatcher: WryDispatcher { window_id, context: context.clone(), diff --git a/core/tauri-runtime/src/window.rs b/core/tauri-runtime/src/window.rs index d8eaab68c..af15dac05 100644 --- a/core/tauri-runtime/src/window.rs +++ b/core/tauri-runtime/src/window.rs @@ -225,9 +225,6 @@ pub struct PendingWindow> { /// How to handle IPC calls on the webview window. pub ipc_handler: Option>, - /// The resolved URL to load on the webview. - pub url: String, - /// Maps runtime id to a string menu id. pub menu_ids: Arc>>, @@ -236,6 +233,9 @@ pub struct PendingWindow> { /// A handler to decide if incoming url is allowed to navigate. pub navigation_handler: Option bool + Send>>, + + /// The current webview URL. + pub current_url: Arc>, } pub fn is_label_valid(label: &str) -> bool { @@ -272,10 +272,10 @@ impl> PendingWindow { uri_scheme_protocols: Default::default(), label, ipc_handler: None, - url: "tauri://localhost".to_string(), menu_ids: Arc::new(Mutex::new(menu_ids)), js_event_listeners: Default::default(), navigation_handler: Default::default(), + current_url: Arc::new(Mutex::new("tauri://localhost".parse().unwrap())), }) } } @@ -302,10 +302,10 @@ impl> PendingWindow { uri_scheme_protocols: Default::default(), label, ipc_handler: None, - url: "tauri://localhost".to_string(), menu_ids: Arc::new(Mutex::new(menu_ids)), js_event_listeners: Default::default(), navigation_handler: Default::default(), + current_url: Arc::new(Mutex::new("tauri://localhost".parse().unwrap())), }) } } @@ -346,6 +346,9 @@ pub struct JsEventListenerKey { /// A webview window that is not yet managed by Tauri. #[derive(Debug)] pub struct DetachedWindow> { + /// The current webview URL. + pub current_url: Arc>, + /// Name of the window pub label: String, @@ -362,6 +365,7 @@ pub struct DetachedWindow> { impl> Clone for DetachedWindow { fn clone(&self) -> Self { Self { + current_url: self.current_url.clone(), label: self.label.clone(), dispatcher: self.dispatcher.clone(), menu_ids: self.menu_ids.clone(), diff --git a/core/tauri-utils/src/config.rs b/core/tauri-utils/src/config.rs index bcda67566..ef49094b2 100644 --- a/core/tauri-utils/src/config.rs +++ b/core/tauri-utils/src/config.rs @@ -1196,6 +1196,25 @@ impl Default for DisabledCspModificationKind { } } +/// External command access definition. +#[derive(Debug, PartialEq, Eq, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "schema", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RemoteDomainAccessScope { + /// The URL scheme to allow. By default, all schemas are allowed. + pub scheme: Option, + /// The domain to allow. + pub domain: String, + /// The list of window labels this scope applies to. + pub windows: Vec, + /// The list of plugins that are allowed in this scope. + #[serde(default)] + pub plugins: Vec, + /// Enables access to the Tauri API. + #[serde(default, rename = "enableTauriAPI", alias = "enable-tauri-api")] + pub enable_tauri_api: bool, +} + /// Security configuration. /// /// See more: https://tauri.app/v1/api/config#securityconfig @@ -1233,6 +1252,20 @@ pub struct SecurityConfig { /// Your application might be vulnerable to XSS attacks without this Tauri protection. #[serde(default, alias = "dangerous-disable-asset-csp-modification")] pub dangerous_disable_asset_csp_modification: DisabledCspModificationKind, + /// Allow external domains to send command to Tauri. + /// + /// By default, external domains do not have access to `window.__TAURI__`, which means they cannot + /// communicate with the commands defined in Rust. This prevents attacks where an externally + /// loaded malicious or compromised sites could start executing commands on the user's device. + /// + /// This configuration allows a set of external domains to have access to the Tauri commands. + /// When you configure a domain to be allowed to access the IPC, all subpaths are allowed. Subdomains are not allowed. + /// + /// **WARNING:** Only use this option if you either have internal checks against malicious + /// external sites or you can trust the allowed external sites. You application might be + /// vulnerable to dangerous Tauri command related attacks otherwise. + #[serde(default, alias = "dangerous-remote-domain-ipc-access")] + pub dangerous_remote_domain_ipc_access: Vec, } /// Defines an allowlist type. @@ -3590,12 +3623,34 @@ mod build { } } + impl ToTokens for RemoteDomainAccessScope { + fn to_tokens(&self, tokens: &mut TokenStream) { + let scheme = opt_str_lit(self.scheme.as_ref()); + let domain = str_lit(&self.domain); + let windows = vec_lit(&self.windows, str_lit); + let plugins = vec_lit(&self.plugins, str_lit); + let enable_tauri_api = self.enable_tauri_api; + + literal_struct!( + tokens, + RemoteDomainAccessScope, + scheme, + domain, + windows, + plugins, + enable_tauri_api + ); + } + } + impl ToTokens for SecurityConfig { fn to_tokens(&self, tokens: &mut TokenStream) { let csp = opt_lit(self.csp.as_ref()); let dev_csp = opt_lit(self.dev_csp.as_ref()); let freeze_prototype = self.freeze_prototype; let dangerous_disable_asset_csp_modification = &self.dangerous_disable_asset_csp_modification; + let dangerous_remote_domain_ipc_access = + vec_lit(&self.dangerous_remote_domain_ipc_access, identity); literal_struct!( tokens, @@ -3603,7 +3658,8 @@ mod build { csp, dev_csp, freeze_prototype, - dangerous_disable_asset_csp_modification + dangerous_disable_asset_csp_modification, + dangerous_remote_domain_ipc_access ); } } @@ -3868,6 +3924,7 @@ mod test { dev_csp: None, freeze_prototype: false, dangerous_disable_asset_csp_modification: DisabledCspModificationKind::Flag(false), + dangerous_remote_domain_ipc_access: Vec::new(), }, allowlist: AllowlistConfig::default(), system_tray: None, diff --git a/core/tauri/scripts/init.js b/core/tauri/scripts/init.js index 98d8588cf..75761bb58 100644 --- a/core/tauri/scripts/init.js +++ b/core/tauri/scripts/init.js @@ -2,35 +2,33 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -;(function () { - if (window.location.origin.startsWith(__TEMPLATE_origin__)) { - __RAW_freeze_prototype__ +; (function () { + __RAW_freeze_prototype__ - ;(function () { + ; (function () { __RAW_hotkeys__ })() - __RAW_pattern_script__ + __RAW_pattern_script__ - __RAW_ipc_script__ - ;(function () { + __RAW_ipc_script__ + ; (function () { __RAW_bundle_script__ })() - __RAW_listen_function__ + __RAW_listen_function__ - __RAW_core_script__ + __RAW_core_script__ - __RAW_event_initialization_script__ + __RAW_event_initialization_script__ - if (window.ipc) { + if (window.ipc) { + window.__TAURI_INVOKE__('__initialized', { url: window.location.href }) + } else { + window.addEventListener('DOMContentLoaded', function () { window.__TAURI_INVOKE__('__initialized', { url: window.location.href }) - } else { - window.addEventListener('DOMContentLoaded', function () { - window.__TAURI_INVOKE__('__initialized', { url: window.location.href }) - }) - } - - __RAW_plugin_initialization_script__ + }) } + + __RAW_plugin_initialization_script__ })() diff --git a/core/tauri/scripts/isolation.js b/core/tauri/scripts/isolation.js index c7cf95f9c..c8e0ed7fb 100644 --- a/core/tauri/scripts/isolation.js +++ b/core/tauri/scripts/isolation.js @@ -3,15 +3,13 @@ // SPDX-License-Identifier: MIT window.addEventListener('DOMContentLoaded', () => { - if (window.location.origin.startsWith(__TEMPLATE_origin__)) { - let style = document.createElement('style') - style.textContent = __TEMPLATE_style__ - document.head.append(style) + let style = document.createElement('style') + style.textContent = __TEMPLATE_style__ + document.head.append(style) - let iframe = document.createElement('iframe') - iframe.id = '__tauri_isolation__' - iframe.sandbox.add('allow-scripts') - iframe.src = __TEMPLATE_isolation_src__ - document.body.append(iframe) - } + let iframe = document.createElement('iframe') + iframe.id = '__tauri_isolation__' + iframe.sandbox.add('allow-scripts') + iframe.src = __TEMPLATE_isolation_src__ + document.body.append(iframe) }) diff --git a/core/tauri/src/app.rs b/core/tauri/src/app.rs index def52242f..014ab5f79 100644 --- a/core/tauri/src/app.rs +++ b/core/tauri/src/app.rs @@ -19,7 +19,7 @@ use crate::{ window::{PendingWindow, WindowEvent as RuntimeWindowEvent}, ExitRequestedEventAction, RunEvent as RuntimeRunEvent, }, - scope::FsScope, + scope::{FsScope, IpcScope}, sealed::{ManagerBase, RuntimeOrDispatch}, utils::config::Config, utils::{assets::Assets, resources::resource_relpath, Env}, @@ -1049,7 +1049,7 @@ impl Builder { #[cfg(any(windows, target_os = "linux"))] runtime_any_thread: false, setup: Box::new(|_| Ok(())), - invoke_handler: Box::new(|_| ()), + invoke_handler: Box::new(|invoke| invoke.resolver.reject("not implemented")), invoke_responder: Arc::new(window_invoke_responder), invoke_initialization_script: format!("Object.defineProperty(window, '__TAURI_POST_MESSAGE__', {{ value: (message) => window.ipc.postMessage({}(message)) }})", crate::manager::STRINGIFY_IPC_MESSAGE_FN), @@ -1571,10 +1571,10 @@ impl Builder { let mut webview_attributes = WebviewAttributes::new(url).accept_first_mouse(config.accept_first_mouse); if let Some(ua) = &config.user_agent { - webview_attributes = webview_attributes.user_agent(&ua.to_string()); + webview_attributes = webview_attributes.user_agent(ua); } if let Some(args) = &config.additional_browser_args { - webview_attributes = webview_attributes.additional_browser_args(&args.to_string()); + webview_attributes = webview_attributes.additional_browser_args(args); } if !config.file_drop_enabled { webview_attributes = webview_attributes.disable_file_drop_handler(); @@ -1627,6 +1627,7 @@ impl Builder { let env = Env::default(); app.manage(Scopes { + ipc: IpcScope::new(&app.config(), &app.manager), fs: FsScope::for_fs_api( &app.manager.config(), app.package_info(), diff --git a/core/tauri/src/hooks.rs b/core/tauri/src/hooks.rs index 4768a5d77..fb9dea03b 100644 --- a/core/tauri/src/hooks.rs +++ b/core/tauri/src/hooks.rs @@ -39,7 +39,6 @@ pub(crate) struct IpcJavascript<'a> { #[derive(Template)] #[default_template("../scripts/isolation.js")] pub(crate) struct IsolationJavascript<'a> { - pub(crate) origin: String, pub(crate) isolation_src: &'a str, pub(crate) style: &'a str, } diff --git a/core/tauri/src/lib.rs b/core/tauri/src/lib.rs index bbcea0285..f064029d9 100644 --- a/core/tauri/src/lib.rs +++ b/core/tauri/src/lib.rs @@ -779,6 +779,11 @@ pub trait Manager: sealed::ManagerBase { self.state::().inner().fs.clone() } + /// Gets the scope for the IPC. + fn ipc_scope(&self) -> IpcScope { + self.state::().inner().ipc.clone() + } + /// Gets the scope for the asset protocol. #[cfg(protocol_asset)] fn asset_protocol_scope(&self) -> FsScope { diff --git a/core/tauri/src/manager.rs b/core/tauri/src/manager.rs index e0545c092..1d61b85a1 100644 --- a/core/tauri/src/manager.rs +++ b/core/tauri/src/manager.rs @@ -28,7 +28,7 @@ use tauri_utils::{ use crate::hooks::IpcJavascript; #[cfg(feature = "isolation")] use crate::hooks::IsolationJavascript; -use crate::pattern::{format_real_schema, PatternJavascript}; +use crate::pattern::PatternJavascript; use crate::{ app::{AppHandle, GlobalWindowEvent, GlobalWindowEventListener}, event::{assert_event_name_is_valid, Event, EventHandler, Listeners}, @@ -142,7 +142,7 @@ fn set_csp( let default_src = csp .entry("default-src".into()) .or_insert_with(Default::default); - default_src.push(format_real_schema(schema)); + default_src.push(crate::pattern::format_real_schema(schema)); } Csp::DirectiveMap(csp).to_string() @@ -234,7 +234,7 @@ pub struct InnerWindowManager { /// The script that initializes the invoke system. invoke_initialization_script: String, /// Application pattern. - pattern: Pattern, + pub(crate) pattern: Pattern, } impl fmt::Debug for InnerWindowManager { @@ -370,21 +370,16 @@ impl WindowManager { /// Get the base URL to use for webview requests. /// /// In dev mode, this will be based on the `devPath` configuration value. - fn get_url(&self) -> Cow<'_, Url> { + pub(crate) fn get_url(&self) -> Cow<'_, Url> { match self.base_path() { AppUrl::Url(WindowUrl::External(url)) => Cow::Borrowed(url), + #[cfg(windows)] + _ => Cow::Owned(Url::parse("https://tauri.localhost").unwrap()), + #[cfg(not(windows))] _ => Cow::Owned(Url::parse("tauri://localhost").unwrap()), } } - /// Get the origin as it will be seen in the webview. - fn get_browser_origin(&self) -> String { - match self.base_path() { - AppUrl::Url(WindowUrl::External(url)) => url.origin().ascii_serialization(), - _ => format_real_schema("tauri"), - } - } - fn csp(&self) -> Option { if cfg!(feature = "custom-protocol") { self.inner.config.tauri.security.csp.clone() @@ -458,7 +453,6 @@ impl WindowManager { if let Pattern::Isolation { schema, .. } = self.pattern() { webview_attributes = webview_attributes.initialization_script( &IsolationJavascript { - origin: self.get_browser_origin(), isolation_src: &crate::pattern::format_real_schema(schema), style: tauri_utils::pattern::isolation::IFRAME_STYLE, } @@ -480,7 +474,7 @@ impl WindowManager { }); } - let window_url = Url::parse(&pending.url).unwrap(); + let window_url = pending.current_url.lock().unwrap().clone(); let window_origin = if cfg!(windows) && window_url.scheme() != "http" && window_url.scheme() != "https" { format!("https://{}.localhost", window_url.scheme()) @@ -943,7 +937,6 @@ impl WindowManager { #[derive(Template)] #[default_template("../scripts/init.js")] struct InitJavascript<'a> { - origin: String, #[raw] pattern_script: &'a str, #[raw] @@ -1006,7 +999,6 @@ impl WindowManager { let hotkeys = ""; InitJavascript { - origin: self.get_browser_origin(), pattern_script, ipc_script, bundle_script, @@ -1076,7 +1068,16 @@ mod test { ); #[cfg(custom_protocol)] - assert_eq!(manager.get_url().to_string(), "tauri://localhost"); + { + assert_eq!( + manager.get_url().to_string(), + if cfg!(windows) { + "https://tauri.localhost/" + } else { + "tauri://localhost" + } + ); + } #[cfg(dev)] assert_eq!(manager.get_url().to_string(), "http://localhost:4000/"); @@ -1127,27 +1128,21 @@ impl WindowManager { return Err(crate::Error::WindowLabelAlreadyExists(pending.label)); } #[allow(unused_mut)] // mut url only for the data-url parsing - let (is_local, mut url) = match &pending.webview_attributes.url { + let mut url = match &pending.webview_attributes.url { WindowUrl::App(path) => { let url = self.get_url(); - ( - true, - // ignore "index.html" just to simplify the url - if path.to_str() != Some("index.html") { - url - .join(&path.to_string_lossy()) - .map_err(crate::Error::InvalidUrl) - // this will never fail - .unwrap() - } else { - url.into_owned() - }, - ) - } - WindowUrl::External(url) => { - let config_url = self.get_url(); - (config_url.make_relative(url).is_some(), url.clone()) + // ignore "index.html" just to simplify the url + if path.to_str() != Some("index.html") { + url + .join(&path.to_string_lossy()) + .map_err(crate::Error::InvalidUrl) + // this will never fail + .unwrap() + } else { + url.into_owned() + } } + WindowUrl::External(url) => url.clone(), _ => unimplemented!(), }; @@ -1174,7 +1169,7 @@ impl WindowManager { } } - pending.url = url.to_string(); + *pending.current_url.lock().unwrap() = url; if !pending.window_builder.has_icon() { if let Some(default_window_icon) = self.inner.default_window_icon.clone() { @@ -1190,17 +1185,15 @@ impl WindowManager { } } - if is_local { - let label = pending.label.clone(); - pending = self.prepare_pending_window( - pending, - &label, - window_labels, - app_handle.clone(), - web_resource_request_handler, - )?; - pending.ipc_handler = Some(self.prepare_ipc_handler(app_handle)); - } + let label = pending.label.clone(); + pending = self.prepare_pending_window( + pending, + &label, + window_labels, + app_handle.clone(), + web_resource_request_handler, + )?; + pending.ipc_handler = Some(self.prepare_ipc_handler(app_handle)); // in `Windows`, we need to force a data_directory // but we do respect user-specification @@ -1225,6 +1218,17 @@ impl WindowManager { } } + let current_url_ = pending.current_url.clone(); + let navigation_handler = pending.navigation_handler.take(); + pending.navigation_handler = Some(Box::new(move |url| { + *current_url_.lock().unwrap() = url.clone(); + if let Some(handler) = &navigation_handler { + handler(url) + } else { + true + } + })); + Ok(pending) } diff --git a/core/tauri/src/pattern.rs b/core/tauri/src/pattern.rs index 745aca05e..5ac8f8f87 100644 --- a/core/tauri/src/pattern.rs +++ b/core/tauri/src/pattern.rs @@ -11,6 +11,9 @@ use serialize_to_javascript::{default_template, Template}; use tauri_utils::assets::{Assets, EmbeddedAssets}; +/// The domain of the isolation iframe source. +pub const ISOLATION_IFRAME_SRC_DOMAIN: &str = "localhost"; + /// An application pattern. #[derive(Debug, Clone)] pub enum Pattern { @@ -87,8 +90,8 @@ pub(crate) struct PatternJavascript { #[allow(dead_code)] pub(crate) fn format_real_schema(schema: &str) -> String { if cfg!(windows) { - format!("https://{schema}.localhost") + format!("https://{schema}.{ISOLATION_IFRAME_SRC_DOMAIN}") } else { - format!("{schema}://localhost") + format!("{schema}://{ISOLATION_IFRAME_SRC_DOMAIN}") } } diff --git a/core/tauri/src/scope/ipc.rs b/core/tauri/src/scope/ipc.rs new file mode 100644 index 000000000..a86bce057 --- /dev/null +++ b/core/tauri/src/scope/ipc.rs @@ -0,0 +1,430 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::sync::{Arc, Mutex}; + +use crate::{manager::WindowManager, Config, Runtime, Window}; +#[cfg(feature = "isolation")] +use crate::{pattern::ISOLATION_IFRAME_SRC_DOMAIN, sealed::ManagerBase, Pattern}; +use url::Url; + +/// IPC access configuration for a remote domain. +#[derive(Debug, Clone)] +pub struct RemoteDomainAccessScope { + scheme: Option, + domain: String, + windows: Vec, + plugins: Vec, + enable_tauri_api: bool, +} + +impl RemoteDomainAccessScope { + /// Creates a new access scope. + pub fn new(domain: impl Into) -> Self { + Self { + scheme: None, + domain: domain.into(), + windows: Vec::new(), + plugins: Vec::new(), + enable_tauri_api: false, + } + } + + /// Sets the scheme of the URL to allow in this scope. By default, all schemes with the given domain are allowed. + pub fn allow_on_scheme(mut self, scheme: impl Into) -> Self { + self.scheme.replace(scheme.into()); + self + } + + /// Adds the given window label to the list of windows that uses this scope. + pub fn add_window(mut self, window: impl Into) -> Self { + self.windows.push(window.into()); + self + } + + /// Adds the given plugin to the allowed plugin list. + pub fn add_plugin(mut self, plugin: impl Into) -> Self { + self.plugins.push(plugin.into()); + self + } + + /// Enables access to the Tauri API. + pub fn enable_tauri_api(mut self) -> Self { + self.enable_tauri_api = true; + self + } + + /// The domain of the URLs that can access this scope. + pub fn domain(&self) -> &str { + &self.domain + } + + /// The list of window labels that can access this scope. + pub fn windows(&self) -> &Vec { + &self.windows + } + + /// The list of plugins enabled by this scope. + pub fn plugins(&self) -> &Vec { + &self.plugins + } + + /// Whether this scope enables Tauri API access or not. + pub fn enables_tauri_api(&self) -> bool { + self.enable_tauri_api + } +} + +pub(crate) struct RemoteAccessError { + pub matches_window: bool, + pub matches_domain: bool, +} + +/// IPC scope. +#[derive(Clone)] +pub struct Scope { + remote_access: Arc>>, +} + +impl Scope { + #[allow(unused_variables)] + pub(crate) fn new(config: &Config, manager: &WindowManager) -> Self { + #[allow(unused_mut)] + let mut remote_access: Vec = config + .tauri + .security + .dangerous_remote_domain_ipc_access + .clone() + .into_iter() + .map(|s| RemoteDomainAccessScope { + scheme: s.scheme, + domain: s.domain, + windows: s.windows, + plugins: s.plugins, + enable_tauri_api: s.enable_tauri_api, + }) + .collect(); + + #[cfg(feature = "isolation")] + if let Pattern::Isolation { schema, .. } = &manager.inner.pattern { + remote_access.push(RemoteDomainAccessScope { + scheme: Some(schema.clone()), + domain: ISOLATION_IFRAME_SRC_DOMAIN.into(), + windows: Vec::new(), + plugins: Vec::new(), + enable_tauri_api: true, + }); + } + + Self { + remote_access: Arc::new(Mutex::new(remote_access)), + } + } + + /// Adds the given configuration for remote access. + /// + /// # Examples + /// + /// ``` + /// use tauri::{Manager, scope::ipc::RemoteDomainAccessScope}; + /// tauri::Builder::default() + /// .setup(|app| { + /// app.ipc_scope().configure_remote_access( + /// RemoteDomainAccessScope::new("tauri.app") + /// .add_window("main") + /// .enable_tauri_api() + /// ); + /// Ok(()) + /// }); + /// ``` + pub fn configure_remote_access(&self, access: RemoteDomainAccessScope) { + self.remote_access.lock().unwrap().push(access); + } + + pub(crate) fn remote_access_for( + &self, + window: &Window, + url: &Url, + ) -> Result { + let mut scope = None; + let mut found_scope_for_window = false; + let mut found_scope_for_domain = false; + let label = window.label().to_string(); + + for s in &*self.remote_access.lock().unwrap() { + #[allow(unused_mut)] + let mut matches_window = s.windows.contains(&label); + // the isolation iframe is always able to access the IPC + #[cfg(feature = "isolation")] + if let Pattern::Isolation { schema, .. } = &window.manager().inner.pattern { + if schema == url.scheme() && url.domain() == Some(ISOLATION_IFRAME_SRC_DOMAIN) { + matches_window = true; + } + } + + let matches_scheme = s + .scheme + .as_ref() + .map(|scheme| scheme == url.scheme()) + .unwrap_or(true); + + let matches_domain = + matches_scheme && url.domain().map(|d| d == s.domain).unwrap_or_default(); + found_scope_for_window = found_scope_for_window || matches_window; + found_scope_for_domain = found_scope_for_domain || matches_domain; + if matches_window && matches_domain && scope.is_none() { + scope.replace(s.clone()); + } + } + + if let Some(s) = scope { + Ok(s) + } else { + Err(RemoteAccessError { + matches_window: found_scope_for_window, + matches_domain: found_scope_for_domain, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::RemoteDomainAccessScope; + use crate::{api::ipc::CallbackFn, test::MockRuntime, App, InvokePayload, Manager, Window}; + + const PLUGIN_NAME: &str = "test"; + + fn test_context(scopes: Vec) -> (App, Window) { + let app = crate::test::mock_app(); + let window = app.get_window("main").unwrap(); + + for scope in scopes { + app.ipc_scope().configure_remote_access(scope); + } + + (app, window) + } + + fn assert_ipc_response( + window: &Window, + payload: InvokePayload, + expected: Result<&str, &str>, + ) { + let callback = payload.callback; + let error = payload.error; + window.clone().on_message(payload).unwrap(); + + let mut num_tries = 0; + let evaluated_script = loop { + std::thread::sleep(std::time::Duration::from_millis(50)); + let evaluated_script = window.dispatcher().last_evaluated_script(); + if let Some(s) = evaluated_script { + break s; + } + num_tries += 1; + if num_tries == 20 { + panic!("Response script not evaluated"); + } + }; + let (expected_response, fn_name) = match expected { + Ok(payload) => (payload, callback), + Err(payload) => (payload, error), + }; + let expected = format!( + "window[\"_{}\"]({})", + fn_name.0, + crate::api::ipc::serialize_js(&expected_response).unwrap() + ); + + println!("Last evaluated script:"); + println!("{evaluated_script}"); + println!("Expected:"); + println!("{expected}"); + assert!(evaluated_script.contains(&expected)); + } + + fn app_version_payload() -> InvokePayload { + let callback = CallbackFn(0); + let error = CallbackFn(1); + + let mut payload = serde_json::Map::new(); + let mut msg = serde_json::Map::new(); + msg.insert( + "cmd".into(), + serde_json::Value::String("getAppVersion".into()), + ); + payload.insert("message".into(), serde_json::Value::Object(msg)); + + InvokePayload { + cmd: "".into(), + tauri_module: Some("App".into()), + callback, + error, + inner: serde_json::Value::Object(payload), + } + } + + fn plugin_test_payload() -> InvokePayload { + let callback = CallbackFn(0); + let error = CallbackFn(1); + + InvokePayload { + cmd: format!("plugin:{PLUGIN_NAME}|doSomething"), + tauri_module: None, + callback, + error, + inner: Default::default(), + } + } + + #[test] + fn scope_not_defined() { + let (_app, window) = test_context(vec![RemoteDomainAccessScope::new("app.tauri.app") + .add_window("other") + .enable_tauri_api()]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Err(&crate::window::ipc_scope_not_found_error_message( + "main", + "https://tauri.app/", + )), + ); + } + + #[test] + fn scope_not_defined_for_window() { + let (_app, window) = test_context(vec![RemoteDomainAccessScope::new("tauri.app") + .add_window("second") + .enable_tauri_api()]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Err(&crate::window::ipc_scope_window_error_message("main")), + ); + } + + #[test] + fn scope_not_defined_for_url() { + let (_app, window) = test_context(vec![RemoteDomainAccessScope::new("github.com") + .add_window("main") + .enable_tauri_api()]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Err(&crate::window::ipc_scope_domain_error_message( + "https://tauri.app/", + )), + ); + } + + #[test] + fn subdomain_is_not_allowed() { + let (app, mut window) = test_context(vec![ + RemoteDomainAccessScope::new("tauri.app") + .add_window("main") + .enable_tauri_api(), + RemoteDomainAccessScope::new("sub.tauri.app") + .add_window("main") + .enable_tauri_api(), + ]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Ok(app.package_info().version.to_string().as_str()), + ); + + window.navigate("https://blog.tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Err(&crate::window::ipc_scope_domain_error_message( + "https://blog.tauri.app/", + )), + ); + + window.navigate("https://sub.tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Ok(app.package_info().version.to_string().as_str()), + ); + + window.window.label = "test".into(); + window.navigate("https://dev.tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Err(&crate::window::ipc_scope_not_found_error_message( + "test", + "https://dev.tauri.app/", + )), + ); + } + + #[test] + fn subpath_is_allowed() { + let (app, window) = test_context(vec![RemoteDomainAccessScope::new("tauri.app") + .add_window("main") + .enable_tauri_api()]); + + window.navigate("https://tauri.app/inner/path".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Ok(app.package_info().version.to_string().as_str()), + ); + } + + #[test] + fn tauri_api_not_allowed() { + let (_app, window) = test_context(vec![ + RemoteDomainAccessScope::new("tauri.app").add_window("main") + ]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + app_version_payload(), + Err(crate::window::IPC_SCOPE_DOES_NOT_ALLOW), + ); + } + + #[test] + fn plugin_allowed() { + let (_app, window) = test_context(vec![RemoteDomainAccessScope::new("tauri.app") + .add_window("main") + .add_plugin(PLUGIN_NAME)]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + plugin_test_payload(), + Err(&format!("plugin {PLUGIN_NAME} not found")), + ); + } + + #[test] + fn plugin_not_allowed() { + let (_app, window) = test_context(vec![ + RemoteDomainAccessScope::new("tauri.app").add_window("main") + ]); + + window.navigate("https://tauri.app".parse().unwrap()); + assert_ipc_response( + &window, + plugin_test_payload(), + Err(crate::window::IPC_SCOPE_DOES_NOT_ALLOW), + ); + } +} diff --git a/core/tauri/src/scope/mod.rs b/core/tauri/src/scope/mod.rs index f0eaf5e52..72243c29e 100644 --- a/core/tauri/src/scope/mod.rs +++ b/core/tauri/src/scope/mod.rs @@ -4,10 +4,13 @@ mod fs; mod http; +/// IPC scope. +pub mod ipc; #[cfg(shell_scope)] mod shell; pub use self::http::Scope as HttpScope; +pub use self::ipc::Scope as IpcScope; pub use fs::{Event as FsScopeEvent, Pattern as GlobPattern, Scope as FsScope}; #[cfg(shell_scope)] pub use shell::{ @@ -18,6 +21,7 @@ pub use shell::{ use std::path::Path; pub(crate) struct Scopes { + pub ipc: IpcScope, pub fs: FsScope, #[cfg(protocol_asset)] pub asset_protocol: FsScope, diff --git a/core/tauri/src/test/mock_runtime.rs b/core/tauri/src/test/mock_runtime.rs index bc56a71f2..28e5673a8 100644 --- a/core/tauri/src/test/mock_runtime.rs +++ b/core/tauri/src/test/mock_runtime.rs @@ -69,8 +69,10 @@ impl RuntimeHandle for MockRuntimeHandle { ) -> Result> { Ok(DetachedWindow { label: pending.label, + current_url: Arc::new(Mutex::new("tauri://localhost".parse().unwrap())), dispatcher: MockDispatcher { context: self.context.clone(), + last_evaluated_script: Default::default(), }, menu_ids: Default::default(), js_event_listeners: Default::default(), @@ -111,6 +113,13 @@ impl RuntimeHandle for MockRuntimeHandle { #[derive(Debug, Clone)] pub struct MockDispatcher { context: RuntimeContext, + last_evaluated_script: Arc>>, +} + +impl MockDispatcher { + pub fn last_evaluated_script(&self) -> Option { + self.last_evaluated_script.lock().unwrap().clone() + } } #[cfg(all(desktop, feature = "global-shortcut"))] @@ -558,6 +567,11 @@ impl Dispatch for MockDispatcher { } fn eval_script>(&self, script: S) -> Result<()> { + self + .last_evaluated_script + .lock() + .unwrap() + .replace(script.into()); Ok(()) } @@ -689,8 +703,10 @@ impl Runtime for MockRuntime { fn create_window(&self, pending: PendingWindow) -> Result> { Ok(DetachedWindow { label: pending.label, + current_url: Arc::new(Mutex::new("tauri://localhost".parse().unwrap())), dispatcher: MockDispatcher { context: self.context.clone(), + last_evaluated_script: Default::default(), }, menu_ids: Default::default(), js_event_listeners: Default::default(), diff --git a/core/tauri/src/window.rs b/core/tauri/src/window.rs index f87bd9e8a..b14b6d2cb 100644 --- a/core/tauri/src/window.rs +++ b/core/tauri/src/window.rs @@ -317,13 +317,13 @@ impl<'a, R: Runtime> WindowBuilder<'a, R> { self.label.clone(), )?; let labels = self.manager.labels().into_iter().collect::>(); - let mut pending = self.manager.prepare_window( + let pending = self.manager.prepare_window( self.app_handle.clone(), pending, &labels, web_resource_request_handler, )?; - pending.navigation_handler = self.navigation_handler.take(); + let window = match &mut self.runtime { RuntimeOrDispatch::Runtime(runtime) => runtime.create_window(pending), RuntimeOrDispatch::RuntimeHandle(handle) => handle.create_window(pending), @@ -678,7 +678,7 @@ impl<'a, R: Runtime> WindowBuilder<'a, R> { #[derive(Debug)] pub struct Window { /// The webview window created by the runtime. - window: DetachedWindow, + pub(crate) window: DetachedWindow, /// The manager to associate this webview window with. manager: WindowManager, pub(crate) app_handle: AppHandle, @@ -1384,13 +1384,39 @@ impl Window { /// Webview APIs. impl Window { /// Returns the current url of the webview. - pub fn url(&self) -> crate::Result { - self.window.dispatcher.url().map_err(Into::into) + pub fn url(&self) -> Url { + self.window.current_url.lock().unwrap().clone() + } + + #[cfg(test)] + pub(crate) fn navigate(&self, url: Url) { + *self.window.current_url.lock().unwrap() = url; } /// Handles this window receiving an [`InvokeMessage`]. pub fn on_message(self, payload: InvokePayload) -> crate::Result<()> { let manager = self.manager.clone(); + let current_url = self.url(); + let config_url = manager.get_url(); + let is_local = config_url.make_relative(¤t_url).is_some(); + + let mut scope_not_found_error_message = + ipc_scope_not_found_error_message(&self.window.label, current_url.as_str()); + let scope = if is_local { + None + } else { + match self.ipc_scope().remote_access_for(&self, ¤t_url) { + Ok(scope) => Some(scope), + Err(e) => { + if e.matches_window { + scope_not_found_error_message = ipc_scope_domain_error_message(current_url.as_str()); + } else if e.matches_domain { + scope_not_found_error_message = ipc_scope_window_error_message(&self.window.label); + } + None + } + } + }; match payload.cmd.as_str() { "__initialized" => { let payload: PageLoadPayload = serde_json::from_value(payload.inner)?; @@ -1404,9 +1430,18 @@ impl Window { payload.inner, ); let resolver = InvokeResolver::new(self, payload.callback, payload.error); - let invoke = Invoke { message, resolver }; + + if !is_local && scope.is_none() { + invoke.resolver.reject(scope_not_found_error_message); + return Ok(()); + } + if let Some(module) = &payload.tauri_module { + if !is_local && scope.map(|s| !s.enables_tauri_api()).unwrap_or_default() { + invoke.resolver.reject(IPC_SCOPE_DOES_NOT_ALLOW); + return Ok(()); + } crate::endpoints::handle( module.to_string(), invoke, @@ -1414,6 +1449,17 @@ impl Window { manager.package_info(), ); } else if payload.cmd.starts_with("plugin:") { + if !is_local { + let command = invoke.message.command.replace("plugin:", ""); + let plugin_name = command.split('|').next().unwrap().to_string(); + if !scope + .map(|s| s.plugins().contains(&plugin_name)) + .unwrap_or(true) + { + invoke.resolver.reject(IPC_SCOPE_DOES_NOT_ALLOW); + return Ok(()); + } + } manager.extend_api(invoke); } else { manager.run_invoke_handler(invoke); @@ -1645,6 +1691,20 @@ impl Window { } } +pub(crate) const IPC_SCOPE_DOES_NOT_ALLOW: &str = "Not allowed by the scope"; + +pub(crate) fn ipc_scope_not_found_error_message(label: &str, url: &str) -> String { + format!("Scope not defined for window `{label}` and URL `{url}`. See https://tauri.app/v1/api/config/#securityconfig.dangerousremotedomainipcaccess and https://docs.rs/tauri/1/tauri/scope/struct.IpcScope.html#method.configure_remote_access") +} + +pub(crate) fn ipc_scope_window_error_message(label: &str) -> String { + format!("Scope not defined for window `{}`. See https://tauri.app/v1/api/config/#securityconfig.dangerousremotedomainipcaccess and https://docs.rs/tauri/1/tauri/scope/struct.IpcScope.html#method.configure_remote_access", label) +} + +pub(crate) fn ipc_scope_domain_error_message(url: &str) -> String { + format!("Scope not defined for URL `{url}`. See https://tauri.app/v1/api/config/#securityconfig.dangerousremotedomainipcaccess and https://docs.rs/tauri/1/tauri/scope/struct.IpcScope.html#method.configure_remote_access") +} + #[cfg(test)] mod tests { #[test] diff --git a/tooling/cli/schema.json b/tooling/cli/schema.json index 6f7391afd..2fee51980 100644 --- a/tooling/cli/schema.json +++ b/tooling/cli/schema.json @@ -163,6 +163,7 @@ }, "security": { "dangerousDisableAssetCspModification": false, + "dangerousRemoteDomainIpcAccess": [], "freezePrototype": false }, "updater": { @@ -415,6 +416,7 @@ "description": "Security configuration.", "default": { "dangerousDisableAssetCspModification": false, + "dangerousRemoteDomainIpcAccess": [], "freezePrototype": false }, "allOf": [ @@ -2603,6 +2605,14 @@ "$ref": "#/definitions/DisabledCspModificationKind" } ] + }, + "dangerousRemoteDomainIpcAccess": { + "description": "Allow external domains to send command to Tauri.\n\nBy default, external domains do not have access to `window.__TAURI__`, which means they cannot communicate with the commands defined in Rust. This prevents attacks where an externally loaded malicious or compromised sites could start executing commands on the user's device.\n\nThis configuration allows a set of external domains to have access to the Tauri commands. When you configure a domain to be allowed to access the IPC, all subpaths are allowed. Subdomains are not allowed.\n\n**WARNING:** Only use this option if you either have internal checks against malicious external sites or you can trust the allowed external sites. You application might be vulnerable to dangerous Tauri command related attacks otherwise.", + "default": [], + "type": "array", + "items": { + "$ref": "#/definitions/RemoteDomainAccessScope" + } } }, "additionalProperties": false @@ -2655,6 +2665,48 @@ } ] }, + "RemoteDomainAccessScope": { + "description": "External command access definition.", + "type": "object", + "required": [ + "domain", + "windows" + ], + "properties": { + "scheme": { + "description": "The URL scheme to allow. By default, all schemas are allowed.", + "type": [ + "string", + "null" + ] + }, + "domain": { + "description": "The domain to allow.", + "type": "string" + }, + "windows": { + "description": "The list of window labels this scope applies to.", + "type": "array", + "items": { + "type": "string" + } + }, + "plugins": { + "description": "The list of plugins that are allowed in this scope.", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, + "enableTauriAPI": { + "description": "Enables access to the Tauri API.", + "default": false, + "type": "boolean" + } + }, + "additionalProperties": false + }, "UpdaterConfig": { "description": "The Updater configuration object.\n\nSee more: https://tauri.app/v1/api/config#updaterconfig", "type": "object",