@@ -1724,14 +1724,14 @@ tauri::Builder::default()
17241724 // so we check using the first part of the domain
17251725 #[ cfg( any( windows, target_os = "android" ) ) ]
17261726 let local = {
1727- let protocol_url = self . manager ( ) . tauri_protocol_url ( uses_https) ;
1728- let maybe_protocol = current_url
1727+ let scheme = scheme == self . manager ( ) . tauri_protocol_url ( uses_https) . scheme ( ) ;
1728+ let protocol = current_url
17291729 . domain ( )
1730- . and_then ( |d| d . split_once ( '.' ) )
1731- . unwrap_or_default ( )
1732- . 0 ;
1730+ . and_then ( |d| d. strip_suffix ( ".localhost" ) )
1731+ . map ( |protocol| protocols . contains_key ( protocol ) )
1732+ . unwrap_or_default ( ) ;
17331733
1734- protocols . contains_key ( maybe_protocol ) && scheme == protocol_url . scheme ( )
1734+ scheme && protocol
17351735 } ;
17361736
17371737 local
@@ -2354,12 +2354,68 @@ impl<T: ScopeObject> ResolvedScope<T> {
23542354
23552355#[ cfg( test) ]
23562356mod tests {
2357+ use url:: Url ;
2358+
2359+ fn test_webview_window ( ) -> crate :: WebviewWindow < crate :: test:: MockRuntime > {
2360+ use crate :: test:: { mock_builder, mock_context, noop_assets} ;
2361+
2362+ // Create a mock app with proper context
2363+ let app = mock_builder ( ) . build ( mock_context ( noop_assets ( ) ) ) . unwrap ( ) ;
2364+
2365+ // Create a webview window
2366+ crate :: WebviewWindowBuilder :: new ( & app, "test" , crate :: WebviewUrl :: default ( ) )
2367+ . build ( )
2368+ . unwrap ( )
2369+ }
2370+
23572371 #[ test]
23582372 fn webview_is_send_sync ( ) {
23592373 crate :: test_utils:: assert_send :: < super :: Webview > ( ) ;
23602374 crate :: test_utils:: assert_sync :: < super :: Webview > ( ) ;
23612375 }
23622376
2377+ #[ test]
2378+ fn tauri_protocol_is_local ( ) {
2379+ let webview = test_webview_window ( ) . webview ;
2380+
2381+ #[ cfg( all( not( windows) , not( target_os = "android" ) ) ) ]
2382+ assert ! ( webview. is_local_url( & Url :: parse( "tauri://localhost/" ) . unwrap( ) ) ) ;
2383+
2384+ #[ cfg( any( windows, target_os = "android" ) ) ]
2385+ assert ! ( webview. is_local_url( & Url :: parse( "https://tauri.localhost/" ) . unwrap( ) ) ) ;
2386+ }
2387+
2388+ // On Windows/Android, custom protocols are served as `https://<name>.localhost/`.
2389+ // We ensure only `.localhost` domains are accepted to prevent a subdomain being able to
2390+ // impersonate a protocol name.
2391+ #[ cfg( any( windows, target_os = "android" ) ) ]
2392+ #[ test]
2393+ fn windows_custom_protocol_rejects_spoofed_domain ( ) {
2394+ use crate :: test:: { mock_builder, mock_context, noop_assets} ;
2395+
2396+ let app = mock_builder ( )
2397+ . register_uri_scheme_protocol ( "myproto" , |_, _| {
2398+ http:: Response :: builder ( ) . body ( Vec :: new ( ) ) . unwrap ( )
2399+ } )
2400+ . build ( mock_context ( noop_assets ( ) ) )
2401+ . unwrap ( ) ;
2402+ let webview = crate :: WebviewWindowBuilder :: new ( & app, "test" , crate :: WebviewUrl :: default ( ) )
2403+ . build ( )
2404+ . unwrap ( )
2405+ . webview ;
2406+
2407+ let url = |s| Url :: parse ( s) . unwrap ( ) ;
2408+
2409+ // Legitimate Windows custom protocol URL
2410+ assert ! ( webview. is_local_url( & url( "https://myproto.localhost/" ) ) ) ;
2411+
2412+ // Attacker domain that starts with a registered protocol name — must NOT be local.
2413+ assert ! ( !webview. is_local_url( & url( "https://myproto.evil.com/" ) ) ) ;
2414+
2415+ // Subdomain of .localhost with unregistered name — must NOT be local
2416+ assert ! ( !webview. is_local_url( & url( "https://notregistered.localhost/" ) ) ) ;
2417+ }
2418+
23632419 /// Custom (non-plugin) commands must be rejected when the IPC request
23642420 /// originates from a remote URL, even when no `AppManifest` has been
23652421 /// configured. Only local (bundled) origins should be able to reach
@@ -2376,7 +2432,7 @@ mod tests {
23762432 . unwrap ( ) ;
23772433
23782434 // Request from a remote origin for a custom (non-plugin) command
2379- // – should be rejected even without an AppManifest.
2435+ // - should be rejected even without an AppManifest.
23802436 let remote_result = crate :: test:: get_ipc_response (
23812437 & webview,
23822438 InvokeRequest {
@@ -2394,7 +2450,7 @@ mod tests {
23942450 "custom command should be rejected from a remote origin"
23952451 ) ;
23962452
2397- // Same command from the local origin – should NOT be rejected by the
2453+ // Same command from the local origin - should NOT be rejected by the
23982454 // remote-origin guard (it may still fail because the command doesn't
23992455 // exist, but the error message will be different).
24002456 let local_result = crate :: test:: get_ipc_response (
@@ -2423,16 +2479,7 @@ mod tests {
24232479 #[ cfg( target_os = "macos" ) ]
24242480 #[ test]
24252481 fn test_webview_window_has_set_simple_fullscreen_method ( ) {
2426- use crate :: test:: { mock_builder, mock_context, noop_assets} ;
2427-
2428- // Create a mock app with proper context
2429- let app = mock_builder ( ) . build ( mock_context ( noop_assets ( ) ) ) . unwrap ( ) ;
2430-
2431- // Get or create a webview window
2432- let webview_window =
2433- crate :: WebviewWindowBuilder :: new ( & app, "test" , crate :: WebviewUrl :: default ( ) )
2434- . build ( )
2435- . unwrap ( ) ;
2482+ let webview_window = test_webview_window ( ) ;
24362483
24372484 // This should compile if set_simple_fullscreen exists
24382485 let result = webview_window. set_simple_fullscreen ( true ) ;
0 commit comments