diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 04ae30be0d17fe8bd333b715f6b7ff448808e699..e9d49c3163c632243b52e7e8ac49067244914526 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -132,6 +132,9 @@ build-repro: integration: stage: test image: debian:stable-slim + variables: + # The build environment here runs as root and seems to have umask 000. + ARTI_FS_DISABLE_PERMISSION_CHECKS: "true" script: - apt update - apt install -y tor git python3 curl dnsutils diff --git a/Cargo.lock b/Cargo.lock index d1e8c4852320b475dfc39de2262658890a43caaf..f42c913068bd4240482582fa15dee728cbc86855 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,6 +81,7 @@ dependencies = [ "clap", "config", "derive_builder_fork_arti", + "fs-mistrust", "futures", "libc", "notify", @@ -131,6 +132,7 @@ dependencies = [ "derive_more", "directories", "educe", + "fs-mistrust", "futures", "humantime-serde", "once_cell", @@ -3470,6 +3472,7 @@ dependencies = [ "educe", "event-listener", "float_eq", + "fs-mistrust", "fslock", "futures", "futures-await-test", @@ -3663,6 +3666,7 @@ dependencies = [ name = "tor-persist" version = "0.3.0" dependencies = [ + "fs-mistrust", "fslock", "sanitize-filename", "serde", diff --git a/crates/arti-client/Cargo.toml b/crates/arti-client/Cargo.toml index c31e2c1ba3303eef6f3f0535dec13e68dd5560f6..22462ae13b52eec7a01dc3c2644ee58ff07f3dba 100644 --- a/crates/arti-client/Cargo.toml +++ b/crates/arti-client/Cargo.toml @@ -31,6 +31,7 @@ error_detail = [] experimental-api = [] [dependencies] +fs-mistrust = { path = "../fs-mistrust", version = "0.1.0" } safelog = { path = "../safelog", version = "0.1.0" } tor-basic-utils = { path = "../tor-basic-utils", version = "0.3.0"} tor-circmgr = { path = "../tor-circmgr", version = "0.3.0"} diff --git a/crates/arti-client/src/builder.rs b/crates/arti-client/src/builder.rs index f99a22f1473bdb0829fe0d8286cb54dfeb1f68dc..5771eb564f945113ae50bf21d326b170b43903dc 100644 --- a/crates/arti-client/src/builder.rs +++ b/crates/arti-client/src/builder.rs @@ -51,6 +51,8 @@ pub struct TorClientBuilder<R: Runtime> { /// How the client should behave when it is asked to do something on the Tor /// network before `bootstrap()` is called. bootstrap_behavior: BootstrapBehavior, + /// How the client should decide which file permissions to trust. + fs_mistrust: Option<fs_mistrust::Mistrust>, /// Optional object to construct a DirProvider. /// /// Wrapped in an Arc so that we don't need to force DirProviderBuilder to @@ -70,6 +72,7 @@ impl<R: Runtime> TorClientBuilder<R> { runtime, config: TorClientConfig::default(), bootstrap_behavior: BootstrapBehavior::default(), + fs_mistrust: None, dirmgr_builder: Arc::new(DirMgrBuilder {}), #[cfg(feature = "dirfilter")] dirfilter: None, @@ -93,6 +96,31 @@ impl<R: Runtime> TorClientBuilder<R> { self } + /// Build an [`TorClient`] that will not validate permissions and + /// ownership on the filesystem. + /// + /// By default, these checks are enabled, unless the + /// `ARTI_FS_DISABLE_PERMISSION_CHECKS` environment variable has been set or + /// this method has been called. + pub fn disable_fs_permission_checks(mut self) -> Self { + let mut mistrust = fs_mistrust::Mistrust::new(); + mistrust.dangerously_trust_everyone(); + self.fs_mistrust = Some(mistrust); + self + } + + /// Build an [`TorClient`] that will always validate permissions and + /// ownership on the filesystem. + /// + /// By default, these checks are enabled, unless the + /// `ARTI_FS_DISABLE_PERMISSION_CHECKS` environment variable has been set or + /// [`disable_fs_permission_checks`](Self::disable_fs_permission_checks) + /// method has been called. + pub fn enable_fs_permission_checks(mut self) -> Self { + self.fs_mistrust = Some(fs_mistrust::Mistrust::new()); + self + } + /// Override the default function used to construct the directory provider. /// /// Only available when compiled with the `experimental-api` feature: this @@ -146,6 +174,8 @@ impl<R: Runtime> TorClientBuilder<R> { self.runtime, self.config, self.bootstrap_behavior, + self.fs_mistrust + .unwrap_or_else(crate::config::default_fs_mistrust), self.dirmgr_builder.as_ref(), dirmgr_extensions, ) diff --git a/crates/arti-client/src/client.rs b/crates/arti-client/src/client.rs index 36da9ce3844dfd19aaba4457e8716f39e759c520..e328fbde3dd833b308f80d8062cffd3041ed068a 100644 --- a/crates/arti-client/src/client.rs +++ b/crates/arti-client/src/client.rs @@ -88,6 +88,9 @@ pub struct TorClient<R: Runtime> { /// Shared boolean for whether we're currently in "dormant mode" or not. dormant: Arc<AtomicBool>, + + /// Settings for how we perform permissions checks on the filesystem. + fs_mistrust: fs_mistrust::Mistrust, } /// Preferences for whether a [`TorClient`] should bootstrap on its own or not. @@ -350,15 +353,17 @@ impl<R: Runtime> TorClient<R> { runtime: R, config: TorClientConfig, autobootstrap: BootstrapBehavior, + mistrust: fs_mistrust::Mistrust, dirmgr_builder: &dyn crate::builder::DirProviderBuilder<R>, dirmgr_extensions: tor_dirmgr::config::DirMgrExtensions, ) -> StdResult<Self, ErrorDetail> { let dir_cfg = { - let mut c: tor_dirmgr::DirMgrConfig = (&config).try_into()?; + let mut c: tor_dirmgr::DirMgrConfig = config.dir_mgr_config(mistrust.clone())?; c.extensions = dirmgr_extensions; c }; - let statemgr = FsStateMgr::from_path(config.storage.expand_state_dir()?)?; + let statemgr = + FsStateMgr::from_path_and_mistrust(config.storage.expand_state_dir()?, &mistrust)?; let addr_cfg = config.address_filter.clone(); let (status_sender, status_receiver) = postage::watch::channel(); @@ -415,6 +420,7 @@ impl<R: Runtime> TorClient<R> { should_bootstrap: autobootstrap, periodic_task_handles, dormant: Arc::new(AtomicBool::new(false)), + fs_mistrust: mistrust, }) } @@ -543,7 +549,9 @@ impl<R: Runtime> TorClient<R> { _ => {} } - let dir_cfg = new_config.try_into().map_err(wrap_err)?; + let dir_cfg = new_config + .dir_mgr_config(self.fs_mistrust.clone()) + .map_err(wrap_err)?; let state_cfg = new_config.storage.expand_state_dir().map_err(wrap_err)?; let addr_cfg = &new_config.address_filter; let timeout_cfg = &new_config.stream_timeouts; diff --git a/crates/arti-client/src/config.rs b/crates/arti-client/src/config.rs index 8ceb52b2b8920bcc9c3f659080d6a6e12e888d1b..2ec4907cbd14471765ba300c9ad882e050df182e 100644 --- a/crates/arti-client/src/config.rs +++ b/crates/arti-client/src/config.rs @@ -321,20 +321,19 @@ impl TorClientConfig { pub fn builder() -> TorClientConfigBuilder { TorClientConfigBuilder::default() } -} - -impl TryInto<dir::DirMgrConfig> for &TorClientConfig { - type Error = ConfigBuildError; + /// Try to create a DirMgrConfig corresponding to this object. #[rustfmt::skip] - fn try_into(self) -> Result<dir::DirMgrConfig, ConfigBuildError> { + pub(crate) fn dir_mgr_config(&self, mistrust: fs_mistrust::Mistrust) -> Result<dir::DirMgrConfig, ConfigBuildError> { Ok(dir::DirMgrConfig { network: self.tor_network .clone(), schedule: self.download_schedule .clone(), cache_path: self.storage.expand_cache_dir()?, + cache_trust: mistrust, override_net_params: self.override_net_params.clone(), extensions: Default::default(), }) + } } @@ -356,6 +355,21 @@ impl TorClientConfigBuilder { } } +/// Return a default value for our fs_mistrust configuration. +/// +/// This is based on the environment rather on the configuration, since we may +/// want to use it to determine whether configuration files are safe to read. +// +// TODO: This function should probably go away and become part of our storage +// configuration code, once we have made Mistrust configurable via Deserialize. +pub(crate) fn default_fs_mistrust() -> fs_mistrust::Mistrust { + let mut mistrust = fs_mistrust::Mistrust::new(); + if std::env::var_os("ARTI_FS_DISABLE_PERMISSION_CHECKS").is_some() { + mistrust.dangerously_trust_everyone(); + } + mistrust +} + #[cfg(test)] mod test { #![allow(clippy::unwrap_used)] diff --git a/crates/arti/Cargo.toml b/crates/arti/Cargo.toml index c05f405ad852581983fe2a2cf894f9d982418e4a..80895940d49bf0d4a16b94782da7ef1e26be22b6 100644 --- a/crates/arti/Cargo.toml +++ b/crates/arti/Cargo.toml @@ -24,6 +24,7 @@ journald = ["tracing-journald"] [dependencies] safelog = { path = "../safelog", version = "0.1.0" } +fs-mistrust = { path = "../fs-mistrust", version = "0.1.0" } arti-client = { package = "arti-client", path = "../arti-client", version = "0.3.0", default-features = false } tor-config = { path = "../tor-config", version = "0.3.0"} tor-error = { path = "../tor-error", version = "0.3.0", default-features = false } diff --git a/crates/arti/src/lib.rs b/crates/arti/src/lib.rs index 302a641d5eccce7f926817430f682317209a13ce..eb48c2c228e977aba96ba4ffd74f62ad952b5ed1 100644 --- a/crates/arti/src/lib.rs +++ b/crates/arti/src/lib.rs @@ -152,15 +152,21 @@ pub async fn run<R: Runtime>( config_sources: arti_config::ConfigurationSources, arti_config: ArtiConfig, client_config: TorClientConfig, + fs_mistrust_disabled: bool, ) -> Result<()> { // Using OnDemand arranges that, while we are bootstrapping, incoming connections wait // for bootstrap to complete, rather than getting errors. use arti_client::BootstrapBehavior::OnDemand; use futures::FutureExt; - let client = TorClient::with_runtime(runtime.clone()) + let mut client_builder = TorClient::with_runtime(runtime.clone()) .config(client_config) - .bootstrap_behavior(OnDemand) - .create_unbootstrapped()?; + .bootstrap_behavior(OnDemand); + if fs_mistrust_disabled { + client_builder = client_builder.disable_fs_permission_checks(); + } else { + client_builder = client_builder.enable_fs_permission_checks(); + } + let client = client_builder.create_unbootstrapped()?; if arti_config.application().watch_configuration { watch_cfg::watch_for_config_changes(config_sources, arti_config, client.clone())?; } @@ -205,6 +211,15 @@ pub async fn run<R: Runtime>( ) } +/// Return true if the environment has been set up to disable FS mistrust. +// +// TODO(nickm): This is duplicate logic from arti_client config. When we make +// fs_mistrust configurable via deserialize, as a real part of our configuration +// logic, we should unify all this code. +fn fs_mistrust_disabled_via_env() -> bool { + std::env::var_os("ARTI_FS_DISABLE_PERMISSION_CHECKS").is_some() +} + /// Inner function to allow convenient error handling /// /// # Panics @@ -259,6 +274,14 @@ pub fn main_main() -> Result<()> { .value_name("LEVEL") .help("Override the log level (usually one of 'trace', 'debug', 'info', 'warn', 'error')."), ) + .arg( + Arg::with_name("disable-fs-permission-checks") + .long("disable-fs-permission-checks") + .takes_value(false) + .value_name("FILE") + .global(true) + .help("Don't check permissions on the files we use."), + ) .subcommand( SubCommand::with_name("proxy") .about( @@ -282,16 +305,38 @@ pub fn main_main() -> Result<()> { .setting(AppSettings::SubcommandRequiredElseHelp) .get_matches(); + let fs_mistrust_disabled = + fs_mistrust_disabled_via_env() | matches.is_present("disable-fs-permission-checks"); + + let mistrust = { + // TODO: This is duplicate code from arti_client::config. When we make + // fs_mistrust configurable via deserialize, as a real part of our configuration + // logic, we should unify this check. + let mut mistrust = fs_mistrust::Mistrust::new(); + if fs_mistrust_disabled { + mistrust.dangerously_trust_everyone(); + } + mistrust + }; + let cfg_sources = { let mut cfg_sources = arti_config::ConfigurationSources::new(); let config_files = matches.values_of_os("config-files").unwrap_or_default(); if config_files.len() == 0 { if let Some(default) = default_config_file() { + match mistrust.verifier().require_file().check(&default) { + Ok(()) => {} + Err(fs_mistrust::Error::NotFound(_)) => {} + Err(e) => return Err(e.into()), + } cfg_sources.push_optional_file(default); } } else { - config_files.for_each(|f| cfg_sources.push_file(f)); + for f in config_files { + mistrust.verifier().require_file().check(f)?; + cfg_sources.push_file(f); + } } matches @@ -356,6 +401,7 @@ pub fn main_main() -> Result<()> { cfg_sources, config, client_config, + fs_mistrust_disabled, ))?; Ok(()) } else { diff --git a/crates/fs-mistrust/src/dir.rs b/crates/fs-mistrust/src/dir.rs index f799622633b7a883ab42bb4783ed6ae9d3749afe..28f9e0efac976e84aa813dd5af36e11d656841df 100644 --- a/crates/fs-mistrust/src/dir.rs +++ b/crates/fs-mistrust/src/dir.rs @@ -3,6 +3,7 @@ use std::{ fs::{File, OpenOptions}, + io::{Read, Write}, path::{Path, PathBuf}, }; @@ -30,6 +31,7 @@ use std::os::unix::fs::OpenOptionsExt; /// APIs. /// /// See also the crate-level [Limitations](crate#limitations) section. +#[derive(Debug, Clone)] pub struct CheckedDir { /// The `Mistrust` object whose rules we apply to members of this directory. mistrust: Mistrust, @@ -102,9 +104,7 @@ impl CheckedDir { options.custom_flags(libc::O_NOFOLLOW); } - let file = options - .open(&path) - .map_err(|e| Error::inspecting(e, &path))?; + let file = options.open(&path).map_err(|e| Error::io(e, &path))?; let meta = file.metadata().map_err(|e| Error::inspecting(e, &path))?; if let Some(error) = self @@ -128,6 +128,90 @@ impl CheckedDir { self.location.as_path() } + /// Return a new [`PathBuf`] containing this directory's path, with `path` + /// appended to it. + /// + /// Return an error if `path` has any components that could take us outside + /// of this directory. + pub fn join<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf> { + let path = path.as_ref(); + self.check_path(path)?; + Ok(self.location.join(path)) + } + + /// Read the contents of the file at `path` within this directory, as a + /// String, if possible. + /// + /// Return an error if `path` is absent, if its permissions are incorrect, + /// if it has any components that could take us outside of this directory, + /// or if its contents are not UTF-8. + pub fn read_to_string<P: AsRef<Path>>(&self, path: P) -> Result<String> { + let path = path.as_ref(); + let mut file = self.open(path, OpenOptions::new().read(true))?; + let mut result = String::new(); + file.read_to_string(&mut result) + .map_err(|e| Error::io(e, path))?; + Ok(result) + } + + /// Read the contents of the file at `path` within this directory, as a + /// vector of bytes, if possible. + /// + /// Return an error if `path` is absent, if its permissions are incorrect, + /// or if it has any components that could take us outside of this + /// directory. + pub fn read<P: AsRef<Path>>(&self, path: P) -> Result<Vec<u8>> { + let path = path.as_ref(); + let mut file = self.open(path, OpenOptions::new().read(true))?; + let mut result = Vec::new(); + file.read_to_end(&mut result) + .map_err(|e| Error::io(e, path))?; + Ok(result) + } + + /// Store `contents` into the file located at `path` within this directory. + /// + /// We won't write to `path` directly: instead, we'll write to a temporary + /// file in the same directory as `path`, and then replace `path` with that + /// temporary file if we were successful. (This isn't truly atomic on all + /// file systems, but it's closer than many alternatives.) + /// + /// # Limitations + /// + /// This function will clobber any existing files with the same name as + /// `path` but with the extension `tmp`. (That is, if you are writing to + /// "foo.txt", it will replace "foo.tmp" in the same directory.) + /// + /// This function may give incorrect behavior if multiple threads or + /// processes are writing to the same file at the same time: it is the + /// programmer's responsibility to use appropriate locking to avoid this. + pub fn write_and_replace<P: AsRef<Path>, C: AsRef<[u8]>>( + &self, + path: P, + contents: C, + ) -> Result<()> { + let path = path.as_ref(); + self.check_path(path)?; + + let tmp_name = path.with_extension("tmp"); + let mut tmp_file = self.open( + &tmp_name, + OpenOptions::new().create(true).truncate(true).write(true), + )?; + + // Write the data. + tmp_file + .write_all(contents.as_ref()) + .map_err(|e| Error::io(e, &tmp_name))?; + // Flush and close. + drop(tmp_file); + + // Replace the old file. + std::fs::rename(self.location.join(tmp_name), self.location.join(path)) + .map_err(|e| Error::io(e, path))?; + Ok(()) + } + /// Helper: create a [`Verifier`] with the appropriate rules for this /// `CheckedDir`. fn verifier(&self) -> Verifier<'_> { @@ -142,7 +226,10 @@ impl CheckedDir { /// guaranteed to stay within this directory. fn check_path(&self, p: &Path) -> Result<()> { use std::path::Component; - if p.is_absolute() {} + // This check should be redundant, but let's be certain. + if p.is_absolute() { + return Err(Error::InvalidSubdirectory); + } for component in p.components() { match component { @@ -239,4 +326,42 @@ mod test { sd.make_directory("hello/world").unwrap(); } + + #[test] + fn read_and_write() { + let d = Dir::new(); + d.dir("a"); + d.chmod("a", 0o700); + let mut m = Mistrust::new(); + m.ignore_prefix(d.canonical_root()).unwrap(); + + let checked = m.verifier().secure_dir(d.path("a")).unwrap(); + + // Simple case: write and read. + checked + .write_and_replace("foo.txt", "this is incredibly silly") + .unwrap(); + + let s1 = checked.read_to_string("foo.txt").unwrap(); + let s2 = checked.read("foo.txt").unwrap(); + assert_eq!(s1, "this is incredibly silly"); + assert_eq!(s1.as_bytes(), &s2[..]); + + // Trickier: write when the preferred temporary already has content. + checked + .open("bar.tmp", OpenOptions::new().create(true).write(true)) + .unwrap() + .write_all("be the other guy".as_bytes()) + .unwrap(); + assert!(checked.join("bar.tmp").unwrap().exists()); + + checked + .write_and_replace("bar.txt", "its hard and nobody understands") + .unwrap(); + + // Temp file should be gone. + assert!(!checked.join("bar.tmp").unwrap().exists()); + let s4 = checked.read_to_string("bar.txt").unwrap(); + assert_eq!(s4, "its hard and nobody understands"); + } } diff --git a/crates/fs-mistrust/src/err.rs b/crates/fs-mistrust/src/err.rs index cf699d09cb6a59b500ff9b36791b2f6f739a16d3..79e3846f6102fb86c7cdcd97d1f727c7cceb1e0f 100644 --- a/crates/fs-mistrust/src/err.rs +++ b/crates/fs-mistrust/src/err.rs @@ -83,15 +83,23 @@ pub enum Error { #[error("Unable to list directory")] Listing(#[source] Arc<walkdir::Error>), - /// We were unable to open a file with [`CheckedDir::open`](crate::CheckedDir::open) - /// Tried to use an invalid path with a [`CheckedDir`](crate::CheckedDir), #[error("Path was not valid for use with CheckedDir.")] InvalidSubdirectory, + + /// We encountered an error while attempting an IO operation on a file. + #[error("IO error on {0}")] + Io(PathBuf, #[source] Arc<IoError>), + + /// We could not create an unused temporary path when trying to write a + /// file. + #[error("Could not name temporary file for {0}")] + NoTempFile(PathBuf), } impl Error { - /// Create an error from an IoError object. + /// Create an error from an IoError encountered while inspecting permissions + /// on an object. pub(crate) fn inspecting(err: IoError, fname: impl Into<PathBuf>) -> Self { match err.kind() { IoErrorKind::NotFound => Error::NotFound(fname.into()), @@ -99,6 +107,15 @@ impl Error { } } + /// Create an error from an IoError encountered while performing IO (open, + /// read, write) on an object. + pub(crate) fn io(err: IoError, fname: impl Into<PathBuf>) -> Self { + match err.kind() { + IoErrorKind::NotFound => Error::NotFound(fname.into()), + _ => Error::Io(fname.into(), Arc::new(err)), + } + } + /// Return the path, if any, associated with this error. pub fn path(&self) -> Option<&Path> { Some( @@ -108,6 +125,8 @@ impl Error { Error::BadOwner(pb, _) => pb, Error::BadType(pb) => pb, Error::CouldNotInspect(pb, _) => pb, + Error::Io(pb, _) => pb, + Error::NoTempFile(pb) => pb, Error::Multiple(_) => return None, Error::StepsExceeded => return None, Error::CurrentDirectory(_) => return None, @@ -120,6 +139,30 @@ impl Error { ) } + /// Return true iff this error indicates a problem with filesystem + /// permissions. + /// + /// (Other errors typically indicate an IO problem, possibly one preventing + /// us from looking at permissions in the first place) + pub fn is_bad_permission(&self) -> bool { + match self { + Error::BadPermission(_, _) | Error::BadOwner(_, _) | Error::BadType(_) => true, + + Error::NotFound(_) + | Error::CouldNotInspect(_, _) + | Error::StepsExceeded + | Error::CurrentDirectory(_) + | Error::CreatingDir(_) + | Error::Listing(_) + | Error::InvalidSubdirectory + | Error::Io(_, _) + | Error::NoTempFile(_) => false, + + Error::Multiple(errs) => errs.iter().any(|e| e.is_bad_permission()), + Error::Content(err) => err.is_bad_permission(), + } + } + /// Return an iterator over all of the errors contained in this Error. /// /// If this is a singleton, the iterator returns only a single element. diff --git a/crates/fs-mistrust/src/imp.rs b/crates/fs-mistrust/src/imp.rs index 462830ff33f81cccd6a7f209c398f4c7b5b20c58..d960d4d4f91575709deac9f11401c38214723ad1 100644 --- a/crates/fs-mistrust/src/imp.rs +++ b/crates/fs-mistrust/src/imp.rs @@ -182,18 +182,21 @@ impl<'a> super::Verifier<'a> { if uid != 0 && Some(uid) != self.mistrust.trust_uid { errors.push(Error::BadOwner(path.into(), uid)); } - let mut forbidden_bits = if !self.readable_okay - && (path_type == PathType::Final || path_type == PathType::Content) - { - // If this is the target or a content object, and it must not be - // readable, then we forbid it to be group-rwx and all-rwx. + let mut forbidden_bits = if !self.readable_okay && path_type == PathType::Final { + // If this is the target object, and it must not be readable, then + // we forbid it to be group-rwx and all-rwx. + // + // (We allow _content_ to be globally readable even if readable_okay + // is false, since we check that the Final directory is itself + // unreadable. This is okay unless the content has hard links: see + // the Limitations section of the crate-level documentation.) 0o077 } else { - // If this is the target object and it may be readable, or if - // this is _any parent directory_, then we typically forbid the - // group-write and all-write bits. (Those are the bits that - // would allow non-trusted users to change the object, or change - // things around in a directory.) + // If this is the target object and it may be readable, or if this + // is _any parent directory_ or any content, then we typically + // forbid the group-write and all-write bits. (Those are the bits + // that would allow non-trusted users to change the object, or + // change things around in a directory.) if meta.is_dir() && meta.mode() & STICKY_BIT != 0 && path_type == PathType::Intermediate { // This is an intermediate directory and this sticky bit is diff --git a/crates/fs-mistrust/src/lib.rs b/crates/fs-mistrust/src/lib.rs index bc1f3b46254de7512142ef4e0869f2feb080252e..d1ed1529a85c529baa929d0d64904fd73950c11b 100644 --- a/crates/fs-mistrust/src/lib.rs +++ b/crates/fs-mistrust/src/lib.rs @@ -200,6 +200,17 @@ //! * SELinux capabilities //! * POSIX (and other) ACLs. //! +//! We use a somewhat inaccurate heuristic when we're checking the permissions +//! of items _inside_ a target directory (using [`Verifier::check_content`] or +//! [`CheckedDir`]): we continue to forbid untrusted-writeable directories and +//! files, but we still allow readable ones, even if we insisted that the target +//! directory itself was required to to be unreadable. This is too permissive +//! in the case of readable objects with hard links: if there is a hard link to +//! the file somewhere else, then an untrusted user can read it. It is also too +//! restrictive in the case of writeable objects _without_ hard links: if +//! untrusted users have no path to those objects, they can't actually write +//! them. +//! //! On Windows, we accept all file permissions and owners. //! //! We don't check for mount-points and the privacy of filesystem devices @@ -232,7 +243,6 @@ // POSSIBLY TODO: // - Cache information across runs. -// - Add a way to recursively check the contents of a directory. #![deny(missing_docs)] #![warn(noop_method_call)] @@ -907,7 +917,7 @@ mod test { d.chmod("a", 0o700); d.chmod("a/b", 0o700); d.chmod("a/b/c", 0o755); - d.chmod("a/b/c/d", 0o644); + d.chmod("a/b/c/d", 0o666); let mut m = Mistrust::new(); m.ignore_prefix(d.canonical_root()).unwrap(); @@ -915,15 +925,18 @@ mod test { // A check should work... m.check_directory(d.path("a/b")).unwrap(); - // But we get errors if we check the contents. + // But we get an error if we check the contents. let e = m .verifier() .all_errors() .check_content() .check(d.path("a/b")) .unwrap_err(); + assert_eq!(1, e.errors().count()); - assert_eq!(2, e.errors().count()); + // We only expect an error on the _writable_ contents: the _readable_ + // a/b/c is okay. + assert_eq!(e.path().unwrap(), d.path("a/b/c/d")); } #[test] diff --git a/crates/tor-dirmgr/Cargo.toml b/crates/tor-dirmgr/Cargo.toml index eeb967bac4ccbd74ca1bfd546e9cd532e960985e..aa316d7985b85f4de43b1d1ea36ee5617515ab62 100644 --- a/crates/tor-dirmgr/Cargo.toml +++ b/crates/tor-dirmgr/Cargo.toml @@ -26,6 +26,7 @@ dirfilter = [] experimental-api = [] [dependencies] +fs-mistrust = { path = "../fs-mistrust", version = "0.1.0" } tor-basic-utils = { path = "../tor-basic-utils", version = "0.3.0"} retry-error = { path = "../retry-error", version = "0.2.0"} tor-checkable = { path = "../tor-checkable", version = "0.3.0"} diff --git a/crates/tor-dirmgr/src/config.rs b/crates/tor-dirmgr/src/config.rs index 65b984b4c953cd3e243948119a23353ebc95990d..fb7b3b63ddd870522cb60a85fb36db147852c489 100644 --- a/crates/tor-dirmgr/src/config.rs +++ b/crates/tor-dirmgr/src/config.rs @@ -189,6 +189,9 @@ pub struct DirMgrConfig { /// Cannot be changed on a running Arti client. pub cache_path: PathBuf, + /// Rules for whether to trust the the permissions on the cache_path. + pub cache_trust: fs_mistrust::Mistrust, + /// Configuration information about the network. pub network: NetworkConfig, @@ -228,10 +231,13 @@ impl DirMgrConfig { /// Note that each time this is called, a new store object will be /// created: you probably only want to call this once. pub(crate) fn open_store(&self, readonly: bool) -> Result<DynStore> { - Ok(Box::new(crate::storage::SqliteStore::from_path( - &self.cache_path, - readonly, - )?)) + Ok(Box::new( + crate::storage::SqliteStore::from_path_and_mistrust( + &self.cache_path, + &self.cache_trust, + readonly, + )?, + )) } /// Return a slice of the configured authorities @@ -251,6 +257,7 @@ impl DirMgrConfig { pub(crate) fn update_from_config(&self, new_config: &DirMgrConfig) -> DirMgrConfig { DirMgrConfig { cache_path: self.cache_path.clone(), + cache_trust: self.cache_trust.clone(), network: NetworkConfig { fallback_caches: new_config.network.fallback_caches.clone(), authorities: self.network.authorities.clone(), diff --git a/crates/tor-dirmgr/src/err.rs b/crates/tor-dirmgr/src/err.rs index a7f16297b78f9da7d7df1a9794585c2a3f284f97..633eb7577c630050fcf9d727c45535a223468399 100644 --- a/crates/tor-dirmgr/src/err.rs +++ b/crates/tor-dirmgr/src/err.rs @@ -83,6 +83,9 @@ pub enum Error { /// An attempt was made to bootstrap a `DirMgr` created in offline mode. #[error("cannot bootstrap offline DirMgr")] OfflineMode, + /// A problem with file permissions on our cache directory. + #[error("Bad permissions in cache directory")] + CachePermissions(#[from] fs_mistrust::Error), /// Unable to spawn task #[error("unable to spawn {spawning}")] @@ -154,6 +157,7 @@ impl Error { // These errors cannot come from a directory cache. Error::NoDownloadSupport | Error::CacheCorruption(_) + | Error::CachePermissions(_) | Error::SqliteError(_) | Error::UnrecognizedSchema | Error::BadNetworkConfig(_) @@ -209,6 +213,13 @@ impl HasKind for Error { E::Unwanted(_) => EK::TorProtocolViolation, E::NoDownloadSupport => EK::NotImplemented, E::CacheCorruption(_) => EK::CacheCorrupted, + E::CachePermissions(e) => { + if e.is_bad_permission() { + EK::FsPermissions + } else { + EK::CacheAccessFailed + } + } E::SqliteError(e) => sqlite_error_kind(e), E::UnrecognizedSchema => EK::CacheCorrupted, E::BadNetworkConfig(_) => EK::InvalidConfig, diff --git a/crates/tor-dirmgr/src/state.rs b/crates/tor-dirmgr/src/state.rs index 46701bddb1acb035d56109d9a953d129108b5890..a53087aba4c20b65f39c0fd511550625873e7ecc 100644 --- a/crates/tor-dirmgr/src/state.rs +++ b/crates/tor-dirmgr/src/state.rs @@ -995,7 +995,12 @@ mod test { fn temp_store() -> (TempDir, Mutex<DynStore>) { let tempdir = TempDir::new().unwrap(); - let store = crate::storage::SqliteStore::from_path(tempdir.path(), false).unwrap(); + let store = crate::storage::SqliteStore::from_path_and_mistrust( + tempdir.path(), + fs_mistrust::Mistrust::new().dangerously_trust_everyone(), + false, + ) + .unwrap(); (tempdir, Mutex::new(Box::new(store))) } diff --git a/crates/tor-dirmgr/src/storage.rs b/crates/tor-dirmgr/src/storage.rs index 0359f8e841613ad8595d666368be669cfc8a75b2..860d2b4fbbc7f8013b6e15194f3ae97f1354738e 100644 --- a/crates/tor-dirmgr/src/storage.rs +++ b/crates/tor-dirmgr/src/storage.rs @@ -17,8 +17,9 @@ use crate::docmeta::{AuthCertMeta, ConsensusMeta}; use crate::{Error, Result}; use std::cell::RefCell; use std::collections::HashMap; +use std::fs::File; +use std::str::Utf8Error; use std::time::SystemTime; -use std::{path::Path, str::Utf8Error}; use time::Duration; pub(crate) mod sqlite; @@ -124,18 +125,15 @@ impl InputString { } } } - - /// Construct a new InputString from a file on disk, trying to - /// memory-map the file if possible. - pub(crate) fn load<P: AsRef<Path>>(path: P) -> Result<Self> { - let f = std::fs::File::open(path)?; + /// DOCDOC + pub(crate) fn load(file: File) -> Result<Self> { #[cfg(feature = "mmap")] { let mapping = unsafe { // I'd rather have a safe option, but that's not possible // with mmap, since other processes could in theory replace // the contents of the file while we're using it. - memmap2::Mmap::map(&f) + memmap2::Mmap::map(&file) }; if let Ok(bytes) = mapping { return Ok(InputString::MappedBytes { @@ -145,7 +143,7 @@ impl InputString { } } use std::io::{BufReader, Read}; - let mut f = BufReader::new(f); + let mut f = BufReader::new(file); let mut result = String::new(); f.read_to_string(&mut result)?; Ok(InputString::Utf8(result)) @@ -308,13 +306,9 @@ mod test { fn files() { let td = tempdir().unwrap(); - let absent = td.path().join("absent"); - let s = InputString::load(&absent); - assert!(s.is_err()); - let goodstr = td.path().join("goodstr"); std::fs::write(&goodstr, "This is a reasonable file.\n").unwrap(); - let s = InputString::load(&goodstr); + let s = InputString::load(File::open(goodstr).unwrap()); let s = s.unwrap(); assert_eq!(s.as_str().unwrap(), "This is a reasonable file.\n"); assert_eq!(s.as_str().unwrap(), "This is a reasonable file.\n"); @@ -322,7 +316,7 @@ mod test { let badutf8 = td.path().join("badutf8"); std::fs::write(&badutf8, b"Not good \xff UTF-8.\n").unwrap(); - let s = InputString::load(&badutf8); + let s = InputString::load(File::open(badutf8).unwrap()); assert!(s.is_err() || s.unwrap().as_str().is_err()); } diff --git a/crates/tor-dirmgr/src/storage/sqlite.rs b/crates/tor-dirmgr/src/storage/sqlite.rs index 41337db5b23a168d0a0ae11e90bd1997fdc20ad2..4bc8b0d1aa22c6ed64061e2336ed9a171d5b29e4 100644 --- a/crates/tor-dirmgr/src/storage/sqlite.rs +++ b/crates/tor-dirmgr/src/storage/sqlite.rs @@ -8,6 +8,7 @@ use crate::docmeta::{AuthCertMeta, ConsensusMeta}; use crate::storage::{InputString, Store}; use crate::{Error, Result}; +use fs_mistrust::CheckedDir; use tor_netdoc::doc::authcert::AuthCertKeyIds; use tor_netdoc::doc::microdesc::MdDigest; use tor_netdoc::doc::netstatus::{ConsensusFlavor, Lifetime}; @@ -15,16 +16,14 @@ use tor_netdoc::doc::netstatus::{ConsensusFlavor, Lifetime}; use tor_netdoc::doc::routerdesc::RdDigest; use std::collections::HashMap; -use std::path::{self, Path, PathBuf}; +use std::fs::OpenOptions; +use std::path::{Path, PathBuf}; use std::time::SystemTime; use rusqlite::{params, OpenFlags, OptionalExtension, Transaction}; use time::OffsetDateTime; use tracing::trace; -#[cfg(target_family = "unix")] -use std::os::unix::fs::DirBuilderExt; - /// Local directory cache using a Sqlite3 connection. pub(crate) struct SqliteStore { /// Connection to the sqlite3 database. @@ -32,7 +31,7 @@ pub(crate) struct SqliteStore { /// Location for the sqlite3 database; used to reopen it. sql_path: Option<PathBuf>, /// Location to store blob files. - path: PathBuf, + blob_dir: CheckedDir, /// Lockfile to prevent concurrent write attempts from different /// processes. /// @@ -61,19 +60,36 @@ impl SqliteStore { /// _per process_. Therefore, you might get unexpected results if /// two SqliteStores are created in the same process with the /// path. - pub(crate) fn from_path<P: AsRef<Path>>(path: P, mut readonly: bool) -> Result<Self> { + pub(crate) fn from_path_and_mistrust<P: AsRef<Path>>( + path: P, + mistrust: &fs_mistrust::Mistrust, + mut readonly: bool, + ) -> Result<Self> { let path = path.as_ref(); let sqlpath = path.join("dir.sqlite3"); let blobpath = path.join("dir_blobs/"); let lockpath = path.join("dir.lock"); - if !readonly { - let mut builder = std::fs::DirBuilder::new(); - #[cfg(target_family = "unix")] - builder.mode(0o700); - builder.recursive(true).create(&blobpath).map_err(|err| { - Error::StorageError(format!("Creating directory at {:?}: {}", &blobpath, err)) - })?; + let verifier = mistrust.verifier().permit_readable().check_content(); + + let blob_dir = if readonly { + verifier.secure_dir(blobpath)? + } else { + verifier.make_secure_dir(blobpath)? + }; + + // Check permissions on the sqlite and lock files; don't require them to + // exist. + for p in [&lockpath, &sqlpath] { + match mistrust + .verifier() + .permit_readable() + .require_file() + .check(p) + { + Ok(()) | Err(fs_mistrust::Error::NotFound(_)) => {} + Err(e) => return Err(e.into()), + } } let mut lockfile = fslock::LockFile::open(&lockpath)?; @@ -86,7 +102,7 @@ impl SqliteStore { OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE }; let conn = rusqlite::Connection::open_with_flags(&sqlpath, flags)?; - let mut store = SqliteStore::from_conn(conn, &blobpath)?; + let mut store = SqliteStore::from_conn(conn, blob_dir)?; store.sql_path = Some(sqlpath); store.lockfile = Some(lockfile); Ok(store) @@ -96,14 +112,10 @@ impl SqliteStore { /// for blob files. /// /// Used for testing with a memory-backed database. - pub(crate) fn from_conn<P>(conn: rusqlite::Connection, path: P) -> Result<Self> - where - P: AsRef<Path>, - { - let path = path.as_ref().to_path_buf(); + pub(crate) fn from_conn(conn: rusqlite::Connection, blob_dir: CheckedDir) -> Result<Self> { let mut result = SqliteStore { conn, - path, + blob_dir, lockfile: None, sql_path: None, }; @@ -153,36 +165,21 @@ impl SqliteStore { Ok(()) } - /// Return the correct filename for a given blob, based on the filename - /// from the ExtDocs table. - fn blob_fname<P>(&self, path: P) -> Result<PathBuf> - where - P: AsRef<Path>, - { - let path = path.as_ref(); - if !path - .components() - .all(|c| matches!(c, path::Component::Normal(_))) - { - return Err(Error::CacheCorruption("Invalid path in database")); - } - - let mut result = self.path.clone(); - result.push(path); - Ok(result) - } - /// Read a blob from disk, mapping it if possible. fn read_blob<P>(&self, path: P) -> Result<InputString> where P: AsRef<Path>, { let path = path.as_ref(); - let full_path = self.blob_fname(path)?; - InputString::load(&full_path).map_err(|err| { + + let file = self.blob_dir.open(path, OpenOptions::new().read(true))?; + + InputString::load(file).map_err(|err| { Error::StorageError(format!( "Loading blob {:?} from storage at {:?}: {}", - path, full_path, err + path, + self.blob_dir.as_path().join(path), + err )) }) } @@ -202,10 +199,10 @@ impl SqliteStore { let digest = hex::encode(digest); let digeststr = format!("{}-{}", dtype, digest); let fname = format!("{}_{}", doctype, digeststr); - let full_path = self.blob_fname(&fname)?; + let full_path = self.blob_dir.join(&fname)?; let unlinker = Unlinker::new(&full_path); - std::fs::write(full_path, contents)?; + self.blob_dir.write_and_replace(&fname, contents)?; let tx = self.conn.unchecked_transaction()?; tx.execute(INSERT_EXTDOC, params![digeststr, expires, dtype, fname])?; @@ -301,7 +298,7 @@ impl Store for SqliteStore { tx.execute(DROP_OLD_ROUTERDESCS, [now - expiration.router_descs])?; tx.commit()?; for name in expired_blobs { - let fname = self.blob_fname(name); + let fname = self.blob_dir.join(name); if let Ok(fname) = fname { let _ignore = std::fs::remove_file(fname); } @@ -864,7 +861,12 @@ mod test { let tmp_dir = tempdir().unwrap(); let sql_path = tmp_dir.path().join("db.sql"); let conn = rusqlite::Connection::open(&sql_path)?; - let store = SqliteStore::from_conn(conn, &tmp_dir)?; + let blob_dir = fs_mistrust::Mistrust::new() + .dangerously_trust_everyone() + .verifier() + .secure_dir(&tmp_dir) + .unwrap(); + let store = SqliteStore::from_conn(conn, blob_dir)?; Ok((tmp_dir, store)) } @@ -872,46 +874,50 @@ mod test { #[test] fn init() -> Result<()> { let tmp_dir = tempdir().unwrap(); + let blob_dir = fs_mistrust::Mistrust::new() + .dangerously_trust_everyone() + .verifier() + .secure_dir(&tmp_dir) + .unwrap(); let sql_path = tmp_dir.path().join("db.sql"); // Initial setup: everything should work. { let conn = rusqlite::Connection::open(&sql_path)?; - let _store = SqliteStore::from_conn(conn, &tmp_dir)?; + let _store = SqliteStore::from_conn(conn, blob_dir.clone())?; } // Second setup: shouldn't need to upgrade. { let conn = rusqlite::Connection::open(&sql_path)?; - let _store = SqliteStore::from_conn(conn, &tmp_dir)?; + let _store = SqliteStore::from_conn(conn, blob_dir.clone())?; } // Third setup: shouldn't need to upgrade. { let conn = rusqlite::Connection::open(&sql_path)?; conn.execute_batch("UPDATE TorSchemaMeta SET version = 9002;")?; - let _store = SqliteStore::from_conn(conn, &tmp_dir)?; + let _store = SqliteStore::from_conn(conn, blob_dir.clone())?; } // Fourth: this says we can't read it, so we'll get an error. { let conn = rusqlite::Connection::open(&sql_path)?; conn.execute_batch("UPDATE TorSchemaMeta SET readable_by = 9001;")?; - let val = SqliteStore::from_conn(conn, &tmp_dir); + let val = SqliteStore::from_conn(conn, blob_dir); assert!(val.is_err()); } Ok(()) } #[test] - fn bad_blob_fnames() -> Result<()> { + fn bad_blob_fname() -> Result<()> { let (_tmp_dir, store) = new_empty()?; - assert!(store.blob_fname("abcd").is_ok()); - assert!(store.blob_fname("abcd..").is_ok()); - assert!(store.blob_fname("..abcd..").is_ok()); - assert!(store.blob_fname(".abcd").is_ok()); + assert!(store.blob_dir.join("abcd").is_ok()); + assert!(store.blob_dir.join("abcd..").is_ok()); + assert!(store.blob_dir.join("..abcd..").is_ok()); + assert!(store.blob_dir.join(".abcd").is_ok()); - assert!(store.blob_fname(".").is_err()); - assert!(store.blob_fname("..").is_err()); - assert!(store.blob_fname("../abcd").is_err()); - assert!(store.blob_fname("/abcd").is_err()); + assert!(store.blob_dir.join("..").is_err()); + assert!(store.blob_dir.join("../abcd").is_err()); + assert!(store.blob_dir.join("/abcd").is_err()); Ok(()) } @@ -943,13 +949,13 @@ mod test { fname1, "greeting_sha1-7b502c3a1f48c8609ae212cdfb639dee39673f5e" ); - assert_eq!(store.blob_fname(&fname1)?, tmp_dir.path().join(&fname1)); + assert_eq!(store.blob_dir.join(&fname1)?, tmp_dir.path().join(&fname1)); assert_eq!( - &std::fs::read(store.blob_fname(&fname1)?)?[..], + &std::fs::read(store.blob_dir.join(&fname1)?)?[..], b"Hello world" ); assert_eq!( - &std::fs::read(store.blob_fname(&fname2)?)?[..], + &std::fs::read(store.blob_dir.join(&fname2)?)?[..], b"Goodbye, dear friends" ); @@ -964,10 +970,10 @@ mod test { // Now expire: the second file should go away. store.expire_all(&EXPIRATION_DEFAULTS)?; assert_eq!( - &std::fs::read(store.blob_fname(&fname1)?)?[..], + &std::fs::read(store.blob_dir.join(&fname1)?)?[..], b"Hello world" ); - assert!(std::fs::read(store.blob_fname(&fname2)?).is_err()); + assert!(std::fs::read(store.blob_dir.join(&fname2)?).is_err()); let n: u32 = store .conn .query_row("SELECT COUNT(filename) FROM ExtDocs", [], |row| row.get(0))?; @@ -1182,15 +1188,20 @@ mod test { #[test] fn from_path_rw() -> Result<()> { let tmp = tempdir().unwrap(); + let mistrust = { + let mut m = fs_mistrust::Mistrust::new(); + m.dangerously_trust_everyone(); + m + }; // Nothing there: can't open read-only - let r = SqliteStore::from_path(tmp.path(), true); + let r = SqliteStore::from_path_and_mistrust(tmp.path(), &mistrust, true); assert!(r.is_err()); assert!(!tmp.path().join("dir_blobs").exists()); // Opening it read-write will crate the files { - let mut store = SqliteStore::from_path(tmp.path(), false)?; + let mut store = SqliteStore::from_path_and_mistrust(tmp.path(), &mistrust, false)?; assert!(tmp.path().join("dir_blobs").is_dir()); assert!(store.lockfile.is_some()); assert!(!store.is_readonly()); @@ -1199,7 +1210,7 @@ mod test { // At this point, we can successfully make a read-only connection. { - let mut store2 = SqliteStore::from_path(tmp.path(), true)?; + let mut store2 = SqliteStore::from_path_and_mistrust(tmp.path(), &mistrust, true)?; assert!(store2.is_readonly()); // Nobody else is locking this, so we can upgrade. diff --git a/crates/tor-error/src/lib.rs b/crates/tor-error/src/lib.rs index bbd371741354467da6867f46676a50aec3b86e12..0731d12fd660164d9d1549b7ee18305623d3a794 100644 --- a/crates/tor-error/src/lib.rs +++ b/crates/tor-error/src/lib.rs @@ -158,6 +158,15 @@ pub enum ErrorKind { #[display(fmt = "could not read/write persistent state")] PersistentStateAccessFailed, + /// We encountered a problem with filesystem permissions. + /// + /// This is likeliest to be caused by permissions on a file or directory + /// being too permissive; the next likeliest cause is that we were unable to + /// check the permissions on the file or directory, or on one of its + /// ancestors. + #[display(fmt = "problem with filesystem permissions")] + FsPermissions, + /// Tor client's persistent state has been corrupted /// /// This could be because of a bug in the Tor code, or because something diff --git a/crates/tor-persist/Cargo.toml b/crates/tor-persist/Cargo.toml index d54eb496139e3b9f0eaea3431eae07612ce9ae40..22e9fcfd377b5363365e432dd4eb09d5718e96b4 100644 --- a/crates/tor-persist/Cargo.toml +++ b/crates/tor-persist/Cargo.toml @@ -17,6 +17,7 @@ repository = "https://gitlab.torproject.org/tpo/core/arti.git/" testing = [] [dependencies] +fs-mistrust = { path = "../fs-mistrust", version = "0.1.0", features = ["walkdir"] } serde = { version = "1.0.103", features = ["derive"] } serde_json = "1.0.50" sanitize-filename = "0.3.0" diff --git a/crates/tor-persist/src/fs.rs b/crates/tor-persist/src/fs.rs index 91f999932e74c4812cf7ca9084eab64b47d88c89..82455c561d114495c7bfaf81600c6b15fbc609ef 100644 --- a/crates/tor-persist/src/fs.rs +++ b/crates/tor-persist/src/fs.rs @@ -4,15 +4,13 @@ mod clean; use crate::{load_error, store_error}; use crate::{Error, LockStatus, Result, StateMgr}; +use fs_mistrust::CheckedDir; use serde::{de::DeserializeOwned, Serialize}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tracing::{info, warn}; -#[cfg(target_family = "unix")] -use std::os::unix::fs::DirBuilderExt; - /// Implementation of StateMgr that stores state as JSON files on disk. /// /// # Locking @@ -48,7 +46,7 @@ pub struct FsStateMgr { #[derive(Debug)] struct FsStateMgrInner { /// Directory in which we store state files. - statepath: PathBuf, + statepath: CheckedDir, /// Lockfile to achieve exclusive access to state files. lockfile: Mutex<fslock::LockFile>, } @@ -58,17 +56,20 @@ impl FsStateMgr { /// /// This function will try to create `path` if it does not already /// exist. - pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> { + /// + /// All files must be "private" according to the rules specified in `mistrust`. + pub fn from_path_and_mistrust<P: AsRef<Path>>( + path: P, + mistrust: &fs_mistrust::Mistrust, + ) -> Result<Self> { let path = path.as_ref(); let statepath = path.join("state"); - let lockpath = path.join("state.lock"); - { - let mut builder = std::fs::DirBuilder::new(); - #[cfg(target_family = "unix")] - builder.mode(0o700); - builder.recursive(true).create(&statepath)?; - } + let statepath = mistrust + .verifier() + .check_content() + .make_secure_dir(statepath)?; + let lockpath = statepath.join("state.lock")?; let lockfile = Mutex::new(fslock::LockFile::open(&lockpath)?); @@ -79,20 +80,32 @@ impl FsStateMgr { }), }) } - /// Return a filename to use for storing data with `key`. + /// Like from_path_and_mistrust, but do not verify permissions. + /// + /// Testing only. + #[cfg(test)] + pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> { + Self::from_path_and_mistrust( + path, + fs_mistrust::Mistrust::new().dangerously_trust_everyone(), + ) + } + + /// Return a filename, relative to the top of this directory, to use for + /// storing data with `key`. /// /// See "Limitations" section on [`FsStateMgr`] for caveats. - fn filename(&self, key: &str) -> PathBuf { - self.inner - .statepath - .join(sanitize_filename::sanitize(key) + ".json") + fn rel_filename(&self, key: &str) -> PathBuf { + (sanitize_filename::sanitize(key) + ".json").into() } /// Return the top-level directory for this storage manager. /// - /// (This is the same directory passed to [`FsStateMgr::from_path`].) + /// (This is the same directory passed to + /// [`FsStateMgr::from_path_and_mistrust`].) pub fn path(&self) -> &Path { self.inner .statepath + .as_path() .parent() .expect("No parent directory even after path.join?") } @@ -101,7 +114,7 @@ impl FsStateMgr { /// /// Requires that we hold the lock. fn clean(&self) { - for fname in clean::files_to_delete(&self.inner.statepath, SystemTime::now()) { + for fname in clean::files_to_delete(self.inner.statepath.as_path(), SystemTime::now()) { info!("Deleting obsolete file {}", fname.display()); if let Err(e) = std::fs::remove_file(&fname) { warn!("Unable to delete {}: {}", fname.display(), e); @@ -149,17 +162,12 @@ impl StateMgr for FsStateMgr { where D: DeserializeOwned, { - let fname = self.filename(key); - - let string = match std::fs::read_to_string(fname) { - Ok(s) => s, - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - return Ok(None); - } else { - return Err(e.into()); - } - } + let rel_fname = self.rel_filename(key); + + let string = match self.inner.statepath.read_to_string(rel_fname) { + Ok(string) => string, + Err(fs_mistrust::Error::NotFound(_)) => return Ok(None), + Err(e) => return Err(e.into()), }; Ok(Some(serde_json::from_str(&string).map_err(load_error)?)) @@ -173,13 +181,11 @@ impl StateMgr for FsStateMgr { return Err(Error::NoLock); } - let fname = self.filename(key); + let rel_fname = self.rel_filename(key); let output = serde_json::to_string_pretty(val).map_err(store_error)?; - let fname_tmp = fname.with_extension("tmp"); - std::fs::write(&fname_tmp, &output)?; - std::fs::rename(fname_tmp, fname)?; + self.inner.statepath.write_and_replace(rel_fname, output)?; Ok(()) } diff --git a/crates/tor-persist/src/lib.rs b/crates/tor-persist/src/lib.rs index 196ea40fa7c6c04a4597331c516c41abcca85f73..8163d68dda0c136ca0f889c84fa14dc2792d1815 100644 --- a/crates/tor-persist/src/lib.rs +++ b/crates/tor-persist/src/lib.rs @@ -5,7 +5,7 @@ //! implement [Tor](https://www.torproject.org/) in Rust. //! //! For now, users should construct storage objects directly with (for -//! example) [`FsStateMgr::from_path()`], but use them primarily via the +//! example) [`FsStateMgr::from_path_and_mistrust()`], but use them primarily via the //! interfaces of the [`StateMgr`] trait. #![deny(missing_docs)] @@ -142,6 +142,10 @@ pub enum Error { #[error("IO error")] IoError(#[source] Arc<std::io::Error>), + /// Permissions on a file or path were incorrect + #[error("Invalid permissions on state file")] + Permissions(#[from] fs_mistrust::Error), + /// Tried to save without holding an exclusive lock. // // TODO This error seems to actually be sometimes used to make store a no-op. @@ -166,6 +170,11 @@ impl tor_error::HasKind for Error { use tor_error::ErrorKind as K; match self { E::IoError(..) => K::PersistentStateAccessFailed, + E::Permissions(e) => if e.is_bad_permission() { + K::FsPermissions + } else { + K::PersistentStateAccessFailed + } E::NoLock => K::BadApiUsage, E::Serialize(..) => K::Internal, E::Deserialize(..) => K::PersistentStateCorrupted, diff --git a/doc/semver_status.md b/doc/semver_status.md index 488a230f560f3188ea57c173d8f8d1b176341ade..f45ed5443f47fd5a971e6254723134a8473c82be 100644 --- a/doc/semver_status.md +++ b/doc/semver_status.md @@ -18,3 +18,19 @@ We can delete older sections here after we bump the releases. ## Since Arti 0.3.0 +### arti-client + +MODIFIED: Code to configure fs-mistrust. +BREAKING: TorConfig no longer implements TryInto<DirMgrConfig> + +### fs-mistrust + +MODIFIED: New APIs for CachedDir, Error. + +### tor-dirmgr + +BREAKING: Added new cache_trust element to DirMgrConfig. + +### tor-persist + ++BREAKING: Replaced from_path with from_path_and_mistrust