Skip to main content

unitycatalog_server/services/
location_policy.rs

1//! Server-side allowlist for local (`file://`) storage locations.
2//!
3//! Once the server can serve data from the host filesystem (see
4//! `services::object_store`), an unrestricted caller could register a storage
5//! location pointing at *any* path the server process can reach — `file:///etc`,
6//! `file:///`, a co-tenant's data — by accident or design. [`LocalStoragePolicy`]
7//! restricts which host paths may back a `file://` location.
8//!
9//! The policy is **deny-by-default**: an empty allowlist rejects every `file://`
10//! location. A deployment opts in by configuring one or more allowed roots; only
11//! paths at or beneath an allowed root are accepted. Cloud schemes (s3, abfss,
12//! gs, …) are never inspected here — they are governed by external locations and
13//! credential vending.
14
15use std::path::{Component, Path, PathBuf};
16
17use object_store::ObjectStoreScheme;
18
19use super::location::{StorageLocationScheme, StorageLocationUrl};
20use crate::{Error, Result};
21
22/// An allowlist of host filesystem roots that may back `file://` storage
23/// locations.
24///
25/// Construct via [`LocalStoragePolicy::new`]; an empty policy denies all local
26/// storage. Held on the server handler and reached by request handlers through
27/// [`ProvidesLocalStoragePolicy`](super::ProvidesLocalStoragePolicy).
28#[derive(Debug, Clone, Default)]
29pub struct LocalStoragePolicy {
30    /// Canonicalized allowed roots. Empty ⇒ deny all `file://`.
31    allowed_roots: Vec<PathBuf>,
32}
33
34impl LocalStoragePolicy {
35    /// Build a policy from configured root paths.
36    ///
37    /// Each root is canonicalized (resolving symlinks and `..`) so later prefix
38    /// checks compare against a stable, real path. A root that does not exist or
39    /// cannot be canonicalized is a configuration error — surfacing it at
40    /// startup is preferable to silently denying every local location later.
41    pub fn new<I, P>(roots: I) -> Result<Self>
42    where
43        I: IntoIterator<Item = P>,
44        P: AsRef<Path>,
45    {
46        let allowed_roots = roots
47            .into_iter()
48            .map(|root| {
49                let root = root.as_ref();
50                root.canonicalize().map_err(|e| {
51                    Error::invalid_argument(format!(
52                        "allowed local storage root '{}' is not accessible: {e}",
53                        root.display()
54                    ))
55                })
56            })
57            .collect::<Result<Vec<_>>>()?;
58        Ok(Self { allowed_roots })
59    }
60
61    /// A deny-all policy (no allowed roots). The default for a server with no
62    /// `local_storage` configuration.
63    pub fn deny_all() -> Self {
64        Self::default()
65    }
66
67    /// Whether any local root is allowed. Used by managed-root resolution to
68    /// decide whether a sole allowed root can serve as an implicit root.
69    pub(crate) fn allowed_roots(&self) -> &[PathBuf] {
70        &self.allowed_roots
71    }
72
73    /// Authorize a storage location.
74    ///
75    /// Cloud schemes pass through untouched. A `file://` location is accepted
76    /// only when its host path — with `..` rejected and `.` collapsed — sits at
77    /// or beneath an allowed root. With no allowed roots, every `file://`
78    /// location is rejected.
79    pub fn check(&self, location: &StorageLocationUrl) -> Result<()> {
80        if !matches!(
81            location.scheme(),
82            StorageLocationScheme::ObjectStore(ObjectStoreScheme::Local)
83        ) {
84            return Ok(());
85        }
86
87        let path = location.raw().to_file_path().map_err(|_| {
88            Error::invalid_argument(format!("not a valid local file path: {}", location.raw()))
89        })?;
90
91        // Reject any traversal component outright rather than trying to resolve
92        // it: a `..` in a governed storage path is never legitimate and is the
93        // classic way to escape an allowed root.
94        if path.components().any(|c| matches!(c, Component::ParentDir)) {
95            return Err(Error::invalid_argument(format!(
96                "local storage path '{}' must not contain '..'",
97                path.display()
98            )));
99        }
100        let normalized = lexically_normalize(&path);
101
102        if self.allowed_roots.is_empty() {
103            return Err(Error::invalid_argument(format!(
104                "local (file://) storage is not enabled on this server; \
105                 path '{}' is not within any allowed root",
106                normalized.display()
107            )));
108        }
109
110        if self
111            .allowed_roots
112            .iter()
113            .any(|root| is_within(&normalized, root))
114        {
115            Ok(())
116        } else {
117            Err(Error::invalid_argument(format!(
118                "local storage path '{}' is not within any allowed root",
119                normalized.display()
120            )))
121        }
122    }
123}
124
125/// Collapse `.` components from an absolute path without touching the
126/// filesystem. `..` is handled by the caller (rejected), so it is not resolved
127/// here. Used because governed paths frequently do not exist yet (managed roots,
128/// not-yet-created tables) and so cannot be canonicalized.
129fn lexically_normalize(path: &Path) -> PathBuf {
130    path.components()
131        .filter(|c| !matches!(c, Component::CurDir))
132        .collect()
133}
134
135/// Whether `path` is equal to, or a descendant of, `root`, comparing on whole
136/// path components (so `/data` does not match `/data-secret`).
137fn is_within(path: &Path, root: &Path) -> bool {
138    let mut root_parts = root.components();
139    let mut path_parts = path.components();
140    loop {
141        match (root_parts.next(), path_parts.next()) {
142            // Consumed the whole root: `path` is at or below it.
143            (None, _) => return true,
144            // Root has more components than the path, or a component differs.
145            (Some(_), None) => return false,
146            (Some(a), Some(b)) if a != b => return false,
147            (Some(_), Some(_)) => continue,
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn loc(url: &str) -> StorageLocationUrl {
157        StorageLocationUrl::parse(url).unwrap()
158    }
159
160    #[test]
161    fn deny_all_rejects_every_file_url() {
162        let policy = LocalStoragePolicy::deny_all();
163        assert!(policy.check(&loc("file:///tmp/anything")).is_err());
164        assert!(policy.check(&loc("file:///")).is_err());
165    }
166
167    #[test]
168    fn cloud_schemes_pass_through_even_with_empty_policy() {
169        let policy = LocalStoragePolicy::deny_all();
170        assert!(policy.check(&loc("s3://bucket/data")).is_ok());
171        assert!(
172            policy
173                .check(&loc("abfss://c@acct.dfs.core.windows.net/x"))
174                .is_ok()
175        );
176    }
177
178    // The following tests construct real `file://` paths under a canonicalized
179    // root and exercise prefix matching. They are POSIX-only: on Windows,
180    // `canonicalize()` yields a `\\?\C:\…` extended-length prefix that does not
181    // match the plain `C:\…` from a URL's `to_file_path()`, and local file://
182    // storage is gated off on Windows anyway (see `local_store` /
183    // `get_local_store`). Deny-by-default and cloud pass-through (tested above)
184    // are the behaviors that still matter on Windows.
185    #[cfg(not(windows))]
186    #[test]
187    fn allows_paths_within_a_configured_root() {
188        let dir = tempfile::tempdir().unwrap();
189        let root = dir.path().canonicalize().unwrap();
190        let policy = LocalStoragePolicy::new([&root]).unwrap();
191
192        let inside = url::Url::from_directory_path(root.join("catalog/table"))
193            .unwrap()
194            .to_string();
195        assert!(policy.check(&loc(&inside)).is_ok());
196        // The root itself is allowed.
197        let at_root = url::Url::from_directory_path(&root).unwrap().to_string();
198        assert!(policy.check(&loc(&at_root)).is_ok());
199    }
200
201    #[cfg(not(windows))]
202    #[test]
203    fn rejects_paths_outside_every_root() {
204        let dir = tempfile::tempdir().unwrap();
205        let root = dir.path().canonicalize().unwrap();
206        let policy = LocalStoragePolicy::new([&root]).unwrap();
207        assert!(policy.check(&loc("file:///etc/passwd")).is_err());
208    }
209
210    #[cfg(not(windows))]
211    #[test]
212    fn rejects_sibling_prefix() {
213        // `<root>` must not match `<root>-secret`. Build the sibling as a native
214        // path (its file name + "-secret") so the URL is well-formed on every OS.
215        let dir = tempfile::tempdir().unwrap();
216        let root = dir.path().canonicalize().unwrap();
217        let policy = LocalStoragePolicy::new([&root]).unwrap();
218
219        let mut sibling = root.clone();
220        let name = format!("{}-secret", root.file_name().unwrap().to_string_lossy());
221        sibling.set_file_name(name);
222        sibling.push("data");
223        let url = url::Url::from_directory_path(&sibling).unwrap().to_string();
224        assert!(policy.check(&loc(&url)).is_err());
225    }
226
227    #[cfg(not(windows))]
228    #[test]
229    fn rejects_parent_dir_traversal() {
230        let dir = tempfile::tempdir().unwrap();
231        let root = dir.path().canonicalize().unwrap();
232        let policy = LocalStoragePolicy::new([&root]).unwrap();
233
234        // A crafted `..` under the root: built as a native path so the file URL
235        // is well-formed (drive letters/separators) on every OS. The `..`
236        // component is rejected outright.
237        let escape = url::Url::from_directory_path(root.join("..").join("escape"))
238            .unwrap()
239            .to_string();
240        assert!(policy.check(&loc(&escape)).is_err());
241    }
242
243    #[test]
244    fn new_errors_on_missing_root() {
245        let result = LocalStoragePolicy::new(["/this/path/does/not/exist/uc-xyz"]);
246        assert!(result.is_err());
247    }
248
249    #[test]
250    fn is_within_matches_on_component_boundaries() {
251        assert!(is_within(Path::new("/data/t1"), Path::new("/data")));
252        assert!(is_within(Path::new("/data"), Path::new("/data")));
253        assert!(!is_within(Path::new("/data-secret/t1"), Path::new("/data")));
254        assert!(!is_within(Path::new("/other/t1"), Path::new("/data")));
255    }
256}