Skip to main content

actix_files/
path_buf.rs

1use std::{
2    borrow::Cow,
3    path::{Component, Path, PathBuf},
4    str::FromStr,
5};
6
7#[expect(deprecated, reason = "preserve the public ready future type")]
8use actix_utils::future::{ready, Ready};
9use actix_web::{dev::Payload, FromRequest, HttpRequest};
10
11use crate::error::UriSegmentError;
12
13/// Secure Path Traversal Guard
14///
15/// This struct parses a request-uri [`PathBuf`](std::path::PathBuf)
16#[derive(Debug, PartialEq, Eq)]
17pub struct PathBufWrap(PathBuf);
18
19impl FromStr for PathBufWrap {
20    type Err = UriSegmentError;
21
22    fn from_str(path: &str) -> Result<Self, Self::Err> {
23        Self::parse_path(path, false)
24    }
25}
26
27impl PathBufWrap {
28    /// Parse a safe path from the unprocessed tail of a supplied
29    /// [`HttpRequest`](actix_web::HttpRequest), given the choice of allowing hidden files to be
30    /// considered valid segments.
31    ///
32    /// This uses [`HttpRequest::match_info`](actix_web::HttpRequest::match_info) and
33    /// [`Path::unprocessed`](actix_web::dev::Path::unprocessed), which returns the part of the
34    /// path not matched by route patterns. This is useful for mounted services (eg. `Files`),
35    /// where only the tail should be parsed.
36    ///
37    /// Path traversal is guarded by this method.
38    #[inline]
39    pub fn parse_unprocessed_req(
40        req: &HttpRequest,
41        hidden_files: bool,
42    ) -> Result<Self, UriSegmentError> {
43        Self::parse_path(req.match_info().unprocessed(), hidden_files)
44    }
45
46    /// Parse a safe path from the full request path of a supplied
47    /// [`HttpRequest`](actix_web::HttpRequest), given the choice of allowing hidden files to be
48    /// considered valid segments.
49    ///
50    /// This uses [`HttpRequest::path`](actix_web::HttpRequest::path), and is more appropriate
51    /// for non-mounted handlers that want the entire request path.
52    ///
53    /// Path traversal is guarded by this method.
54    #[inline]
55    pub fn parse_req_path(req: &HttpRequest, hidden_files: bool) -> Result<Self, UriSegmentError> {
56        Self::parse_path(req.path(), hidden_files)
57    }
58
59    /// Parse a path, giving the choice of allowing hidden files to be considered valid segments.
60    ///
61    /// Path traversal is guarded by this method.
62    pub fn parse_path(path: &str, hidden_files: bool) -> Result<Self, UriSegmentError> {
63        let mut buf = PathBuf::new();
64
65        // equivalent to `path.split('/').count()`
66        let mut segment_count = path.matches('/').count() + 1;
67
68        // we can decode the whole path here (instead of per-segment decoding)
69        // because we will reject `%2F` in paths using `segment_count`.
70        let path = percent_encoding::percent_decode_str(path)
71            .decode_utf8()
72            .map_err(|_| UriSegmentError::NotValidUtf8)?;
73
74        // disallow decoding `%2F` into `/`
75        if let Cow::Owned(ref path) = path {
76            if segment_count != path.matches('/').count() + 1 {
77                return Err(UriSegmentError::BadChar('/'));
78            }
79        }
80
81        for segment in path.split('/') {
82            if segment == "." {
83                return Err(UriSegmentError::BadStart('.'));
84            } else if segment == ".." {
85                segment_count -= 1;
86                buf.pop();
87            } else if !hidden_files && segment.starts_with('.') {
88                return Err(UriSegmentError::BadStart('.'));
89            } else if segment.starts_with('*') {
90                return Err(UriSegmentError::BadStart('*'));
91            } else if segment.ends_with(':') {
92                return Err(UriSegmentError::BadEnd(':'));
93            } else if segment.ends_with('>') {
94                return Err(UriSegmentError::BadEnd('>'));
95            } else if segment.ends_with('<') {
96                return Err(UriSegmentError::BadEnd('<'));
97            } else if segment.is_empty() {
98                segment_count -= 1;
99                continue;
100            } else if cfg!(windows) && segment.contains('\\') {
101                return Err(UriSegmentError::BadChar('\\'));
102            } else if cfg!(windows) && segment.contains(':') {
103                return Err(UriSegmentError::BadChar(':'));
104            } else {
105                buf.push(segment)
106            }
107        }
108
109        // make sure we agree with stdlib parser
110        for (i, component) in buf.components().enumerate() {
111            assert!(
112                matches!(component, Component::Normal(_)),
113                "component `{:?}` is not normal",
114                component
115            );
116            assert!(i < segment_count);
117        }
118
119        Ok(PathBufWrap(buf))
120    }
121}
122
123impl AsRef<Path> for PathBufWrap {
124    fn as_ref(&self) -> &Path {
125        self.0.as_ref()
126    }
127}
128
129#[expect(deprecated, reason = "preserve the public ready future type")]
130impl FromRequest for PathBufWrap {
131    type Error = UriSegmentError;
132    type Future = Ready<Result<Self, Self::Error>>;
133
134    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
135        // Uses the unprocessed tail of the request path and disallows hidden files.
136        ready(req.match_info().unprocessed().parse())
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn test_path_buf() {
146        assert_eq!(
147            PathBufWrap::from_str("/test/.tt").map(|t| t.0),
148            Err(UriSegmentError::BadStart('.'))
149        );
150        assert_eq!(
151            PathBufWrap::from_str("/test/*tt").map(|t| t.0),
152            Err(UriSegmentError::BadStart('*'))
153        );
154        assert_eq!(
155            PathBufWrap::from_str("/test/tt:").map(|t| t.0),
156            Err(UriSegmentError::BadEnd(':'))
157        );
158        assert_eq!(
159            PathBufWrap::from_str("/test/tt<").map(|t| t.0),
160            Err(UriSegmentError::BadEnd('<'))
161        );
162        assert_eq!(
163            PathBufWrap::from_str("/test/tt>").map(|t| t.0),
164            Err(UriSegmentError::BadEnd('>'))
165        );
166        assert_eq!(
167            PathBufWrap::from_str("/seg1/seg2/").unwrap().0,
168            PathBuf::from_iter(vec!["seg1", "seg2"])
169        );
170        assert_eq!(
171            PathBufWrap::from_str("/seg1/../seg2/").unwrap().0,
172            PathBuf::from_iter(vec!["seg2"])
173        );
174    }
175
176    #[test]
177    fn test_parse_path() {
178        assert_eq!(
179            PathBufWrap::parse_path("/test/.tt", false).map(|t| t.0),
180            Err(UriSegmentError::BadStart('.'))
181        );
182
183        assert_eq!(
184            PathBufWrap::parse_path("/test/.tt", true).unwrap().0,
185            PathBuf::from_iter(vec!["test", ".tt"])
186        );
187
188        assert_eq!(
189            PathBufWrap::parse_path("/test/./file.txt", true).map(|t| t.0),
190            Err(UriSegmentError::BadStart('.'))
191        );
192    }
193
194    #[test]
195    fn path_traversal() {
196        assert_eq!(
197            PathBufWrap::parse_path("/../README.md", false).unwrap().0,
198            PathBuf::from_iter(vec!["README.md"])
199        );
200
201        assert_eq!(
202            PathBufWrap::parse_path("/../README.md", true).unwrap().0,
203            PathBuf::from_iter(vec!["README.md"])
204        );
205
206        assert_eq!(
207            PathBufWrap::parse_path("/../../../../../../../../../../etc/passwd", false)
208                .unwrap()
209                .0,
210            PathBuf::from_iter(vec!["etc/passwd"])
211        );
212    }
213
214    #[test]
215    fn encoded_slash_is_rejected() {
216        assert_eq!(
217            PathBufWrap::parse_path("/test%2Ffile.txt", false),
218            Err(UriSegmentError::BadChar('/'))
219        );
220    }
221
222    #[test]
223    #[cfg_attr(windows, should_panic)]
224    fn windows_drive_traversal() {
225        // detect issues in windows that could lead to path traversal
226        // see <https://github.com/SergioBenitez/Rocket/issues/1949
227
228        assert_eq!(
229            PathBufWrap::parse_path("C:test.txt", false).unwrap().0,
230            PathBuf::from_iter(vec!["C:test.txt"])
231        );
232
233        assert_eq!(
234            PathBufWrap::parse_path("C:../whatever", false).unwrap().0,
235            PathBuf::from_iter(vec!["C:../whatever"])
236        );
237
238        assert_eq!(
239            PathBufWrap::parse_path(":test.txt", false).unwrap().0,
240            PathBuf::from_iter(vec![":test.txt"])
241        );
242    }
243}