diff --git a/Cargo.lock b/Cargo.lock index 33c749f3..1b748e3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -703,7 +703,6 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-shell", "tauri-plugin-single-instance", @@ -1843,16 +1842,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix", - "windows-link", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -3414,22 +3403,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "os_info" -version = "3.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" -dependencies = [ - "android_system_properties", - "log", - "nix 0.31.3", - "objc2", - "objc2-foundation", - "objc2-ui-kit", - "serde", - "windows-sys 0.61.2", -] - [[package]] name = "os_pipe" version = "1.2.3" @@ -5310,15 +5283,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -5592,24 +5556,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-os" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd69d272ab772b30462779de12a40f837616831c794c71531bea91474e1061f" -dependencies = [ - "gethostname", - "log", - "os_info", - "serde", - "serde_json", - "serialize-to-javascript", - "sys-locale", - "tauri", - "tauri-plugin", - "thiserror 2.0.21", -] - [[package]] name = "tauri-plugin-process" version = "2.4.0" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index be8044fc..52873f9e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -13,7 +13,6 @@ clavyn-core = { path = "../../core" } tauri = { version = "2", features = [] } tauri-plugin-shell = "2" tauri-plugin-dialog = "2" -tauri-plugin-os = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" tauri-plugin-single-instance = "2" diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 962531c1..1511670f 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -4,8 +4,70 @@ "description": "Default capability for the main window", "windows": ["main"], "permissions": [ - "core:default", + "core:event:allow-listen", + "core:event:allow-unlisten", + "core:webview:allow-internal-toggle-devtools", "shell:allow-open", - "os:default" + "allow-startup-failure", + "allow-set-aside-unreadable-file", + "allow-restart-app", + "allow-list-hosts", + "allow-add-host", + "allow-update-host", + "allow-delete-host", + "allow-list-groups", + "allow-add-group", + "allow-delete-group", + "allow-list-identities", + "allow-add-identity", + "allow-update-identity", + "allow-delete-identity", + "allow-vault-is-initialized", + "allow-secure-initialize-vault", + "allow-secure-unlock-vault", + "allow-secure-lock-vault", + "allow-secure-reset-vault", + "allow-is-vault-unlocked", + "allow-biometric-available", + "allow-biometric-passphrase-stored", + "allow-store-biometric-passphrase", + "allow-unlock-with-biometric", + "allow-clear-biometric-passphrase", + "allow-list-keys", + "allow-generate-key", + "allow-import-key", + "allow-delete-key", + "allow-list-known-hosts", + "allow-remove-known-host", + "allow-list-removed-known-hosts", + "allow-forget-known-host", + "allow-list-host-key-changes", + "allow-replace-known-host", + "allow-list-workspaces", + "allow-create-workspace", + "allow-save-workspace", + "allow-delete-workspace", + "allow-set-active-workspace", + "allow-connect-ssh", + "allow-create-local-terminal", + "allow-session-write", + "allow-session-resize", + "allow-close-session", + "allow-list-sessions", + "allow-sftp-connect", + "allow-sftp-list-dir", + "allow-sftp-canonicalize", + "allow-sftp-create-dir", + "allow-sftp-remove-file", + "allow-sftp-remove-dir", + "allow-sftp-rename", + "allow-sftp-close", + "allow-sftp-pick-upload-file", + "allow-sftp-download-to-local", + "allow-sftp-upload-from-local", + "allow-pick-key-file", + "allow-get-app-info", + "allow-check-for-updates", + "allow-install-update" ] } diff --git a/desktop/src-tauri/permissions/app-commands.toml b/desktop/src-tauri/permissions/app-commands.toml new file mode 100644 index 00000000..7fe22582 --- /dev/null +++ b/desktop/src-tauri/permissions/app-commands.toml @@ -0,0 +1,255 @@ +# One permission per command in generate_handler! in src/main.rs, granted in +# capabilities/default.json. +# +# Defining any app permission gives the app its own ACL manifest. Tauri checks +# app commands invoked from the app's own pages against the capability files +# only when that manifest exists; without it, any page the app serves, in any +# window, can invoke every registered command. With it, a command runs only +# for a window a capability grants it to. +# +# tests/app_command_acl.rs fails when this file, the handler list and the +# capability disagree. + +[[permission]] +identifier = "allow-startup-failure" +commands.allow = ["startup_failure"] + +[[permission]] +identifier = "allow-set-aside-unreadable-file" +commands.allow = ["set_aside_unreadable_file"] + +[[permission]] +identifier = "allow-restart-app" +commands.allow = ["restart_app"] + +[[permission]] +identifier = "allow-list-hosts" +commands.allow = ["list_hosts"] + +[[permission]] +identifier = "allow-add-host" +commands.allow = ["add_host"] + +[[permission]] +identifier = "allow-update-host" +commands.allow = ["update_host"] + +[[permission]] +identifier = "allow-delete-host" +commands.allow = ["delete_host"] + +[[permission]] +identifier = "allow-list-groups" +commands.allow = ["list_groups"] + +[[permission]] +identifier = "allow-add-group" +commands.allow = ["add_group"] + +[[permission]] +identifier = "allow-delete-group" +commands.allow = ["delete_group"] + +[[permission]] +identifier = "allow-list-identities" +commands.allow = ["list_identities"] + +[[permission]] +identifier = "allow-add-identity" +commands.allow = ["add_identity"] + +[[permission]] +identifier = "allow-update-identity" +commands.allow = ["update_identity"] + +[[permission]] +identifier = "allow-delete-identity" +commands.allow = ["delete_identity"] + +[[permission]] +identifier = "allow-vault-is-initialized" +commands.allow = ["vault_is_initialized"] + +[[permission]] +identifier = "allow-secure-initialize-vault" +commands.allow = ["secure_initialize_vault"] + +[[permission]] +identifier = "allow-secure-unlock-vault" +commands.allow = ["secure_unlock_vault"] + +[[permission]] +identifier = "allow-secure-lock-vault" +commands.allow = ["secure_lock_vault"] + +[[permission]] +identifier = "allow-secure-reset-vault" +commands.allow = ["secure_reset_vault"] + +[[permission]] +identifier = "allow-is-vault-unlocked" +commands.allow = ["is_vault_unlocked"] + +[[permission]] +identifier = "allow-biometric-available" +commands.allow = ["biometric_available"] + +[[permission]] +identifier = "allow-biometric-passphrase-stored" +commands.allow = ["biometric_passphrase_stored"] + +[[permission]] +identifier = "allow-store-biometric-passphrase" +commands.allow = ["store_biometric_passphrase"] + +[[permission]] +identifier = "allow-unlock-with-biometric" +commands.allow = ["unlock_with_biometric"] + +[[permission]] +identifier = "allow-clear-biometric-passphrase" +commands.allow = ["clear_biometric_passphrase"] + +[[permission]] +identifier = "allow-list-keys" +commands.allow = ["list_keys"] + +[[permission]] +identifier = "allow-generate-key" +commands.allow = ["generate_key"] + +[[permission]] +identifier = "allow-import-key" +commands.allow = ["import_key"] + +[[permission]] +identifier = "allow-delete-key" +commands.allow = ["delete_key"] + +[[permission]] +identifier = "allow-list-known-hosts" +commands.allow = ["list_known_hosts"] + +[[permission]] +identifier = "allow-remove-known-host" +commands.allow = ["remove_known_host"] + +[[permission]] +identifier = "allow-list-removed-known-hosts" +commands.allow = ["list_removed_known_hosts"] + +[[permission]] +identifier = "allow-forget-known-host" +commands.allow = ["forget_known_host"] + +[[permission]] +identifier = "allow-list-host-key-changes" +commands.allow = ["list_host_key_changes"] + +[[permission]] +identifier = "allow-replace-known-host" +commands.allow = ["replace_known_host"] + +[[permission]] +identifier = "allow-list-workspaces" +commands.allow = ["list_workspaces"] + +[[permission]] +identifier = "allow-create-workspace" +commands.allow = ["create_workspace"] + +[[permission]] +identifier = "allow-save-workspace" +commands.allow = ["save_workspace"] + +[[permission]] +identifier = "allow-delete-workspace" +commands.allow = ["delete_workspace"] + +[[permission]] +identifier = "allow-set-active-workspace" +commands.allow = ["set_active_workspace"] + +[[permission]] +identifier = "allow-connect-ssh" +commands.allow = ["connect_ssh"] + +[[permission]] +identifier = "allow-create-local-terminal" +commands.allow = ["create_local_terminal"] + +[[permission]] +identifier = "allow-session-write" +commands.allow = ["session_write"] + +[[permission]] +identifier = "allow-session-resize" +commands.allow = ["session_resize"] + +[[permission]] +identifier = "allow-close-session" +commands.allow = ["close_session"] + +[[permission]] +identifier = "allow-list-sessions" +commands.allow = ["list_sessions"] + +[[permission]] +identifier = "allow-sftp-connect" +commands.allow = ["sftp_connect"] + +[[permission]] +identifier = "allow-sftp-list-dir" +commands.allow = ["sftp_list_dir"] + +[[permission]] +identifier = "allow-sftp-canonicalize" +commands.allow = ["sftp_canonicalize"] + +[[permission]] +identifier = "allow-sftp-create-dir" +commands.allow = ["sftp_create_dir"] + +[[permission]] +identifier = "allow-sftp-remove-file" +commands.allow = ["sftp_remove_file"] + +[[permission]] +identifier = "allow-sftp-remove-dir" +commands.allow = ["sftp_remove_dir"] + +[[permission]] +identifier = "allow-sftp-rename" +commands.allow = ["sftp_rename"] + +[[permission]] +identifier = "allow-sftp-close" +commands.allow = ["sftp_close"] + +[[permission]] +identifier = "allow-sftp-pick-upload-file" +commands.allow = ["sftp_pick_upload_file"] + +[[permission]] +identifier = "allow-sftp-download-to-local" +commands.allow = ["sftp_download_to_local"] + +[[permission]] +identifier = "allow-sftp-upload-from-local" +commands.allow = ["sftp_upload_from_local"] + +[[permission]] +identifier = "allow-pick-key-file" +commands.allow = ["pick_key_file"] + +[[permission]] +identifier = "allow-get-app-info" +commands.allow = ["get_app_info"] + +[[permission]] +identifier = "allow-check-for-updates" +commands.allow = ["check_for_updates"] + +[[permission]] +identifier = "allow-install-update" +commands.allow = ["install_update"] diff --git a/desktop/src-tauri/src/command_acl_tests.rs b/desktop/src-tauri/src/command_acl_tests.rs new file mode 100644 index 00000000..3ba764e4 --- /dev/null +++ b/desktop/src-tauri/src/command_acl_tests.rs @@ -0,0 +1,101 @@ +//! The capability ACL as the shipped context applies it to a real invoke. +//! +//! `tests/app_command_acl.rs` checks that the command lists agree; this +//! checks what they add up to at runtime, through the same `on_message` path +//! the webview's IPC takes. It lives in the binary rather than under +//! `tests/` because a test executable that links Tauri needs the Windows +//! application manifest the build script attaches to binaries only. + +use tauri::ipc::{CallbackFn, InvokeBody}; +use tauri::test::{ + get_ipc_response, mock_builder, mock_context, noop_assets, MockRuntime, INVOKE_KEY, +}; +use tauri::webview::InvokeRequest; +use tauri::{App, Manager, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; + +/// Stands in for the app command of the same name. The ACL is keyed on the +/// command name alone, and the real handler takes the Wry runtime's +/// `AppHandle`, which the mock runtime cannot provide. +#[tauri::command] +fn get_app_info() -> &'static str { + "info" +} + +fn invoke_from(window: &WebviewWindow, url: &str) -> Result<(), String> { + get_ipc_response( + window, + InvokeRequest { + cmd: "get_app_info".into(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: url.parse().unwrap(), + body: InvokeBody::default(), + headers: Default::default(), + invoke_key: INVOKE_KEY.to_string(), + }, + ) + .map(|_| ()) + .map_err(|error| error.to_string()) +} + +fn window(app: &App, label: &str) -> WebviewWindow { + app.get_webview_window(label).unwrap_or_else(|| { + WebviewWindowBuilder::new(app, label, WebviewUrl::default()) + .build() + .unwrap() + }) +} + +/// The shipped context, with its ACL, decides who reaches an app command. +#[test] +fn only_the_main_window_on_a_local_page_reaches_app_commands() { + let app = mock_builder() + .invoke_handler(tauri::generate_handler![get_app_info]) + // `test = true` leaves out the macOS Info.plist embed, a fixed + // symbol that `main`'s own context already defines in this binary. + .build(tauri::generate_context!(test = true)) + .unwrap(); + // Test builds are dev builds, where the app's pages are served from devUrl. + let local = app + .config() + .build + .dev_url + .clone() + .expect("a devUrl") + .to_string(); + let main = window(&app, "main"); + let other = window(&app, "other"); + + assert_eq!(invoke_from(&main, &local), Ok(())); + + let from_other_window = invoke_from(&other, &local).unwrap_err(); + assert!( + from_other_window.contains("not allowed"), + "{from_other_window}" + ); + + let from_remote_page = invoke_from(&main, "https://example.com/").unwrap_err(); + assert!( + from_remote_page.contains("not allowed"), + "{from_remote_page}" + ); +} + +/// The same call against a context with no app manifest: any window on a +/// local page reaches the command. This is what `permissions/app-commands.toml` +/// changes, and why the test above is not vacuous. +#[test] +fn without_an_app_manifest_any_local_window_reaches_app_commands() { + let app = mock_builder() + .invoke_handler(tauri::generate_handler![get_app_info]) + .build(mock_context(noop_assets())) + .unwrap(); + let other = window(&app, "other"); + let local = if cfg!(windows) { + "http://tauri.localhost/" + } else { + "tauri://localhost/" + }; + + assert_eq!(invoke_from(&other, local), Ok(())); +} diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 9024747e..a4311c6c 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3,9 +3,12 @@ mod app_data_migration; mod biometric; mod biometric_commands; +#[cfg(test)] +mod command_acl_tests; mod commands; mod host_key_prompt; mod local_files; +mod navigation; mod sftp_transfer; mod startup_recovery; mod state; @@ -119,9 +122,9 @@ fn main() { } builder + .plugin(navigation::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .setup(|app| { diff --git a/desktop/src-tauri/src/navigation.rs b/desktop/src-tauri/src/navigation.rs new file mode 100644 index 00000000..5b9abdd4 --- /dev/null +++ b/desktop/src-tauri/src/navigation.rs @@ -0,0 +1,149 @@ +//! Keeps the app's webviews on the app's own pages. +//! +//! A page from anywhere else, loaded into the main window, is drawn inside +//! Clavyn's own frame, where it can pass for one of the app's screens and ask +//! for a vault passphrase or a host password. It cannot invoke the app's +//! commands, which are granted to local pages only, but it does not need to. +//! +//! Nothing in the app navigates its window: the UI is a single page, and the +//! links it shows open in the system browser through the shell plugin. So +//! every top-level navigation that would leave the app's origin is cancelled, +//! whatever started it: script in the page, or a link dropped onto the window. +//! +//! A cancelled navigation is not handed to the system browser instead. That +//! would let any script in the page open arbitrary URLs outside the scope the +//! shell plugin is configured with. +//! +//! A request for a new window is not a navigation of this webview, so +//! `window.open` and `target="_blank"` links never reach this hook. They open +//! nothing because no webview registers `on_new_window`: without that handler, +//! wry marks a WebView2 `NewWindowRequested` as handled without supplying a +//! window, its WKWebView UI delegate returns no webview, and WebKitGTK's +//! `create` signal is left to its default, which creates none. A webview that +//! registers one opens whatever its handler allows, so that handler has to +//! refuse URLs outside the app the way this hook does. + +use tauri::plugin::{Builder, TauriPlugin}; +use tauri::{Manager, Runtime, Url}; + +pub fn init() -> TauriPlugin { + Builder::new("navigation-guard") + .on_navigation(|webview, url| { + let dev_url = tauri::is_dev() + .then(|| webview.config().build.dev_url.clone()) + .flatten(); + let allowed = is_app_page(url, dev_url.as_ref(), cfg!(windows)); + if !allowed { + tracing::warn!( + "blocked webview {} from navigating to {}", + webview.label(), + url.origin().ascii_serialization() + ); + } + allowed + }) + .build() +} + +/// Whether `url` is one of the app's own pages. +/// +/// A dev build serves them from `devUrl`. A packaged build serves them through +/// Tauri's custom protocol, which WebView2 addresses as +/// `http://tauri.localhost` (or `https://` with `useHttpsScheme`) and the +/// other webviews as `tauri://localhost`. +/// +/// Both inputs that depend on the build are parameters, so every combination +/// is reachable from a single test build. +fn is_app_page(url: &Url, dev_url: Option<&Url>, webview2: bool) -> bool { + match dev_url { + Some(dev) => { + url.scheme() == dev.scheme() + && url.host() == dev.host() + && url.port_or_known_default() == dev.port_or_known_default() + } + None if webview2 => { + matches!(url.scheme(), "http" | "https") + && url.host_str() == Some("tauri.localhost") + && url.port().is_none() + } + None => url.scheme() == "tauri" && url.host_str() == Some("localhost"), + } +} + +#[cfg(test)] +mod tests { + use super::is_app_page; + use tauri::Url; + + /// The app pages in `allowed` pass and every URL in `refused` fails. + fn check(dev_url: Option<&str>, webview2: bool, allowed: &[&str], refused: &[&str]) { + let dev_url = dev_url.map(|u| Url::parse(u).unwrap()); + for url in allowed { + let parsed = Url::parse(url).unwrap(); + assert!(is_app_page(&parsed, dev_url.as_ref(), webview2), "{url}"); + } + for url in refused { + let parsed = Url::parse(url).unwrap(); + assert!(!is_app_page(&parsed, dev_url.as_ref(), webview2), "{url}"); + } + } + + #[test] + fn a_dev_build_stays_on_the_dev_server() { + for webview2 in [true, false] { + check( + Some("http://localhost:1420"), + webview2, + &[ + "http://localhost:1420/", + "http://localhost:1420/index.html?x#y", + ], + &[ + "http://localhost:1421/", + "https://localhost:1420/", + "http://127.0.0.1:1420/", + "http://tauri.localhost/", + "tauri://localhost/", + "https://github.com/", + ], + ); + } + } + + #[test] + fn a_packaged_build_on_webview2_stays_on_tauri_localhost() { + check( + None, + true, + &[ + "http://tauri.localhost/", + "https://tauri.localhost/index.html", + ], + &[ + "http://tauri.localhost:8080/", + "http://evil.tauri.localhost/", + "http://localhost/", + "tauri://localhost/", + "https://github.com/CodeAny-inc/Clavyn", + "file:///C:/Windows/win.ini", + "data:text/html,hi", + ], + ); + } + + #[test] + fn a_packaged_build_elsewhere_stays_on_the_tauri_scheme() { + check( + None, + false, + &["tauri://localhost/", "tauri://localhost/index.html"], + &[ + "tauri://evil/", + "http://tauri.localhost/", + "https://github.com/CodeAny-inc/Clavyn", + "file:///etc/passwd", + "about:blank", + ], + ); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index feafc1e0..ea4e152f 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ } ], "security": { - "csp": "default-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; frame-src 'none'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost" + "csp": "default-src 'self'; script-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; frame-src 'none'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self' ipc: http://ipc.localhost" } }, "bundle": { diff --git a/desktop/src-tauri/tests/app_command_acl.rs b/desktop/src-tauri/tests/app_command_acl.rs new file mode 100644 index 00000000..ea710a12 --- /dev/null +++ b/desktop/src-tauri/tests/app_command_acl.rs @@ -0,0 +1,180 @@ +//! Which pages and windows can invoke the app's own commands. +//! +//! The command list lives in three places: `generate_handler!` in +//! `src/main.rs`, the permissions in `permissions/app-commands.toml`, and the +//! grants in `capabilities/default.json`. They are read here as they ship rather than +//! restated, because a mismatch between them is silent until the command is +//! called: a handler command with no grant is rejected at runtime with a +//! permission error, which no build step reports. + +use std::collections::BTreeSet; + +/// The text between `open` and the next `close` after it. +fn between<'a>(text: &'a str, open: &str, close: &str) -> &'a str { + let start = text + .find(open) + .unwrap_or_else(|| panic!("{open} not found")) + + open.len(); + let len = text[start..] + .find(close) + .unwrap_or_else(|| panic!("{close} not found after {open}")); + &text[start..start + len] +} + +/// Command names registered in `generate_handler!`, without their module path. +fn handler_commands() -> Vec { + between(include_str!("../src/main.rs"), "generate_handler![", "]") + .split(',') + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(|path| path.rsplit("::").next().unwrap().to_string()) + .collect() +} + +/// The `[[permission]]` entries in `permissions/app-commands.toml`, as +/// (identifier, allowed commands). The file is kept to those two keys, so a +/// line-based read is exact; any other line fails here rather than being +/// skipped. +fn app_permissions() -> Vec<(String, Vec)> { + let quoted = |value: &str| -> Vec { + value + .split('"') + .skip(1) + .step_by(2) + .map(str::to_string) + .collect() + }; + let mut permissions: Vec<(String, Vec)> = Vec::new(); + for line in include_str!("../permissions/app-commands.toml").lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line == "[[permission]]" { + continue; + } + if let Some(value) = line.strip_prefix("identifier = ") { + permissions.push((quoted(value).concat(), Vec::new())); + } else if let Some(value) = line.strip_prefix("commands.allow = ") { + permissions + .last_mut() + .expect("commands.allow follows an identifier") + .1 = quoted(value); + } else { + panic!("unexpected line in app-commands.toml: {line}"); + } + } + permissions +} + +fn capability() -> serde_json::Value { + serde_json::from_str(include_str!("../capabilities/default.json")) + .expect("capabilities/default.json") +} + +fn granted_permissions() -> Vec { + capability()["permissions"] + .as_array() + .expect("a permissions list") + .iter() + .map(|p| p.as_str().expect("permissions are plain identifiers")) + .map(str::to_string) + .collect() +} + +/// App permissions the capability grants. Plugin permissions are prefixed +/// with the plugin's name (`shell:`, `core:event:`); app permissions are not. +fn granted_app_permissions() -> Vec { + granted_permissions() + .into_iter() + .filter(|p| !p.contains(':')) + .collect() +} + +fn unique(names: &[String], what: &str) -> BTreeSet { + let set: BTreeSet = names.iter().cloned().collect(); + assert_eq!(set.len(), names.len(), "{what} lists a command twice"); + set +} + +/// One `allow-` permission per registered command, each allowing +/// that command and nothing else. +#[test] +fn each_registered_command_has_its_own_permission() { + let mut declared = Vec::new(); + for (identifier, commands) in app_permissions() { + assert_eq!(commands.len(), 1, "{identifier} should allow one command"); + let expected = format!("allow-{}", commands[0].replace('_', "-")); + assert_eq!(identifier, expected, "named after the command it allows"); + declared.push(commands[0].clone()); + } + let declared = unique(&declared, "app-commands.toml"); + let handler = unique(&handler_commands(), "generate_handler!"); + assert!(!handler.is_empty()); + assert_eq!( + handler.difference(&declared).collect::>(), + Vec::<&String>::new(), + "registered in main.rs but missing from app-commands.toml" + ); + assert_eq!( + declared.difference(&handler).collect::>(), + Vec::<&String>::new(), + "in app-commands.toml but not registered in main.rs" + ); +} + +/// Every registered command is granted, through its own `allow-` permission +/// and nothing broader, so the capability file reads as the full list of what +/// the window can call. +#[test] +fn the_capability_grants_exactly_the_registered_commands() { + let expected: BTreeSet = handler_commands() + .iter() + .map(|name| format!("allow-{}", name.replace('_', "-"))) + .collect(); + let granted = unique(&granted_app_permissions(), "capabilities/default.json"); + assert_eq!( + expected.difference(&granted).collect::>(), + Vec::<&String>::new(), + "registered commands with no grant are rejected at runtime" + ); + assert_eq!( + granted.difference(&expected).collect::>(), + Vec::<&String>::new(), + "app permissions granted that are not one allow- per registered command" + ); +} + +/// The grant covers the main window and the app's own pages only. A `remote` +/// entry would hand the commands to whatever URL it matches. +#[test] +fn the_capability_is_limited_to_the_main_window_and_local_pages() { + let capability = capability(); + assert_eq!(capability["windows"], serde_json::json!(["main"])); + assert!(capability.get("webviews").is_none()); + assert!(capability.get("remote").is_none()); + assert_ne!(capability.get("local"), Some(&serde_json::json!(false))); +} + +/// Plugin commands are granted one by one, for what the frontend calls. A +/// plugin's `default` set is wider than its name suggests: `core:default` +/// alone lets the page replace the app and window menus and resolve the +/// user's home and data directories. +#[test] +fn plugin_permissions_are_the_ones_the_frontend_uses() { + let plugin: Vec = granted_permissions() + .into_iter() + .filter(|p| p.contains(':')) + .collect(); + assert_eq!( + plugin, + [ + // `listen` in src/api.ts, and the unlisten it returns. + "core:event:allow-listen", + "core:event:allow-unlisten", + // The devtools hotkey Tauri injects into debug builds. Release + // builds do not register the command, so the grant is inert there. + "core:webview:allow-internal-toggle-devtools", + // `open` from @tauri-apps/plugin-shell, scoped in tauri.conf.json to + // the project's GitHub page and its releases. + "shell:allow-open", + ] + ); +} diff --git a/desktop/src-tauri/tests/content_security_policy.rs b/desktop/src-tauri/tests/content_security_policy.rs index 1f25ce27..c0deb0d4 100644 --- a/desktop/src-tauri/tests/content_security_policy.rs +++ b/desktop/src-tauri/tests/content_security_policy.rs @@ -40,11 +40,20 @@ fn every_directive_that_does_not_inherit_is_declared() { } } -/// Tauri only rewrites a directive when the built assets yield hashes for -/// it, and the built page has no inline script, so leaving `script-src` -/// out is what lets Tauri synthesise it as `'self'` plus its bootstrap -/// hashes. Declaring one here would be overwritten or would fight it. +/// Script sources stay at `'self'` whatever `default-src` becomes. Tauri adds +/// to this directive rather than replacing it: it appends a hash of every +/// bundled script file, plus any inline-script hashes and nonces, keeps +/// `'self'`, and creates the directive only when it is missing. The header +/// a packaged build sends is this value followed by those hashes. No +/// `'unsafe-eval'` or `'wasm-unsafe-eval'`: the bundle uses neither eval nor +/// WebAssembly. #[test] -fn script_src_is_left_to_the_builder() { - assert!(!csp().contains("script-src")); +fn script_src_is_self_only() { + let csp = csp(); + let script_src = csp + .split(';') + .map(str::trim) + .find(|directive| directive.starts_with("script-src ")) + .expect("script-src is declared"); + assert_eq!(script_src, "script-src 'self'"); }