#264 Jul 17, 2026

264. Path::join — One Absolute Path and Your Base Directory Is Gone

base.join(user_input) looks like it appends. But if the input starts with /, join silently throws your base away and returns the input alone.

The trap

Path::join (and PathBuf::push) have a documented surprise: if the argument is an absolute path, it replaces the whole path instead of appending.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
use std::path::Path;

let base = Path::new("/srv/uploads");

// what you expect
let a = base.join("cat.png");
assert_eq!(a, Path::new("/srv/uploads/cat.png"));

// absolute input: base is gone
let b = base.join("/etc/passwd");
assert_eq!(b, Path::new("/etc/passwd"));

No panic, no warning — just a path that escaped your directory. If the joined segment comes from user input (an upload filename, a URL fragment, a config value), this is a path-traversal hole that .. filters won’t catch, because there’s no .. in sight.

The fix

Validate before you join: the input must be relative, and every component must be a normal segment — no .., no root, no prefix.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
use std::path::{Component, Path, PathBuf};

fn safe_join(base: &Path, user: &str) -> Option<PathBuf> {
    let p = Path::new(user);
    let ok = p.is_relative()
        && p.components()
            .all(|c| matches!(c, Component::Normal(_)));
    ok.then(|| base.join(p))
}

let base = Path::new("/srv/uploads");

assert!(safe_join(base, "/etc/passwd").is_none());
assert!(safe_join(base, "../secret").is_none());
assert_eq!(
    safe_join(base, "img/cat.png"),
    Some(PathBuf::from("/srv/uploads/img/cat.png"))
);

The components() check rejects .. traversal in the same pass, so both escape routes are closed with one predicate.

One caveat

This blocks the lexical escapes, not symlinks — if the tree may contain attacker-created links, canonicalize and verify the result still starts with your base. And on Windows the same replacement happens with drive letters and prefixes (base.join("C:\\evil")), so the is_relative() check is doing real work on every platform.

← Previous 263. String::replace_range — Swap a Span In Place, No Rebuild Next → 265. Path::strip_prefix — Compare Paths by Component, Not by Character