safe_path/
scoped_path_resolver.rs

1// Copyright (c) 2022 Alibaba Cloud
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5
6use std::io::{Error, ErrorKind, Result};
7use std::path::{Component, Path, PathBuf};
8
9// Follow the same configuration as
10// [secure_join](https://github.com/cyphar/filepath-securejoin/blob/master/join.go#L51)
11const MAX_SYMLINK_DEPTH: u32 = 255;
12
13fn do_scoped_resolve<R: AsRef<Path>, U: AsRef<Path>>(
14    root: R,
15    unsafe_path: U,
16) -> Result<(PathBuf, PathBuf)> {
17    let root = root.as_ref().canonicalize()?;
18
19    let mut nlinks = 0u32;
20    let mut curr_path = unsafe_path.as_ref().to_path_buf();
21    'restart: loop {
22        let mut subpath = PathBuf::new();
23        let mut iter = curr_path.components();
24
25        'next_comp: while let Some(comp) = iter.next() {
26            match comp {
27                // Linux paths don't have prefixes.
28                Component::Prefix(_) => {
29                    return Err(Error::new(
30                        ErrorKind::Other,
31                        format!("Invalid path prefix in: {}", unsafe_path.as_ref().display()),
32                    ));
33                }
34                // `RootDir` should always be the first component, and Path::components() ensures
35                // that.
36                Component::RootDir | Component::CurDir => {
37                    continue 'next_comp;
38                }
39                Component::ParentDir => {
40                    subpath.pop();
41                }
42                Component::Normal(n) => {
43                    let path = root.join(&subpath).join(n);
44                    if let Ok(v) = path.read_link() {
45                        nlinks += 1;
46                        if nlinks > MAX_SYMLINK_DEPTH {
47                            return Err(Error::new(
48                                ErrorKind::Other,
49                                format!(
50                                    "Too many levels of symlinks: {}",
51                                    unsafe_path.as_ref().display()
52                                ),
53                            ));
54                        }
55                        curr_path = if v.is_absolute() {
56                            v.join(iter.as_path())
57                        } else {
58                            subpath.join(v).join(iter.as_path())
59                        };
60                        continue 'restart;
61                    } else {
62                        subpath.push(n);
63                    }
64                }
65            }
66        }
67
68        return Ok((root, subpath));
69    }
70}
71
72/// Resolve `unsafe_path` to a relative path, rooted at and constrained by `root`.
73///
74/// The `scoped_resolve()` function assumes `root` exists and is an absolute path. It processes
75/// each path component in `unsafe_path` as below:
76/// - assume it's not a symlink and output if the component doesn't exist yet.
77/// - ignore if it's "/" or ".".
78/// - go to parent directory but constrained by `root` if it's "..".
79/// - recursively resolve to the real path if it's a symlink. All symlink resolutions will be
80///   constrained by `root`.
81/// - otherwise output the path component.
82///
83/// # Arguments
84/// - `root`: the absolute path to constrain the symlink resolution.
85/// - `unsafe_path`: the path to resolve.
86///
87/// Note that the guarantees provided by this function only apply if the path components in the
88/// returned PathBuf are not modified (in other words are not replaced with symlinks on the
89/// filesystem) after this function has returned. You may use [crate::PinnedPathBuf] to protect
90/// from such TOCTOU attacks.
91pub fn scoped_resolve<R: AsRef<Path>, U: AsRef<Path>>(root: R, unsafe_path: U) -> Result<PathBuf> {
92    do_scoped_resolve(root, unsafe_path).map(|(_root, path)| path)
93}
94
95/// Safely join `unsafe_path` to `root`, and ensure `unsafe_path` is scoped under `root`.
96///
97/// The `scoped_join()` function assumes `root` exists and is an absolute path. It safely joins the
98/// two given paths and ensures:
99/// - The returned path is guaranteed to be scoped inside `root`.
100/// - Any symbolic links in the path are evaluated with the given `root` treated as the root of the
101///   filesystem, similar to a chroot.
102///
103/// It's modelled after [secure_join](https://github.com/cyphar/filepath-securejoin), but only
104/// for Linux systems.
105///
106/// # Arguments
107/// - `root`: the absolute path to scope the symlink evaluation.
108/// - `unsafe_path`: the path to evaluated and joint with `root`. It is unsafe since it may try to
109///   escape from the `root` by using "../" or symlinks.
110///
111/// # Security
112/// On success return, the `scoped_join()` function guarantees that:
113/// - The resulting PathBuf must be a child path of `root` and will not contain any symlink path
114///   components (they will all get expanded).
115/// - When expanding symlinks, all symlink path components must be resolved relative to the provided
116///   `root`. In particular, this can be considered a userspace implementation of how chroot(2)
117///    operates on file paths.
118/// - Non-existent path components are unaffected.
119///
120/// Note that the guarantees provided by this function only apply if the path components in the
121/// returned string are not modified (in other words are not replaced with symlinks on the
122/// filesystem) after this function has returned. You may use [crate::PinnedPathBuf] to protect
123/// from such TOCTTOU attacks.
124pub fn scoped_join<R: AsRef<Path>, U: AsRef<Path>>(root: R, unsafe_path: U) -> Result<PathBuf> {
125    do_scoped_resolve(root, unsafe_path).map(|(root, path)| root.join(path))
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::fs::DirBuilder;
132    use std::os::unix::fs;
133    use tempfile::tempdir;
134
135    #[allow(dead_code)]
136    #[derive(Debug)]
137    struct TestData<'a> {
138        name: &'a str,
139        rootfs: &'a Path,
140        unsafe_path: &'a str,
141        result: &'a str,
142    }
143
144    fn exec_tests(tests: &[TestData]) {
145        for (i, t) in tests.iter().enumerate() {
146            // Create a string containing details of the test
147            let msg = format!("test[{}]: {:?}", i, t);
148            let result = scoped_resolve(t.rootfs, t.unsafe_path).unwrap();
149            let msg = format!("{}, result: {:?}", msg, result);
150
151            // Perform the checks
152            assert_eq!(&result, Path::new(t.result), "{}", msg);
153        }
154    }
155
156    #[test]
157    fn test_scoped_resolve() {
158        // create temporary directory to emulate container rootfs with symlink
159        let rootfs_dir = tempdir().expect("failed to create tmpdir");
160        DirBuilder::new()
161            .create(rootfs_dir.path().join("b"))
162            .unwrap();
163        fs::symlink(rootfs_dir.path().join("b"), rootfs_dir.path().join("a")).unwrap();
164        let rootfs_path = &rootfs_dir.path().join("a");
165
166        let tests = [
167            TestData {
168                name: "normal path",
169                rootfs: rootfs_path,
170                unsafe_path: "a/b/c",
171                result: "a/b/c",
172            },
173            TestData {
174                name: "path with .. at beginning",
175                rootfs: rootfs_path,
176                unsafe_path: "../../../a/b/c",
177                result: "a/b/c",
178            },
179            TestData {
180                name: "path with complex .. pattern",
181                rootfs: rootfs_path,
182                unsafe_path: "../../../a/../../b/../../c",
183                result: "c",
184            },
185            TestData {
186                name: "path with .. in middle",
187                rootfs: rootfs_path,
188                unsafe_path: "/usr/bin/../../bin/ls",
189                result: "bin/ls",
190            },
191            TestData {
192                name: "path with . and ..",
193                rootfs: rootfs_path,
194                unsafe_path: "/usr/./bin/../../bin/./ls",
195                result: "bin/ls",
196            },
197            TestData {
198                name: "path with . at end",
199                rootfs: rootfs_path,
200                unsafe_path: "/usr/./bin/../../bin/./ls/.",
201                result: "bin/ls",
202            },
203            TestData {
204                name: "path try to escape by ..",
205                rootfs: rootfs_path,
206                unsafe_path: "/usr/./bin/../../../../bin/./ls/../ls",
207                result: "bin/ls",
208            },
209            TestData {
210                name: "path with .. at the end",
211                rootfs: rootfs_path,
212                unsafe_path: "/usr/./bin/../../bin/./ls/..",
213                result: "bin",
214            },
215            TestData {
216                name: "path ..",
217                rootfs: rootfs_path,
218                unsafe_path: "..",
219                result: "",
220            },
221            TestData {
222                name: "path .",
223                rootfs: rootfs_path,
224                unsafe_path: ".",
225                result: "",
226            },
227            TestData {
228                name: "path /",
229                rootfs: rootfs_path,
230                unsafe_path: "/",
231                result: "",
232            },
233            TestData {
234                name: "empty path",
235                rootfs: rootfs_path,
236                unsafe_path: "",
237                result: "",
238            },
239        ];
240
241        exec_tests(&tests);
242    }
243
244    #[test]
245    fn test_scoped_resolve_invalid() {
246        scoped_resolve("./root_is_not_absolute_path", ".").unwrap_err();
247        scoped_resolve("C:", ".").unwrap_err();
248        scoped_resolve(r#"\\server\test"#, ".").unwrap_err();
249        scoped_resolve(r#"http://localhost/test"#, ".").unwrap_err();
250        // Chinese Unicode characters
251        scoped_resolve(r#"您好"#, ".").unwrap_err();
252    }
253
254    #[test]
255    fn test_scoped_resolve_symlink() {
256        // create temporary directory to emulate container rootfs with symlink
257        let rootfs_dir = tempdir().expect("failed to create tmpdir");
258        let rootfs_path = &rootfs_dir.path();
259        std::fs::create_dir(rootfs_path.join("symlink_dir")).unwrap();
260
261        fs::symlink("../../../", rootfs_path.join("1")).unwrap();
262        let tests = [TestData {
263            name: "relative symlink beyond root",
264            rootfs: rootfs_path,
265            unsafe_path: "1",
266            result: "",
267        }];
268        exec_tests(&tests);
269
270        fs::symlink("/dddd", rootfs_path.join("2")).unwrap();
271        let tests = [TestData {
272            name: "abs symlink pointing to non-exist directory",
273            rootfs: rootfs_path,
274            unsafe_path: "2",
275            result: "dddd",
276        }];
277        exec_tests(&tests);
278
279        fs::symlink("/", rootfs_path.join("3")).unwrap();
280        let tests = [TestData {
281            name: "abs symlink pointing to /",
282            rootfs: rootfs_path,
283            unsafe_path: "3",
284            result: "",
285        }];
286        exec_tests(&tests);
287
288        fs::symlink("usr/bin/../bin/ls", rootfs_path.join("4")).unwrap();
289        let tests = [TestData {
290            name: "symlink with one ..",
291            rootfs: rootfs_path,
292            unsafe_path: "4",
293            result: "usr/bin/ls",
294        }];
295        exec_tests(&tests);
296
297        fs::symlink("usr/bin/../../bin/ls", rootfs_path.join("5")).unwrap();
298        let tests = [TestData {
299            name: "symlink with two ..",
300            rootfs: rootfs_path,
301            unsafe_path: "5",
302            result: "bin/ls",
303        }];
304        exec_tests(&tests);
305
306        fs::symlink(
307            "../usr/bin/../../../bin/ls",
308            rootfs_path.join("symlink_dir/6"),
309        )
310        .unwrap();
311        let tests = [TestData {
312            name: "symlink try to escape",
313            rootfs: rootfs_path,
314            unsafe_path: "symlink_dir/6",
315            result: "bin/ls",
316        }];
317        exec_tests(&tests);
318
319        // Detect symlink loop.
320        fs::symlink("/endpoint_b", rootfs_path.join("endpoint_a")).unwrap();
321        fs::symlink("/endpoint_a", rootfs_path.join("endpoint_b")).unwrap();
322        scoped_resolve(rootfs_path, "endpoint_a").unwrap_err();
323    }
324
325    #[test]
326    fn test_scoped_join() {
327        // create temporary directory to emulate container rootfs with symlink
328        let rootfs_dir = tempdir().expect("failed to create tmpdir");
329        let rootfs_path = &rootfs_dir.path();
330
331        assert_eq!(
332            scoped_join(&rootfs_path, "a").unwrap(),
333            rootfs_path.join("a")
334        );
335        assert_eq!(
336            scoped_join(&rootfs_path, "./a").unwrap(),
337            rootfs_path.join("a")
338        );
339        assert_eq!(
340            scoped_join(&rootfs_path, "././a").unwrap(),
341            rootfs_path.join("a")
342        );
343        assert_eq!(
344            scoped_join(&rootfs_path, "c/d/../../a").unwrap(),
345            rootfs_path.join("a")
346        );
347        assert_eq!(
348            scoped_join(&rootfs_path, "c/d/../../../.././a").unwrap(),
349            rootfs_path.join("a")
350        );
351        assert_eq!(
352            scoped_join(&rootfs_path, "../../a").unwrap(),
353            rootfs_path.join("a")
354        );
355        assert_eq!(
356            scoped_join(&rootfs_path, "./../a").unwrap(),
357            rootfs_path.join("a")
358        );
359    }
360
361    #[test]
362    fn test_scoped_join_symlink() {
363        // create temporary directory to emulate container rootfs with symlink
364        let rootfs_dir = tempdir().expect("failed to create tmpdir");
365        let rootfs_path = &rootfs_dir.path();
366        DirBuilder::new()
367            .recursive(true)
368            .create(rootfs_dir.path().join("b/c"))
369            .unwrap();
370        fs::symlink("b/c", rootfs_dir.path().join("a")).unwrap();
371
372        let target = rootfs_path.join("b/c");
373        assert_eq!(scoped_join(&rootfs_path, "a").unwrap(), target);
374        assert_eq!(scoped_join(&rootfs_path, "./a").unwrap(), target);
375        assert_eq!(scoped_join(&rootfs_path, "././a").unwrap(), target);
376        assert_eq!(scoped_join(&rootfs_path, "b/c/../../a").unwrap(), target);
377        assert_eq!(
378            scoped_join(&rootfs_path, "b/c/../../../.././a").unwrap(),
379            target
380        );
381        assert_eq!(scoped_join(&rootfs_path, "../../a").unwrap(), target);
382        assert_eq!(scoped_join(&rootfs_path, "./../a").unwrap(), target);
383        assert_eq!(scoped_join(&rootfs_path, "a/../../../a").unwrap(), target);
384        assert_eq!(scoped_join(&rootfs_path, "a/../../../b/c").unwrap(), target);
385    }
386
387    #[test]
388    fn test_scoped_join_symlink_loop() {
389        // create temporary directory to emulate container rootfs with symlink
390        let rootfs_dir = tempdir().expect("failed to create tmpdir");
391        let rootfs_path = &rootfs_dir.path();
392        fs::symlink("/endpoint_b", rootfs_path.join("endpoint_a")).unwrap();
393        fs::symlink("/endpoint_a", rootfs_path.join("endpoint_b")).unwrap();
394        scoped_join(rootfs_path, "endpoint_a").unwrap_err();
395    }
396
397    #[test]
398    fn test_scoped_join_unicode_character() {
399        // create temporary directory to emulate container rootfs with symlink
400        let rootfs_dir = tempdir().expect("failed to create tmpdir");
401        let rootfs_path = &rootfs_dir.path().canonicalize().unwrap();
402
403        let path = scoped_join(rootfs_path, "您好").unwrap();
404        assert_eq!(path, rootfs_path.join("您好"));
405
406        let path = scoped_join(rootfs_path, "../../../您好").unwrap();
407        assert_eq!(path, rootfs_path.join("您好"));
408
409        let path = scoped_join(rootfs_path, "。。/您好").unwrap();
410        assert_eq!(path, rootfs_path.join("。。/您好"));
411
412        let path = scoped_join(rootfs_path, "您好/../../test").unwrap();
413        assert_eq!(path, rootfs_path.join("test"));
414    }
415}