Skip to main content

eggserve_core/path/
mod.rs

1//! Path confinement: request-target parsing, validation, and policy enforcement.
2
3pub mod components;
4pub mod decode;
5pub mod platform;
6pub mod policy;
7pub mod rejected;
8pub mod request_target;
9
10pub use policy::{DotfilePolicy, PathPolicy};
11pub use rejected::PathRejection;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct ConfinedPath {
15    decoded: String,
16    components: Vec<String>,
17    path_policy: PathPolicy,
18}
19
20impl ConfinedPath {
21    pub fn parse(raw: &str, policy: &PathPolicy) -> Result<Self, PathRejection> {
22        if raw.len() > 8192 {
23            return Err(PathRejection::TooLong);
24        }
25        let path = request_target::parse_origin_form(raw)?;
26
27        let decoded = decode::percent_decode(path)?;
28
29        let normalized = components::normalize_path(&decoded);
30
31        let parts = components::split_components(&normalized);
32
33        components::validate_components(&parts, policy)?;
34
35        Ok(Self {
36            decoded,
37            components: parts,
38            path_policy: policy.clone(),
39        })
40    }
41
42    #[allow(dead_code)]
43    pub fn as_str(&self) -> &str {
44        &self.decoded
45    }
46
47    pub fn components(&self) -> &[String] {
48        &self.components
49    }
50
51    pub fn path_policy(&self) -> &PathPolicy {
52        &self.path_policy
53    }
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    fn default_policy() -> PathPolicy {
61        PathPolicy::default()
62    }
63
64    #[test]
65    fn simple_path() {
66        let p = ConfinedPath::parse("/foo/bar", &default_policy()).unwrap();
67        assert_eq!(p.as_str(), "/foo/bar");
68        assert_eq!(p.components(), &["foo", "bar"]);
69    }
70
71    #[test]
72    fn root_path() {
73        let p = ConfinedPath::parse("/", &default_policy()).unwrap();
74        assert_eq!(p.as_str(), "/");
75        assert_eq!(p.components().len(), 0);
76    }
77
78    #[test]
79    fn path_with_query_stripped() {
80        let p = ConfinedPath::parse("/foo?bar=baz", &default_policy()).unwrap();
81        assert_eq!(p.as_str(), "/foo");
82        assert_eq!(p.components(), &["foo"]);
83    }
84
85    #[test]
86    fn reject_empty() {
87        assert_eq!(
88            ConfinedPath::parse("", &default_policy()).unwrap_err(),
89            PathRejection::Empty
90        );
91    }
92
93    #[test]
94    fn reject_absolute_form() {
95        assert_eq!(
96            ConfinedPath::parse("http://example.com/path", &default_policy()).unwrap_err(),
97            PathRejection::UnsupportedUriForm
98        );
99    }
100
101    #[test]
102    fn reject_asterisk_form() {
103        assert_eq!(
104            ConfinedPath::parse("*", &default_policy()).unwrap_err(),
105            PathRejection::UnsupportedUriForm
106        );
107    }
108
109    #[test]
110    fn reject_authority_form() {
111        assert_eq!(
112            ConfinedPath::parse("example.com:443", &default_policy()).unwrap_err(),
113            PathRejection::UnsupportedUriForm
114        );
115    }
116
117    #[test]
118    fn normalize_consecutive_slashes() {
119        let p = ConfinedPath::parse("/foo//bar", &default_policy()).unwrap();
120        assert_eq!(p.components(), &["foo", "bar"]);
121    }
122
123    #[test]
124    fn reject_dot_component() {
125        assert_eq!(
126            ConfinedPath::parse("/foo/./bar", &default_policy()).unwrap_err(),
127            PathRejection::CurrentComponent
128        );
129    }
130
131    #[test]
132    fn reject_dotdot_component() {
133        assert_eq!(
134            ConfinedPath::parse("/../etc/passwd", &default_policy()).unwrap_err(),
135            PathRejection::ParentComponent
136        );
137    }
138
139    #[test]
140    fn reject_percent_encoded_dotdot() {
141        assert_eq!(
142            ConfinedPath::parse("/%2e%2e/etc/passwd", &default_policy()).unwrap_err(),
143            PathRejection::ParentComponent
144        );
145    }
146
147    #[test]
148    fn reject_uppercase_percent_encoded_dotdot() {
149        assert_eq!(
150            ConfinedPath::parse("/%2E%2E/etc/passwd", &default_policy()).unwrap_err(),
151            PathRejection::ParentComponent
152        );
153    }
154
155    #[test]
156    fn reject_double_encoded_dotdot() {
157        assert_eq!(
158            ConfinedPath::parse("/%252e%252e/etc/passwd", &default_policy()).unwrap_err(),
159            PathRejection::ParentComponent
160        );
161    }
162
163    #[test]
164    fn reject_dotdot_in_path() {
165        assert_eq!(
166            ConfinedPath::parse("/foo/../../bar", &default_policy()).unwrap_err(),
167            PathRejection::ParentComponent
168        );
169    }
170
171    #[test]
172    fn reject_percent_encoded_dotdot_in_path() {
173        assert_eq!(
174            ConfinedPath::parse("/foo/%2e%2e/bar", &default_policy()).unwrap_err(),
175            PathRejection::ParentComponent
176        );
177    }
178
179    #[test]
180    fn reject_backslash() {
181        assert_eq!(
182            ConfinedPath::parse("/foo\\bar", &default_policy()).unwrap_err(),
183            PathRejection::SeparatorAmbiguity
184        );
185    }
186
187    #[test]
188    fn reject_percent_encoded_backslash() {
189        assert_eq!(
190            ConfinedPath::parse("/%5cetc%5cpasswd", &default_policy()).unwrap_err(),
191            PathRejection::SeparatorAmbiguity
192        );
193    }
194
195    #[test]
196    fn reject_windows_drive_prefix() {
197        assert_eq!(
198            ConfinedPath::parse("/C:/Windows/System32", &default_policy()).unwrap_err(),
199            PathRejection::WindowsPrefixDenied
200        );
201    }
202
203    #[test]
204    fn reject_percent_encoded_windows_drive() {
205        assert_eq!(
206            ConfinedPath::parse("/c%3a/Windows/System32", &default_policy()).unwrap_err(),
207            PathRejection::WindowsPrefixDenied
208        );
209    }
210
211    #[test]
212    fn reject_dotfile() {
213        assert_eq!(
214            ConfinedPath::parse("/.env", &default_policy()).unwrap_err(),
215            PathRejection::DotfileDenied
216        );
217    }
218
219    #[test]
220    fn reject_dotfile_git_config() {
221        assert_eq!(
222            ConfinedPath::parse("/.git/config", &default_policy()).unwrap_err(),
223            PathRejection::DotfileDenied
224        );
225    }
226
227    #[test]
228    fn reject_dotfile_in_subdir() {
229        assert_eq!(
230            ConfinedPath::parse("/foo/.secret", &default_policy()).unwrap_err(),
231            PathRejection::DotfileDenied
232        );
233    }
234
235    #[test]
236    fn reject_windows_reserved_con() {
237        assert_eq!(
238            ConfinedPath::parse("/CON", &default_policy()).unwrap_err(),
239            PathRejection::WindowsReservedNameDenied
240        );
241    }
242
243    #[test]
244    fn reject_windows_reserved_aux() {
245        assert_eq!(
246            ConfinedPath::parse("/AUX.txt", &default_policy()).unwrap_err(),
247            PathRejection::WindowsReservedNameDenied
248        );
249    }
250
251    #[test]
252    fn reject_windows_reserved_com1() {
253        assert_eq!(
254            ConfinedPath::parse("/COM1", &default_policy()).unwrap_err(),
255            PathRejection::WindowsReservedNameDenied
256        );
257    }
258
259    #[test]
260    fn reject_windows_ads() {
261        assert_eq!(
262            ConfinedPath::parse("/file.txt:stream", &default_policy()).unwrap_err(),
263            PathRejection::WindowsAlternateStreamDenied
264        );
265    }
266
267    #[test]
268    fn reject_nul() {
269        assert_eq!(
270            ConfinedPath::parse("/%00", &default_policy()).unwrap_err(),
271            PathRejection::NulByte
272        );
273    }
274
275    #[test]
276    fn reject_malformed_percent() {
277        assert_eq!(
278            ConfinedPath::parse("/%ZZ", &default_policy()).unwrap_err(),
279            PathRejection::MalformedPercentEncoding
280        );
281    }
282
283    #[test]
284    fn allow_dotfile_when_policy_permits() {
285        let policy = PathPolicy {
286            dotfiles: DotfilePolicy::Allow,
287            ..PathPolicy::default()
288        };
289        let p = ConfinedPath::parse("/.env", &policy).unwrap();
290        assert_eq!(p.as_str(), "/.env");
291    }
292
293    #[test]
294    fn allow_backslash_when_policy_permits() {
295        let policy = PathPolicy {
296            reject_backslash: false,
297            ..PathPolicy::default()
298        };
299        let p = ConfinedPath::parse("/foo\\bar", &policy).unwrap();
300        assert_eq!(p.as_str(), "/foo\\bar");
301    }
302
303    #[test]
304    fn reject_double_slash_root() {
305        let p = ConfinedPath::parse("//", &default_policy()).unwrap();
306        assert_eq!(p.components().len(), 0);
307    }
308
309    #[test]
310    fn reject_triple_slash() {
311        let p = ConfinedPath::parse("///", &default_policy()).unwrap();
312        assert_eq!(p.components().len(), 0);
313    }
314
315    #[test]
316    fn path_policy_returns_parsed_policy() {
317        let policy = PathPolicy {
318            dotfiles: DotfilePolicy::Allow,
319            ..PathPolicy::default()
320        };
321        let p = ConfinedPath::parse("/.env", &policy).unwrap();
322        assert_eq!(p.path_policy(), &policy);
323    }
324
325    #[test]
326    fn path_policy_default_returns_default() {
327        let p = ConfinedPath::parse("/foo", &default_policy()).unwrap();
328        assert_eq!(p.path_policy(), &default_policy());
329    }
330
331    #[test]
332    fn reject_too_long() {
333        let long = format!("/{}", "a".repeat(8192));
334        assert_eq!(
335            ConfinedPath::parse(&long, &default_policy()).unwrap_err(),
336            PathRejection::TooLong
337        );
338    }
339
340    #[test]
341    fn allow_max_length() {
342        let max_len = format!("/{}", "a".repeat(8191));
343        assert!(ConfinedPath::parse(&max_len, &default_policy()).is_ok());
344    }
345}