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