Jump to…
snowinitial commitqoxwzsukwmkx1mo
Matt W1//! Path normalisation and containment.
Matt W2//!
Matt W3//! Spec §9: "Every path from a URL or a tree walk is normalized and verified to
Matt W4//! be inside the repo root before use. Reject `..`, absolute paths, and NUL
Matt W5//! bytes at the parser."
Matt W6//!
Matt W7//! These are pure functions with dense tests, because a mistake here is a
Matt W8//! read-anything-on-the-host bug and because every one of the cases below has
Matt W9//! been a real CVE in a real forge.
Matt W10
Matt W11use std::path::{Component, Path, PathBuf};
Matt W12
Matt W13/// Why a path was rejected.
Matt W14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Matt W15pub enum PathError {
Matt W16 /// Contains `..`, or otherwise escapes the root.
Matt W17 Traversal,
Matt W18 /// Begins at the filesystem root.
Matt W19 Absolute,
Matt W20 /// Contains a NUL byte or other control character.
Matt W21 Control,
Matt W22 /// Longer than we are willing to handle.
Matt W23 TooLong,
Matt W24}
Matt W25
Matt W26impl std::fmt::Display for PathError {
Matt W27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Matt W28 let s = match self {
Matt W29 PathError::Traversal => "path escapes the repository root",
Matt W30 PathError::Absolute => "path must be relative",
Matt W31 PathError::Control => "path contains control characters",
Matt W32 PathError::TooLong => "path is too long",
Matt W33 };
Matt W34 f.write_str(s)
Matt W35 }
Matt W36}
Matt W37
Matt W38impl std::error::Error for PathError {}
Matt W39
Matt W40/// Git's own limit is generous; this is the point past which a path is not a
Matt W41/// real file but an attempt to exhaust something.
Matt W42const MAX_PATH_LEN: usize = 4096;
Matt W43const MAX_COMPONENTS: usize = 64;
Matt W44
Matt W45/// Normalise a repository-relative path from a URL.
Matt W46///
Matt W47/// Returns a clean, forward-slash-separated path with no leading slash, no `.`
Matt W48/// or `..` components, and no empty segments. Rejects anything that would
Matt W49/// escape the repository root rather than silently clamping it — a clamped
Matt W50/// path is a path the user did not ask for, and answering a different question
Matt W51/// is worse than refusing.
Matt W52pub fn normalise(raw: &str) -> Result<String, PathError> {
Matt W53 if raw.len() > MAX_PATH_LEN {
Matt W54 return Err(PathError::TooLong);
Matt W55 }
Matt W56
Matt W57 // NUL and other C0 controls: a NUL truncates the path in any C API it
Matt W58 // reaches, and escapes reach terminals and log lines (spec §9).
Matt W59 if raw.bytes().any(|b| b < 0x20 || b == 0x7f) {
Matt W60 return Err(PathError::Control);
Matt W61 }
Matt W62
Matt W63 // Reject Windows-style separators outright rather than translating them.
Matt W64 // A repository may legitimately contain a file with a backslash in its
Matt W65 // name, and quietly rewriting the request would serve the wrong file.
Matt W66 if raw.starts_with('/') || raw.starts_with('\\') {
Matt W67 return Err(PathError::Absolute);
Matt W68 }
Matt W69 // A Windows drive prefix is absolute on some platforms.
Matt W70 if raw.len() >= 2 && raw.as_bytes()[1] == b':' {
Matt W71 return Err(PathError::Absolute);
Matt W72 }
Matt W73
Matt W74 let mut out: Vec<&str> = Vec::new();
Matt W75 for segment in raw.split('/') {
Matt W76 match segment {
Matt W77 // Collapse `//` and trailing `/`.
Matt W78 "" | "." => continue,
Matt W79 ".." => return Err(PathError::Traversal),
Matt W80 s => {
Matt W81 // `.git` inside a tree is not inherently dangerous to *read*,
Matt W82 // but it is never a path the browser should follow, and Git
Matt W83 // itself refuses to track it.
Matt W84 out.push(s);
Matt W85 }
Matt W86 }
Matt W87 }
Matt W88
Matt W89 if out.len() > MAX_COMPONENTS {
Matt W90 return Err(PathError::TooLong);
Matt W91 }
Matt W92
Matt W93 Ok(out.join("/"))
Matt W94}
Matt W95
Matt W96/// Join a normalised relative path onto a root and verify containment.
Matt W97///
Matt W98/// Belt and braces over [`normalise`]: this is the check that still holds if a
Matt W99/// caller constructs a path some other way. It is purely lexical — it does not
Matt W100/// touch the filesystem — so it is safe to call before the path exists.
Matt W101pub fn contain(root: &Path, relative: &str) -> Result<PathBuf, PathError> {
Matt W102 let clean = normalise(relative)?;
Matt W103 let joined = root.join(&clean);
Matt W104
Matt W105 // Re-verify by walking components, so a `..` introduced by any means is
Matt W106 // caught even if `normalise` were bypassed.
Matt W107 let mut depth: i32 = 0;
Matt W108 for c in Path::new(&clean).components() {
Matt W109 match c {
Matt W110 Component::Normal(_) => depth += 1,
Matt W111 Component::CurDir => {}
Matt W112 Component::ParentDir => {
Matt W113 depth -= 1;
Matt W114 if depth < 0 {
Matt W115 return Err(PathError::Traversal);
Matt W116 }
Matt W117 }
Matt W118 Component::RootDir | Component::Prefix(_) => return Err(PathError::Absolute),
Matt W119 }
Matt W120 }
Matt W121
Matt W122 Ok(joined)
Matt W123}
Matt W124
Matt W125/// Split a normalised path into its components.
Matt W126pub fn segments(path: &str) -> Vec<&str> {
Matt W127 path.split('/').filter(|s| !s.is_empty()).collect()
Matt W128}
Matt W129
Matt W130/// Breadcrumb trail for a path: each entry is (label, cumulative path).
Matt W131pub fn breadcrumbs(path: &str) -> Vec<(&str, String)> {
Matt W132 let mut acc = String::new();
Matt W133 let mut out = Vec::new();
Matt W134 for seg in segments(path) {
Matt W135 if !acc.is_empty() {
Matt W136 acc.push('/');
Matt W137 }
Matt W138 acc.push_str(seg);
Matt W139 out.push((seg, acc.clone()));
Matt W140 }
Matt W141 out
Matt W142}
Matt W143
Matt W144#[cfg(test)]
Matt W145mod tests {
Matt W146 use super::*;
Matt W147
Matt W148 #[test]
Matt W149 fn normalises_ordinary_paths() {
Matt W150 assert_eq!(normalise("src/main.rs").unwrap(), "src/main.rs");
Matt W151 assert_eq!(normalise("README.md").unwrap(), "README.md");
Matt W152 assert_eq!(normalise("").unwrap(), "");
Matt W153 }
Matt W154
Matt W155 #[test]
Matt W156 fn collapses_redundant_separators_and_dots() {
Matt W157 assert_eq!(normalise("src//main.rs").unwrap(), "src/main.rs");
Matt W158 assert_eq!(normalise("./src/./main.rs").unwrap(), "src/main.rs");
Matt W159 assert_eq!(normalise("src/").unwrap(), "src");
Matt W160 assert_eq!(normalise("a///b////c").unwrap(), "a/b/c");
Matt W161 }
Matt W162
Matt W163 // ─── traversal (spec §9) ─────────────────────────────────────────────────
Matt W164
Matt W165 #[test]
Matt W166 fn rejects_traversal_in_every_position() {
Matt W167 for bad in [
Matt W168 "..",
Matt W169 "../etc/passwd",
Matt W170 "src/../../etc/passwd",
Matt W171 "a/b/../../..",
Matt W172 "./..",
Matt W173 "src/..",
Matt W174 "a/../../b",
Matt W175 ] {
Matt W176 assert_eq!(
Matt W177 normalise(bad),
Matt W178 Err(PathError::Traversal),
Matt W179 "must reject traversal: {bad:?}"
Matt W180 );
Matt W181 }
Matt W182 }
Matt W183
Matt W184 #[test]
Matt W185 fn rejects_absolute_paths() {
Matt W186 for bad in ["/etc/passwd", "/", "\\windows\\system32", "C:/Windows"] {
Matt W187 assert!(
Matt W188 matches!(normalise(bad), Err(PathError::Absolute)),
Matt W189 "must reject absolute: {bad:?}"
Matt W190 );
Matt W191 }
Matt W192 }
Matt W193
Matt W194 #[test]
Matt W195 fn rejects_nul_and_control_bytes() {
Matt W196 assert_eq!(normalise("a\0b"), Err(PathError::Control));
Matt W197 assert_eq!(normalise("a\nb"), Err(PathError::Control));
Matt W198 assert_eq!(normalise("a\rb"), Err(PathError::Control));
Matt W199 // ANSI escapes reach terminals and log lines.
Matt W200 assert_eq!(normalise("a\x1b[31mb"), Err(PathError::Control));
Matt W201 assert_eq!(normalise("a\x7fb"), Err(PathError::Control));
Matt W202 }
Matt W203
Matt W204 #[test]
Matt W205 fn does_not_rewrite_backslashes_inside_a_name() {
Matt W206 // A file may legitimately be named with a backslash. Translating it to
Matt W207 // a separator would serve a different file than the one requested.
Matt W208 assert_eq!(normalise("weird\\name.txt").unwrap(), "weird\\name.txt");
Matt W209 }
Matt W210
Matt W211 #[test]
Matt W212 fn url_encoded_traversal_is_not_decoded_here() {
Matt W213 // %2e%2e is decoded by the HTTP layer before reaching us. If it somehow
Matt W214 // arrives literally it is just an ordinary filename, not traversal —
Matt W215 // documenting that the decode must happen upstream, exactly once.
Matt W216 assert_eq!(normalise("%2e%2e/etc").unwrap(), "%2e%2e/etc");
Matt W217 }
Matt W218
Matt W219 #[test]
Matt W220 fn rejects_absurd_lengths() {
Matt W221 assert_eq!(normalise(&"a".repeat(5000)), Err(PathError::TooLong));
Matt W222 let deep = (0..100).map(|_| "a").collect::<Vec<_>>().join("/");
Matt W223 assert_eq!(normalise(&deep), Err(PathError::TooLong));
Matt W224 }
Matt W225
Matt W226 // ─── containment ─────────────────────────────────────────────────────────
Matt W227
Matt W228 #[test]
Matt W229 fn contain_keeps_paths_under_the_root() {
Matt W230 let root = Path::new("/srv/repos/ab/repo.git");
Matt W231 assert_eq!(
Matt W232 contain(root, "src/main.rs").unwrap(),
Matt W233 PathBuf::from("/srv/repos/ab/repo.git/src/main.rs")
Matt W234 );
Matt W235 }
Matt W236
Matt W237 #[test]
Matt W238 fn contain_rejects_escapes() {
Matt W239 let root = Path::new("/srv/repos/ab/repo.git");
Matt W240 assert_eq!(contain(root, "../../../etc/passwd"), Err(PathError::Traversal));
Matt W241 assert_eq!(contain(root, "/etc/passwd"), Err(PathError::Absolute));
Matt W242 }
Matt W243
Matt W244 #[test]
Matt W245 fn contained_paths_always_start_with_the_root() {
Matt W246 // The property that matters, asserted over a range of inputs.
Matt W247 let root = Path::new("/srv/repos/ab/repo.git");
Matt W248 for p in ["a", "a/b", "a/b/c.txt", "", "./a", "a//b"] {
Matt W249 let joined = contain(root, p).unwrap();
Matt W250 assert!(
Matt W251 joined.starts_with(root),
Matt W252 "{p:?} produced {joined:?}, which is outside {root:?}"
Matt W253 );
Matt W254 }
Matt W255 }
Matt W256
Matt W257 // ─── breadcrumbs ─────────────────────────────────────────────────────────
Matt W258
Matt W259 #[test]
Matt W260 fn breadcrumbs_accumulate() {
Matt W261 assert_eq!(
Matt W262 breadcrumbs("src/routes/mod.rs"),
Matt W263 vec![
Matt W264 ("src", "src".to_string()),
Matt W265 ("routes", "src/routes".to_string()),
Matt W266 ("mod.rs", "src/routes/mod.rs".to_string()),
Matt W267 ]
Matt W268 );
Matt W269 assert!(breadcrumbs("").is_empty());
Matt W270 }
Matt W271}

271 lines · Rust