Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! Path normalisation and containment.
2//!
3//! Spec §9: "Every path from a URL or a tree walk is normalized and verified to
4//! be inside the repo root before use. Reject `..`, absolute paths, and NUL
5//! bytes at the parser."
6//!
7//! These are pure functions with dense tests, because a mistake here is a
8//! read-anything-on-the-host bug and because every one of the cases below has
9//! been a real CVE in a real forge.
10
11use std::path::{Component, Path, PathBuf};
12
13/// Why a path was rejected.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PathError {
16 /// Contains `..`, or otherwise escapes the root.
17 Traversal,
18 /// Begins at the filesystem root.
19 Absolute,
20 /// Contains a NUL byte or other control character.
21 Control,
22 /// Longer than we are willing to handle.
23 TooLong,
24}
25
26impl std::fmt::Display for PathError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 let s = match self {
29 PathError::Traversal => "path escapes the repository root",
30 PathError::Absolute => "path must be relative",
31 PathError::Control => "path contains control characters",
32 PathError::TooLong => "path is too long",
33 };
34 f.write_str(s)
35 }
36}
37
38impl std::error::Error for PathError {}
39
40/// Git's own limit is generous; this is the point past which a path is not a
41/// real file but an attempt to exhaust something.
42const MAX_PATH_LEN: usize = 4096;
43const MAX_COMPONENTS: usize = 64;
44
45/// Normalise a repository-relative path from a URL.
46///
47/// Returns a clean, forward-slash-separated path with no leading slash, no `.`
48/// or `..` components, and no empty segments. Rejects anything that would
49/// escape the repository root rather than silently clamping it — a clamped
50/// path is a path the user did not ask for, and answering a different question
51/// is worse than refusing.
52pub fn normalise(raw: &str) -> Result<String, PathError> {
53 if raw.len() > MAX_PATH_LEN {
54 return Err(PathError::TooLong);
55 }
56
57 // NUL and other C0 controls: a NUL truncates the path in any C API it
58 // reaches, and escapes reach terminals and log lines (spec §9).
59 if raw.bytes().any(|b| b < 0x20 || b == 0x7f) {
60 return Err(PathError::Control);
61 }
62
63 // Reject Windows-style separators outright rather than translating them.
64 // A repository may legitimately contain a file with a backslash in its
65 // name, and quietly rewriting the request would serve the wrong file.
66 if raw.starts_with('/') || raw.starts_with('\\') {
67 return Err(PathError::Absolute);
68 }
69 // A Windows drive prefix is absolute on some platforms.
70 if raw.len() >= 2 && raw.as_bytes()[1] == b':' {
71 return Err(PathError::Absolute);
72 }
73
74 let mut out: Vec<&str> = Vec::new();
75 for segment in raw.split('/') {
76 match segment {
77 // Collapse `//` and trailing `/`.
78 "" | "." => continue,
79 ".." => return Err(PathError::Traversal),
80 s => {
81 // `.git` inside a tree is not inherently dangerous to *read*,
82 // but it is never a path the browser should follow, and Git
83 // itself refuses to track it.
84 out.push(s);
85 }
86 }
87 }
88
89 if out.len() > MAX_COMPONENTS {
90 return Err(PathError::TooLong);
91 }
92
93 Ok(out.join("/"))
94}
95
96/// Join a normalised relative path onto a root and verify containment.
97///
98/// Belt and braces over [`normalise`]: this is the check that still holds if a
99/// caller constructs a path some other way. It is purely lexical — it does not
100/// touch the filesystem — so it is safe to call before the path exists.
101pub fn contain(root: &Path, relative: &str) -> Result<PathBuf, PathError> {
102 let clean = normalise(relative)?;
103 let joined = root.join(&clean);
104
105 // Re-verify by walking components, so a `..` introduced by any means is
106 // caught even if `normalise` were bypassed.
107 let mut depth: i32 = 0;
108 for c in Path::new(&clean).components() {
109 match c {
110 Component::Normal(_) => depth += 1,
111 Component::CurDir => {}
112 Component::ParentDir => {
113 depth -= 1;
114 if depth < 0 {
115 return Err(PathError::Traversal);
116 }
117 }
118 Component::RootDir | Component::Prefix(_) => return Err(PathError::Absolute),
119 }
120 }
121
122 Ok(joined)
123}
124
125/// Split a normalised path into its components.
126pub fn segments(path: &str) -> Vec<&str> {
127 path.split('/').filter(|s| !s.is_empty()).collect()
128}
129
130/// Breadcrumb trail for a path: each entry is (label, cumulative path).
131pub fn breadcrumbs(path: &str) -> Vec<(&str, String)> {
132 let mut acc = String::new();
133 let mut out = Vec::new();
134 for seg in segments(path) {
135 if !acc.is_empty() {
136 acc.push('/');
137 }
138 acc.push_str(seg);
139 out.push((seg, acc.clone()));
140 }
141 out
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn normalises_ordinary_paths() {
150 assert_eq!(normalise("src/main.rs").unwrap(), "src/main.rs");
151 assert_eq!(normalise("README.md").unwrap(), "README.md");
152 assert_eq!(normalise("").unwrap(), "");
153 }
154
155 #[test]
156 fn collapses_redundant_separators_and_dots() {
157 assert_eq!(normalise("src//main.rs").unwrap(), "src/main.rs");
158 assert_eq!(normalise("./src/./main.rs").unwrap(), "src/main.rs");
159 assert_eq!(normalise("src/").unwrap(), "src");
160 assert_eq!(normalise("a///b////c").unwrap(), "a/b/c");
161 }
162
163 // ─── traversal (spec §9) ─────────────────────────────────────────────────
164
165 #[test]
166 fn rejects_traversal_in_every_position() {
167 for bad in [
168 "..",
169 "../etc/passwd",
170 "src/../../etc/passwd",
171 "a/b/../../..",
172 "./..",
173 "src/..",
174 "a/../../b",
175 ] {
176 assert_eq!(
177 normalise(bad),
178 Err(PathError::Traversal),
179 "must reject traversal: {bad:?}"
180 );
181 }
182 }
183
184 #[test]
185 fn rejects_absolute_paths() {
186 for bad in ["/etc/passwd", "/", "\\windows\\system32", "C:/Windows"] {
187 assert!(
188 matches!(normalise(bad), Err(PathError::Absolute)),
189 "must reject absolute: {bad:?}"
190 );
191 }
192 }
193
194 #[test]
195 fn rejects_nul_and_control_bytes() {
196 assert_eq!(normalise("a\0b"), Err(PathError::Control));
197 assert_eq!(normalise("a\nb"), Err(PathError::Control));
198 assert_eq!(normalise("a\rb"), Err(PathError::Control));
199 // ANSI escapes reach terminals and log lines.
200 assert_eq!(normalise("a\x1b[31mb"), Err(PathError::Control));
201 assert_eq!(normalise("a\x7fb"), Err(PathError::Control));
202 }
203
204 #[test]
205 fn does_not_rewrite_backslashes_inside_a_name() {
206 // A file may legitimately be named with a backslash. Translating it to
207 // a separator would serve a different file than the one requested.
208 assert_eq!(normalise("weird\\name.txt").unwrap(), "weird\\name.txt");
209 }
210
211 #[test]
212 fn url_encoded_traversal_is_not_decoded_here() {
213 // %2e%2e is decoded by the HTTP layer before reaching us. If it somehow
214 // arrives literally it is just an ordinary filename, not traversal —
215 // documenting that the decode must happen upstream, exactly once.
216 assert_eq!(normalise("%2e%2e/etc").unwrap(), "%2e%2e/etc");
217 }
218
219 #[test]
220 fn rejects_absurd_lengths() {
221 assert_eq!(normalise(&"a".repeat(5000)), Err(PathError::TooLong));
222 let deep = (0..100).map(|_| "a").collect::<Vec<_>>().join("/");
223 assert_eq!(normalise(&deep), Err(PathError::TooLong));
224 }
225
226 // ─── containment ─────────────────────────────────────────────────────────
227
228 #[test]
229 fn contain_keeps_paths_under_the_root() {
230 let root = Path::new("/srv/repos/ab/repo.git");
231 assert_eq!(
232 contain(root, "src/main.rs").unwrap(),
233 PathBuf::from("/srv/repos/ab/repo.git/src/main.rs")
234 );
235 }
236
237 #[test]
238 fn contain_rejects_escapes() {
239 let root = Path::new("/srv/repos/ab/repo.git");
240 assert_eq!(contain(root, "../../../etc/passwd"), Err(PathError::Traversal));
241 assert_eq!(contain(root, "/etc/passwd"), Err(PathError::Absolute));
242 }
243
244 #[test]
245 fn contained_paths_always_start_with_the_root() {
246 // The property that matters, asserted over a range of inputs.
247 let root = Path::new("/srv/repos/ab/repo.git");
248 for p in ["a", "a/b", "a/b/c.txt", "", "./a", "a//b"] {
249 let joined = contain(root, p).unwrap();
250 assert!(
251 joined.starts_with(root),
252 "{p:?} produced {joined:?}, which is outside {root:?}"
253 );
254 }
255 }
256
257 // ─── breadcrumbs ─────────────────────────────────────────────────────────
258
259 #[test]
260 fn breadcrumbs_accumulate() {
261 assert_eq!(
262 breadcrumbs("src/routes/mod.rs"),
263 vec![
264 ("src", "src".to_string()),
265 ("routes", "src/routes".to_string()),
266 ("mod.rs", "src/routes/mod.rs".to_string()),
267 ]
268 );
269 assert!(breadcrumbs("").is_empty());
270 }
271}

271 lines · Rust