From 8eace960c22ed7b5ebe00c8a47b3441b0f7ebe6b Mon Sep 17 00:00:00 2001 From: Marco Allegretti Date: Wed, 11 Mar 2026 18:54:25 +0100 Subject: [PATCH] fix(file-portal): block dotdot path-traversal in is_allowed Path::starts_with is component-aware but does not resolve .., so /allowed/../etc/passwd would pass the check. Add normalize_path() that lexically resolves . and .. components without touching the filesystem so the check works on non-existent paths too. Add regression test. --- crates/weft-file-portal/src/main.rs | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/weft-file-portal/src/main.rs b/crates/weft-file-portal/src/main.rs index 5fb7ed8..3475464 100644 --- a/crates/weft-file-portal/src/main.rs +++ b/crates/weft-file-portal/src/main.rs @@ -74,11 +74,27 @@ fn parse_allowed(args: &[String]) -> Vec { allowed } +fn normalize_path(path: &Path) -> PathBuf { + use std::path::Component; + let mut out = PathBuf::new(); + for c in path.components() { + match c { + Component::ParentDir => { + out.pop(); + } + Component::CurDir => {} + other => out.push(other), + } + } + out +} + fn is_allowed(path: &Path, allowed: &[PathBuf]) -> bool { if allowed.is_empty() { return false; } - allowed.iter().any(|a| path.starts_with(a)) + let norm = normalize_path(path); + allowed.iter().any(|a| norm.starts_with(a)) } fn handle_connection(stream: UnixStream, allowed: &[PathBuf]) { @@ -191,6 +207,15 @@ mod tests { assert!(!is_allowed(Path::new("/etc/passwd"), &allowed)); } + #[test] + fn dotdot_traversal_blocked() { + let allowed = vec![PathBuf::from("/tmp/weft-test-allowed")]; + assert!(!is_allowed( + Path::new("/tmp/weft-test-allowed/../etc/passwd"), + &allowed + )); + } + #[test] fn empty_allowlist_rejects_all() { assert!(!is_allowed(Path::new("/tmp/anything"), &[]));