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.
This commit is contained in:
Marco Allegretti 2026-03-11 18:54:25 +01:00
parent a18f5c7604
commit 8eace960c2

View file

@ -74,11 +74,27 @@ fn parse_allowed(args: &[String]) -> Vec<PathBuf> {
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"), &[]));