diff --git a/apps/desktop-shell/scripts/check-config.mjs b/apps/desktop-shell/scripts/check-config.mjs index b0fb9d741..b13428111 100644 --- a/apps/desktop-shell/scripts/check-config.mjs +++ b/apps/desktop-shell/scripts/check-config.mjs @@ -1867,11 +1867,15 @@ if ( } for (const snippet of [ 'open_desktop_host_bridge_external_url', + 'desktop_external_url_from_request', 'normalize_external_url', 'open_normalized_desktop_external_url(app, url)', + 'desktop_external_url_request_accepts_only_safe_system_protocols', 'open_desktop_host_bridge_native_page', + 'desktop_native_page_url_from_request', 'normalize_native_page_url', 'window.navigate(url)', + 'desktop_native_page_request_accepts_only_same_origin_h5_routes', 'reload_desktop_host_bridge_webview', 'window.reload()', ]) { diff --git a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs index 09cddd9db..cc4ee009a 100644 --- a/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs +++ b/apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs @@ -7,22 +7,43 @@ use crate::shell::webview::{ use serde_json::json; use tauri::Manager; -pub(crate) fn open_desktop_host_bridge_external_url( - app: &tauri::AppHandle, +fn desktop_external_url_from_request( request: &HostBridgeRequest, -) -> HostBridgeResponse { - let url = match required_string_payload(request, "url") +) -> Result { + required_string_payload(request, "url") .ok() .and_then(normalize_external_url) - { - Some(url) => url, - None => { - return failed( + .ok_or_else(|| { + failed( request.id.clone(), "invalid_request", "url must use an allowed external protocol", ) - } + }) +} + +fn desktop_native_page_url_from_request( + request: &HostBridgeRequest, +) -> Result { + required_string_payload(request, "url") + .ok() + .and_then(normalize_native_page_url) + .ok_or_else(|| { + failed( + request.id.clone(), + "invalid_request", + "url must use an allowed same-origin H5 route", + ) + }) +} + +pub(crate) fn open_desktop_host_bridge_external_url( + app: &tauri::AppHandle, + request: &HostBridgeRequest, +) -> HostBridgeResponse { + let url = match desktop_external_url_from_request(request) { + Ok(url) => url, + Err(response) => return response, }; match open_normalized_desktop_external_url(app, url) { @@ -35,18 +56,9 @@ pub(crate) fn open_desktop_host_bridge_native_page( app: &tauri::AppHandle, request: &HostBridgeRequest, ) -> HostBridgeResponse { - let url = match required_string_payload(request, "url") - .ok() - .and_then(normalize_native_page_url) - { - Some(url) => url, - None => { - return failed( - request.id.clone(), - "invalid_request", - "url must use an allowed same-origin H5 route", - ) - } + let url = match desktop_native_page_url_from_request(request) { + Ok(url) => url, + Err(response) => return response, }; match app.get_webview_window("main") { @@ -58,6 +70,88 @@ pub(crate) fn open_desktop_host_bridge_native_page( } } +#[cfg(test)] +mod tests { + use super::*; + use crate::host_bridge::protocol::request; + use serde_json::json; + + fn request_with_url(method: &str, url: &str) -> HostBridgeRequest { + let mut request = request(method); + request.payload = Some(json!({ "url": url })); + request + } + + #[test] + fn desktop_external_url_request_accepts_only_safe_system_protocols() { + let request = request_with_url("app.openExternalUrl", " https://example.com/share "); + + assert_eq!( + desktop_external_url_from_request(&request).expect("external url"), + "https://example.com/share" + ); + + for url in [ + "", + "/works/detail?work=PZ-1", + "javascript:alert(1)", + "file:///etc/passwd", + "https://example.com/\nnext", + ] { + let response = desktop_external_url_from_request(&request_with_url( + "app.openExternalUrl", + url, + )) + .expect_err("invalid external url"); + + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!( + error.message, + "url must use an allowed external protocol" + ); + } + } + + #[test] + fn desktop_native_page_request_accepts_only_same_origin_h5_routes() { + let route = desktop_native_page_url_from_request(&request_with_url( + "navigation.openNativePage", + "/works/detail?work=PZ-1", + )) + .expect("native page url"); + + assert_eq!( + route.origin().ascii_serialization(), + "https://app.genarrative.world" + ); + assert_eq!(route.path(), "/works/detail"); + assert!(route + .query_pairs() + .any(|(key, value)| key == "hostShell" && value == "tauri_desktop")); + + for url in [ + "https://example.com/works", + "//example.com/works", + "javascript:alert(1)", + "https://app.genarrative.world/\nnext", + ] { + let response = desktop_native_page_url_from_request(&request_with_url( + "navigation.openNativePage", + url, + )) + .expect_err("invalid native page url"); + + let error = response.error.expect("error"); + assert_eq!(error.code, "invalid_request"); + assert_eq!( + error.message, + "url must use an allowed same-origin H5 route" + ); + } + } +} + pub(crate) fn reload_desktop_host_bridge_webview( app: &tauri::AppHandle, request: &HostBridgeRequest,