Skip to main content

actix_files/
path_buf.rs

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