From 563d73c6807bdad2e74962a99b170bc358c03444 Mon Sep 17 00:00:00 2001 From: Azer Sadykhzadeh <51178055+Sadykhzadeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:55:07 +0400 Subject: [PATCH 1/7] Put the app's own commands behind the capability ACL Tauri checks a command invoked from the app's own pages against the capability files only when it is a plugin command or the app ships an ACL manifest. Clavyn shipped none, so capabilities/default.json gated plugin commands alone, and every command in generate_handler! -- the local shell, session_write, the vault, SFTP -- answered any window on a local page without consulting it. Remote pages were already refused: the runtime puts every non-local origin through the ACL whatever the manifest says. permissions/app-commands.toml declares one allow- permission per registered command, which gives the app a manifest, and the capability grants each one to the main window on local pages. Nothing else is granted them: another window, another webview or a remote page gets a permission error before the handler runs. The permissions are written out rather than generated by build.rs from a command list. tauri-build writes generated ones into the source tree, and an ignored leftover of them is picked up by the build of any commit without this change, giving that build a manifest with no grants, where every command is refused. The command list now lives in three places, and a command registered but not granted fails only when it is called. tests/app_command_acl.rs reads main.rs, the permissions file and the capability as they ship and fails when they disagree, and when the capability widens past the main window or adds a remote context. command_acl_tests.rs builds the shipped context on the mock runtime and invokes through on_message: the main window on a local page is served, another window and a remote page are refused, and the same call without a manifest is served from any local window. --- desktop/src-tauri/capabilities/default.json | 63 ++++- .../src-tauri/permissions/app-commands.toml | 255 ++++++++++++++++++ desktop/src-tauri/src/command_acl_tests.rs | 99 +++++++ desktop/src-tauri/src/main.rs | 2 + desktop/src-tauri/tests/app_command_acl.rs | 148 ++++++++++ 5 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/permissions/app-commands.toml create mode 100644 desktop/src-tauri/src/command_acl_tests.rs create mode 100644 desktop/src-tauri/tests/app_command_acl.rs diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 962531c1..881487cd 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -6,6 +6,67 @@ "permissions": [ "core:default", "shell:allow-open", - "os:default" + "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..b0748d6d --- /dev/null +++ b/desktop/src-tauri/src/command_acl_tests.rs @@ -0,0 +1,99 @@ +//! 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]) + .build(tauri::generate_context!()) + .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..3dc7fddb 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -3,6 +3,8 @@ mod app_data_migration; mod biometric; mod biometric_commands; +#[cfg(test)] +mod command_acl_tests; mod commands; mod host_key_prompt; mod local_files; 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..49a6585d --- /dev/null +++ b/desktop/src-tauri/tests/app_command_acl.rs @@ -0,0 +1,148 @@ +//! 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") +} + +/// App permissions the capability grants. Plugin permissions carry a +/// `plugin:` prefix; app permissions have none. +fn granted_app_permissions() -> Vec { + capability()["permissions"] + .as_array() + .expect("a permissions list") + .iter() + .map(|p| p.as_str().expect("permissions are plain identifiers")) + .filter(|p| !p.contains(':')) + .map(str::to_string) + .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))); +} From da3329647bed6fa5ed66e1c7f257ccd42ee0bb1c Mon Sep 17 00:00:00 2001 From: Azer Sadykhzadeh <51178055+Sadykhzadeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:49:00 +0400 Subject: [PATCH 2/7] Grant the webview only the plugin commands its frontend calls core:default bundles the default set of every core plugin: menu, path, app, image, window, webview, resources and tray. The frontend uses one of them, listen and unlisten from the event plugin. The rest let a script in the page replace the app and window menus with items of its choosing and resolve the user's home and data directories, among others. os:default granted the os plugin's locale command. Nothing in the frontend imports @tauri-apps/plugin-os. The capability now grants event listen and unlisten, the debug-build devtools hotkey Tauri injects (inert in release builds, which do not register the command), and shell open, which tauri.conf.json already scopes to the two GitHub URLs the UI opens. A test pins that list, so widening it is a visible change to the test as well as to the capability. --- desktop/src-tauri/capabilities/default.json | 5 +-- desktop/src-tauri/tests/app_command_acl.rs | 40 ++++++++++++++++++--- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 881487cd..1511670f 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -4,9 +4,10 @@ "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", diff --git a/desktop/src-tauri/tests/app_command_acl.rs b/desktop/src-tauri/tests/app_command_acl.rs index 49a6585d..ea710a12 100644 --- a/desktop/src-tauri/tests/app_command_acl.rs +++ b/desktop/src-tauri/tests/app_command_acl.rs @@ -69,19 +69,25 @@ fn capability() -> serde_json::Value { .expect("capabilities/default.json") } -/// App permissions the capability grants. Plugin permissions carry a -/// `plugin:` prefix; app permissions have none. -fn granted_app_permissions() -> Vec { +fn granted_permissions() -> Vec { capability()["permissions"] .as_array() .expect("a permissions list") .iter() .map(|p| p.as_str().expect("permissions are plain identifiers")) - .filter(|p| !p.contains(':')) .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"); @@ -146,3 +152,29 @@ fn the_capability_is_limited_to_the_main_window_and_local_pages() { 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", + ] + ); +} From bc9623ede3f80570dece42ad0dc8fe1aa03e69f7 Mon Sep 17 00:00:00 2001 From: Azer Sadykhzadeh <51178055+Sadykhzadeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:49:57 +0400 Subject: [PATCH 3/7] Keep the main window on the app's own pages The webview followed any top-level navigation: a script setting location, or a link dropped onto the window (the native drop handler is off, so the webview's own drop behaviour applies). A remote page loaded that way cannot invoke the app's commands, which are granted to local pages only, but it is drawn inside Clavyn's window, where it can pass for the vault unlock screen or a host password prompt. The app never navigates its window -- it is a single page, and its external links go through shell open -- so a plugin now cancels every navigation that leaves the app's origin: devUrl in a dev build, and the custom protocol (tauri.localhost on WebView2, tauri://localhost elsewhere) in a packaged one. A refused URL is logged by origin and not passed to the system browser, which would give page script an open outside the shell plugin's scope. --- desktop/src-tauri/src/main.rs | 2 + desktop/src-tauri/src/navigation.rs | 140 ++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 desktop/src-tauri/src/navigation.rs diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 3dc7fddb..1e833928 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod command_acl_tests; mod commands; mod host_key_prompt; mod local_files; +mod navigation; mod sftp_transfer; mod startup_recovery; mod state; @@ -121,6 +122,7 @@ fn main() { } builder + .plugin(navigation::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_os::init()) diff --git a/desktop/src-tauri/src/navigation.rs b/desktop/src-tauri/src/navigation.rs new file mode 100644 index 00000000..e17a21e7 --- /dev/null +++ b/desktop/src-tauri/src/navigation.rs @@ -0,0 +1,140 @@ +//! 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. + +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", + ], + ); + } +} From 1cf0ddaf6cfcf3437ddbe4c5bf8e034f8ee741a1 Mon Sep 17 00:00:00 2001 From: Azer Sadykhzadeh <51178055+Sadykhzadeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:55:22 +0400 Subject: [PATCH 4/7] Declare script-src 'self' in the CSP Scripts were governed by default-src alone, so any source later added to default-src for images, fonts or a new connection would also have become a place scripts load from. script-src now says 'self' itself. The policy test asserted the directive's absence on the grounds that Tauri would overwrite a declared one. It does not: it appends a hash of every bundled script file, and any inline-script hashes and nonces, to the declared directive, keeps 'self', and creates the directive only when it is missing. A packaged build sends the same script-src either way: 'self' followed by the bundle's hashes. The test now pins the declared value, without 'unsafe-eval' or 'wasm-unsafe-eval'; the bundle uses neither eval nor WebAssembly. --- desktop/src-tauri/tauri.conf.json | 2 +- .../tests/content_security_policy.rs | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) 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/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'"); } From e5ccd924bd32fa58348abe50369f44443fafdc65 Mon Sep 17 00:00:00 2001 From: Azer Sadykhzadeh <51178055+Sadykhzadeh@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:19:04 +0400 Subject: [PATCH 5/7] Build the ACL test's context without the macOS Info.plist embed generate_context! embeds Info.plist on macOS under a fixed symbol, and the desktop binary's own context already defines it, so the test build failed to link on macOS with _EMBED_INFO_PLIST defined twice. test = true skips only that embed; the capabilities and ACL under test are the same. --- desktop/src-tauri/src/command_acl_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/command_acl_tests.rs b/desktop/src-tauri/src/command_acl_tests.rs index b0748d6d..3ba764e4 100644 --- a/desktop/src-tauri/src/command_acl_tests.rs +++ b/desktop/src-tauri/src/command_acl_tests.rs @@ -51,7 +51,9 @@ fn window(app: &App, label: &str) -> WebviewWindow { fn only_the_main_window_on_a_local_page_reaches_app_commands() { let app = mock_builder() .invoke_handler(tauri::generate_handler![get_app_info]) - .build(tauri::generate_context!()) + // `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 From b832b0f4f4b111aedaac2c2a12d0ac8058f37bf8 Mon Sep 17 00:00:00 2001 From: Huseyn Hajiyev Date: Sun, 18 Oct 2026 17:35:34 +0000 Subject: [PATCH 6/7] Stop loading the OS plugin os:default left the capability because nothing in the frontend imports @tauri-apps/plugin-os, but the plugin itself stayed registered. Its init script runs in every page whatever the capability grants and defines window.__TAURI_OS_PLUGIN_INTERNALS__ with the platform, OS type, family, version, architecture, EOL and executable extension, so page script could still read them with no grant. Nothing in the app reads them. Dropping the plugin and the crate removes that, along with os_info, sys-locale and gethostname, which only it pulled in. --- Cargo.lock | 54 ----------------------------------- desktop/src-tauri/Cargo.toml | 1 - desktop/src-tauri/src/main.rs | 1 - 3 files changed, 56 deletions(-) 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/src/main.rs b/desktop/src-tauri/src/main.rs index 1e833928..a4311c6c 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -125,7 +125,6 @@ fn main() { .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| { From f5f897239e68f16619b4ff5fa80a0f42bba3e040 Mon Sep 17 00:00:00 2001 From: Huseyn Hajiyev Date: Sun, 18 Oct 2026 17:35:34 +0000 Subject: [PATCH 7/7] Say what keeps new windows from opening The navigation guard sees top-level navigations of the webview it is attached to. window.open and target=_blank links request a new window instead, which never reaches on_navigation, and the module doc did not say what handles them. They open nothing because no webview registers on_new_window. With no handler, wry 0.57 marks a WebView2 NewWindowRequested as handled without supplying a window, its WKWebView UI delegate returns none, and WebKitGTK falls back to its default create handler, which makes none. The doc now says so, and that a webview which registers a handler takes on refusing non-app URLs itself. --- desktop/src-tauri/src/navigation.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/desktop/src-tauri/src/navigation.rs b/desktop/src-tauri/src/navigation.rs index e17a21e7..5b9abdd4 100644 --- a/desktop/src-tauri/src/navigation.rs +++ b/desktop/src-tauri/src/navigation.rs @@ -13,6 +13,15 @@ //! 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};