| 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