| 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 | |
| 11 | use std::path::{Component, Path, PathBuf}; |
| 12 | |
| 13 | /// Why a path was rejected. |
| 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 15 | pub 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 | |
| 26 | impl 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 | |
| 38 | impl 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. |
| 42 | const MAX_PATH_LEN: usize = 4096; |
| 43 | const 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. |
| 52 | pub 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. |
| 101 | pub 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. |
| 126 | pub 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). |
| 131 | pub 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)] |
| 145 | mod 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