diff --git a/.changes/fix-fs-scope-escape-paths.md b/.changes/fix-fs-scope-escape-paths.md new file mode 100644 index 000000000..0430fb0a9 --- /dev/null +++ b/.changes/fix-fs-scope-escape-paths.md @@ -0,0 +1,6 @@ +--- +fs: minor +persisted-scope: minor +--- + +**Breaking Change:** Replaced the custom `tauri_plugin_fs::Scope` struct with `tauri::fs::Scope`. diff --git a/plugins/dialog/src/commands.rs b/plugins/dialog/src/commands.rs index 4129b7b6a..c3caf027f 100644 --- a/plugins/dialog/src/commands.rs +++ b/plugins/dialog/src/commands.rs @@ -143,7 +143,7 @@ pub(crate) async fn open( for folder in folders { if let Ok(path) = folder.clone().into_path() { if let Some(s) = window.try_fs_scope() { - s.allow_directory(&path, options.recursive); + s.allow_directory(&path, options.recursive)?; } tauri_scope.allow_directory(&path, options.directory)?; } @@ -157,7 +157,7 @@ pub(crate) async fn open( if let Some(folder) = &folder { if let Ok(path) = folder.clone().into_path() { if let Some(s) = window.try_fs_scope() { - s.allow_directory(&path, options.recursive); + s.allow_directory(&path, options.recursive)?; } tauri_scope.allow_directory(&path, options.directory)?; } @@ -175,7 +175,7 @@ pub(crate) async fn open( for file in files { if let Ok(path) = file.clone().into_path() { if let Some(s) = window.try_fs_scope() { - s.allow_file(&path); + s.allow_file(&path)?; } tauri_scope.allow_file(&path)?; @@ -190,7 +190,7 @@ pub(crate) async fn open( if let Some(file) = &file { if let Ok(path) = file.clone().into_path() { if let Some(s) = window.try_fs_scope() { - s.allow_file(&path); + s.allow_file(&path)?; } tauri_scope.allow_file(&path)?; } @@ -232,7 +232,7 @@ pub(crate) async fn save( if let Some(p) = &path { if let Ok(path) = p.clone().into_path() { if let Some(s) = window.try_fs_scope() { - s.allow_file(&path); + s.allow_file(&path)?; } tauri_scope.allow_file(&path)?; } diff --git a/plugins/fs/Cargo.toml b/plugins/fs/Cargo.toml index 4bdefa4d8..ceb0475c7 100644 --- a/plugins/fs/Cargo.toml +++ b/plugins/fs/Cargo.toml @@ -14,7 +14,7 @@ rustc-args = ["--cfg", "docsrs"] rustdoc-args = ["--cfg", "docsrs"] [package.metadata.platforms.support] -windows = { level = "full", notes = "" } +windows = { level = "full", notes = "Apps installed via MSI or NSIS in `perMachine` and `both` mode require admin permissions for write acces in `$RESOURCES` folder" } linux = { level = "full", notes = "No write access to `$RESOURCES` folder" } macos = { level = "full", notes = "No write access to `$RESOURCES` folder" } android = { level = "partial", notes = "Access is restricted to Application folder by default" } diff --git a/plugins/fs/src/commands.rs b/plugins/fs/src/commands.rs index b6a4493f7..85c866c7d 100644 --- a/plugins/fs/src/commands.rs +++ b/plugins/fs/src/commands.rs @@ -16,13 +16,13 @@ use std::{ borrow::Cow, fs::File, io::{BufRead, BufReader, Read, Write}, - path::PathBuf, + path::{Path, PathBuf}, str::FromStr, sync::Mutex, time::{SystemTime, UNIX_EPOCH}, }; -use crate::{scope::Entry, Error, FsExt, SafeFilePath}; +use crate::{scope::Entry, Error, SafeFilePath}; #[derive(Debug, thiserror::Error)] pub enum CommandError { @@ -942,6 +942,8 @@ pub fn resolve_file( path: SafeFilePath, open_options: OpenOptions, ) -> CommandResult<(File, PathBuf)> { + use crate::FsExt; + match path { SafeFilePath::Url(url) => { let path = url.as_str().into(); @@ -974,40 +976,81 @@ pub fn resolve_path( path }; + let fs_scope = webview.state::(); + let scope = tauri::scope::fs::Scope::new( webview, &FsScope::Scope { - allow: webview - .fs_scope() - .allowed - .lock() - .unwrap() - .clone() - .into_iter() - .chain(global_scope.allows().iter().filter_map(|e| e.path.clone())) + allow: global_scope + .allows() + .iter() + .filter_map(|e| e.path.clone()) .chain(command_scope.allows().iter().filter_map(|e| e.path.clone())) .collect(), - deny: webview - .fs_scope() - .denied - .lock() - .unwrap() - .clone() - .into_iter() - .chain(global_scope.denies().iter().filter_map(|e| e.path.clone())) + deny: global_scope + .denies() + .iter() + .filter_map(|e| e.path.clone()) .chain(command_scope.denies().iter().filter_map(|e| e.path.clone())) .collect(), - require_literal_leading_dot: webview.fs_scope().require_literal_leading_dot, + require_literal_leading_dot: fs_scope.require_literal_leading_dot, }, )?; - if scope.is_allowed(&path) { + let require_literal_leading_dot = fs_scope.require_literal_leading_dot.unwrap_or(cfg!(unix)); + + if is_forbidden(&fs_scope.scope, &path, require_literal_leading_dot) + || is_forbidden(&scope, &path, require_literal_leading_dot) + { + return Err(CommandError::Plugin(Error::PathForbidden(path))); + } + + if fs_scope.scope.is_allowed(&path) || scope.is_allowed(&path) { Ok(path) } else { Err(CommandError::Plugin(Error::PathForbidden(path))) } } +fn is_forbidden>( + scope: &tauri::fs::Scope, + path: P, + require_literal_leading_dot: bool, +) -> bool { + let path = path.as_ref(); + let path = if path.is_symlink() { + match std::fs::read_link(path) { + Ok(p) => p, + Err(_) => return false, + } + } else { + path.to_path_buf() + }; + let path = if !path.exists() { + crate::Result::Ok(path) + } else { + std::fs::canonicalize(path).map_err(Into::into) + }; + + if let Ok(path) = path { + let path: PathBuf = path.components().collect(); + scope.forbidden_patterns().iter().any(|p| { + p.matches_path_with( + &path, + glob::MatchOptions { + // this is needed so `/dir/*` doesn't match files within subdirectories such as `/dir/subdir/file.txt` + // see: + require_literal_separator: true, + require_literal_leading_dot, + ..Default::default() + }, + ) + }) + } else { + false + } +} + struct StdFileResource(Mutex); impl StdFileResource { diff --git a/plugins/fs/src/lib.rs b/plugins/fs/src/lib.rs index b48b24dcb..3240ccb3b 100644 --- a/plugins/fs/src/lib.rs +++ b/plugins/fs/src/lib.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use tauri::{ ipc::ScopeObject, plugin::{Builder as PluginBuilder, TauriPlugin}, - utils::acl::Value, + utils::{acl::Value, config::FsScope}, AppHandle, DragDropEvent, Manager, RunEvent, Runtime, WindowEvent, }; @@ -39,7 +39,6 @@ pub use desktop::Fs; pub use mobile::Fs; pub use error::Error; -pub use scope::{Event as ScopeEvent, Scope}; pub use file_path::FilePath; pub use file_path::SafeFilePath; @@ -365,21 +364,26 @@ impl ScopeObject for scope::Entry { } } +pub(crate) struct Scope { + pub(crate) scope: tauri::fs::Scope, + pub(crate) require_literal_leading_dot: Option, +} + pub trait FsExt { - fn fs_scope(&self) -> &Scope; - fn try_fs_scope(&self) -> Option<&Scope>; + fn fs_scope(&self) -> tauri::fs::Scope; + fn try_fs_scope(&self) -> Option; /// Cross platform file system APIs that also support manipulating Android files. fn fs(&self) -> &Fs; } impl> FsExt for T { - fn fs_scope(&self) -> &Scope { - self.state::().inner() + fn fs_scope(&self) -> tauri::fs::Scope { + self.state::().scope.clone() } - fn try_fs_scope(&self) -> Option<&Scope> { - self.try_state::().map(|s| s.inner()) + fn try_fs_scope(&self) -> Option { + self.try_state::().map(|s| s.scope.clone()) } fn fs(&self) -> &Fs { @@ -419,11 +423,13 @@ pub fn init() -> TauriPlugin> { watcher::unwatch ]) .setup(|app, api| { - let mut scope = Scope::default(); - scope.require_literal_leading_dot = api - .config() - .as_ref() - .and_then(|c| c.require_literal_leading_dot); + let scope = Scope { + require_literal_leading_dot: api + .config() + .as_ref() + .and_then(|c| c.require_literal_leading_dot), + scope: tauri::fs::Scope::new(app, &FsScope::default())?, + }; #[cfg(target_os = "android")] { @@ -446,9 +452,9 @@ pub fn init() -> TauriPlugin> { let scope = app.fs_scope(); for path in paths { if path.is_file() { - scope.allow_file(path); + let _ = scope.allow_file(path); } else { - scope.allow_directory(path, true); + let _ = scope.allow_directory(path, true); } } } diff --git a/plugins/fs/src/scope.rs b/plugins/fs/src/scope.rs index e8361d511..7914706a5 100644 --- a/plugins/fs/src/scope.rs +++ b/plugins/fs/src/scope.rs @@ -2,130 +2,18 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::{ - collections::HashMap, - path::{Path, PathBuf}, - sync::{ - atomic::{AtomicU32, Ordering}, - Mutex, - }, -}; +use std::path::PathBuf; use serde::Deserialize; -#[derive(Deserialize)] -#[serde(untagged)] -pub(crate) enum EntryRaw { - Value(PathBuf), - Object { path: PathBuf }, -} - #[derive(Debug)] pub struct Entry { pub path: Option, } -pub type EventId = u32; -type EventListener = Box; - -/// Scope change event. -#[derive(Debug, Clone)] -pub enum Event { - /// A path has been allowed. - PathAllowed(PathBuf), - /// A path has been forbidden. - PathForbidden(PathBuf), -} - -#[derive(Default)] -pub struct Scope { - pub(crate) allowed: Mutex>, - pub(crate) denied: Mutex>, - event_listeners: Mutex>, - next_event_id: AtomicU32, - pub(crate) require_literal_leading_dot: Option, -} - -impl Scope { - /// Extend the allowed patterns with the given directory. - /// - /// After this function has been called, the frontend will be able to use the Tauri API to read - /// the directory and all of its files. If `recursive` is `true`, subdirectories will be accessible too. - pub fn allow_directory>(&self, path: P, recursive: bool) { - let path = path.as_ref(); - - { - let mut allowed = self.allowed.lock().unwrap(); - allowed.push(path.to_path_buf()); - allowed.push(path.join(if recursive { "**" } else { "*" })); - } - - self.emit(Event::PathAllowed(path.to_path_buf())); - } - - /// Extend the allowed patterns with the given file path. - /// - /// After this function has been called, the frontend will be able to use the Tauri API to read the contents of this file. - pub fn allow_file>(&self, path: P) { - let path = path.as_ref(); - - self.allowed.lock().unwrap().push(path.to_path_buf()); - - self.emit(Event::PathAllowed(path.to_path_buf())); - } - - /// Set the given directory path to be forbidden by this scope. - /// - /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**. - pub fn forbid_directory>(&self, path: P, recursive: bool) { - let path = path.as_ref(); - - { - let mut denied = self.denied.lock().unwrap(); - denied.push(path.to_path_buf()); - denied.push(path.join(if recursive { "**" } else { "*" })); - } - - self.emit(Event::PathForbidden(path.to_path_buf())); - } - - /// Set the given file path to be forbidden by this scope. - /// - /// **Note:** this takes precedence over allowed paths, so its access gets denied **always**. - pub fn forbid_file>(&self, path: P) { - let path = path.as_ref(); - - self.denied.lock().unwrap().push(path.to_path_buf()); - - self.emit(Event::PathForbidden(path.to_path_buf())); - } - - /// List of allowed paths. - pub fn allowed(&self) -> Vec { - self.allowed.lock().unwrap().clone() - } - - /// List of forbidden paths. - pub fn forbidden(&self) -> Vec { - self.denied.lock().unwrap().clone() - } - - fn next_event_id(&self) -> u32 { - self.next_event_id.fetch_add(1, Ordering::Relaxed) - } - - fn emit(&self, event: Event) { - let listeners = self.event_listeners.lock().unwrap(); - let handlers = listeners.values(); - for listener in handlers { - listener(&event); - } - } - - /// Listen to an event on this scope. - pub fn listen(&self, f: F) -> EventId { - let id = self.next_event_id(); - self.event_listeners.lock().unwrap().insert(id, Box::new(f)); - id - } +#[derive(Deserialize)] +#[serde(untagged)] +pub(crate) enum EntryRaw { + Value(PathBuf), + Object { path: PathBuf }, } diff --git a/plugins/persisted-scope/src/lib.rs b/plugins/persisted-scope/src/lib.rs index f8dd8ab9d..6ced7b247 100644 --- a/plugins/persisted-scope/src/lib.rs +++ b/plugins/persisted-scope/src/lib.rs @@ -14,13 +14,11 @@ use serde::{Deserialize, Serialize}; use tauri::{ plugin::{Builder, TauriPlugin}, - scope::fs::Pattern as GlobPattern, Manager, Runtime, }; use tauri_plugin_fs::FsExt; use std::{ - collections::HashSet, fs::{create_dir_all, File}, io::Write, path::Path, @@ -44,81 +42,6 @@ const PATTERNS: &[&str] = &[ ]; const REPLACE_WITH: &[&str] = &[r"[", r"]", r"?", r"*", r"\?", r"\\?\", r"\\?\"]; -trait ScopeExt { - type Pattern: ToString; - - fn allow_file(&self, path: &Path); - fn allow_directory(&self, path: &Path, recursive: bool); - - fn forbid_file(&self, path: &Path); - fn forbid_directory(&self, path: &Path, recursive: bool); - - fn allowed_patterns(&self) -> HashSet; - fn forbidden_patterns(&self) -> HashSet; -} - -impl ScopeExt for tauri::scope::fs::Scope { - type Pattern = GlobPattern; - - fn allow_file(&self, path: &Path) { - let _ = tauri::scope::fs::Scope::allow_file(self, path); - } - - fn allow_directory(&self, path: &Path, recursive: bool) { - let _ = tauri::scope::fs::Scope::allow_directory(self, path, recursive); - } - - fn forbid_file(&self, path: &Path) { - let _ = tauri::scope::fs::Scope::forbid_file(self, path); - } - - fn forbid_directory(&self, path: &Path, recursive: bool) { - let _ = tauri::scope::fs::Scope::forbid_directory(self, path, recursive); - } - - fn allowed_patterns(&self) -> HashSet { - tauri::scope::fs::Scope::allowed_patterns(self) - } - - fn forbidden_patterns(&self) -> HashSet { - tauri::scope::fs::Scope::forbidden_patterns(self) - } -} - -impl ScopeExt for tauri_plugin_fs::Scope { - type Pattern = String; - - fn allow_file(&self, path: &Path) { - tauri_plugin_fs::Scope::allow_file(self, path); - } - - fn allow_directory(&self, path: &Path, recursive: bool) { - tauri_plugin_fs::Scope::allow_directory(self, path, recursive); - } - - fn forbid_file(&self, path: &Path) { - tauri_plugin_fs::Scope::forbid_file(self, path); - } - - fn forbid_directory(&self, path: &Path, recursive: bool) { - tauri_plugin_fs::Scope::forbid_directory(self, path, recursive); - } - - fn allowed_patterns(&self) -> HashSet { - self.allowed() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - } - - fn forbidden_patterns(&self) -> HashSet { - self.forbidden() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - } -} - #[derive(Debug, thiserror::Error)] enum Error { #[error(transparent)] @@ -179,41 +102,41 @@ fn fix_directory(path_str: &str) -> &Path { path } -fn allow_path(scope: &impl ScopeExt, path: &str) { +fn allow_path(scope: &tauri::fs::Scope, path: &str) { let target_type = detect_scope_type(path); match target_type { TargetType::File => { - scope.allow_file(Path::new(path)); + let _ = scope.allow_file(Path::new(path)); } TargetType::Directory => { // We remove the '*' at the end of it, else it will be escaped by the pattern. - scope.allow_directory(fix_directory(path), false); + let _ = scope.allow_directory(fix_directory(path), false); } TargetType::RecursiveDirectory => { // We remove the '**' at the end of it, else it will be escaped by the pattern. - scope.allow_directory(fix_directory(path), true); + let _ = scope.allow_directory(fix_directory(path), true); } } } -fn forbid_path(scope: &impl ScopeExt, path: &str) { +fn forbid_path(scope: &tauri::fs::Scope, path: &str) { let target_type = detect_scope_type(path); match target_type { TargetType::File => { - scope.forbid_file(Path::new(path)); + let _ = scope.forbid_file(Path::new(path)); } TargetType::Directory => { - scope.forbid_directory(fix_directory(path), false); + let _ = scope.forbid_directory(fix_directory(path), false); } TargetType::RecursiveDirectory => { - scope.forbid_directory(fix_directory(path), true); + let _ = scope.forbid_directory(fix_directory(path), true); } } } -fn save_scopes(scope: &impl ScopeExt, app_dir: &Path, scope_state_path: &Path) { +fn save_scopes(scope: &tauri::fs::Scope, app_dir: &Path, scope_state_path: &Path) { let scope = Scope { allowed_paths: scope .allowed_patterns() @@ -250,8 +173,11 @@ pub fn init() -> TauriPlugin { #[cfg(feature = "protocol-asset")] let asset_scope_state_path = app_dir.join(ASSET_SCOPE_STATE_FILENAME); - if let Some(fs_scope) = fs_scope { - fs_scope.forbid_file(&fs_scope_state_path); + if let Some(fs_scope) = &fs_scope { + let _ = fs_scope.forbid_file(&fs_scope_state_path); + } else { + #[cfg(debug_assertions)] + eprintln!("Please make sure to register the `fs` plugin before the `persisted-scope` plugin!"); } #[cfg(feature = "protocol-asset")] let _ = asset_protocol_scope.forbid_file(&asset_scope_state_path); @@ -260,7 +186,7 @@ pub fn init() -> TauriPlugin { // We will still save some semi-broken values because the scope events are quite spammy and we don't want to reduce runtime performance any further. let ac = AhoCorasick::new(PATTERNS).unwrap(/* This should be impossible to fail since we're using a small static input */); - if let Some(fs_scope) = fs_scope { + if let Some(fs_scope) = &fs_scope { if fs_scope_state_path.exists() { let scope: Scope = std::fs::read(&fs_scope_state_path) .map_err(Error::from) @@ -305,11 +231,11 @@ pub fn init() -> TauriPlugin { #[cfg(feature = "protocol-asset")] let app_dir_ = app_dir.clone(); - if let Some(fs_scope) = fs_scope { + if let Some(fs_scope) = &fs_scope { let app_ = app.clone(); fs_scope.listen(move |event| { - if let tauri_plugin_fs::ScopeEvent::PathAllowed(_) = event { - save_scopes(app_.fs_scope(), &app_dir, &fs_scope_state_path); + if let tauri::fs::Event::PathAllowed(_) = event { + save_scopes(&app_.fs_scope(), &app_dir, &fs_scope_state_path); } }); }