RFA-171 · Case file with fixtures · Case 143 of 694 · Runtime evidence
Why Path::join Can Replace the Base Path
Path::join composes paths according to platform rules; it does not prove that a result stays under a trusted directory. An absolute component replaces the base, and relative parent components require a separate containment policy.
- Reviewed
- Rust
- Rust 1.98.1, edition 2024
- Targets
- Unix fixture; path rules are target-specific
- Profiles
- dev, release, test
Direct answer
What this Rust failure means
- Why it happens
- Path joining follows platform path semantics: pushing an absolute path replaces the accumulated path rather than treating it as a relative child.
- First discriminating check
- Check candidate.is_absolute before joining and inspect path components instead of assuming join provides containment.
I used to read base.join(candidate) as “put this candidate below the base.” That is a useful sentence for ordinary relative paths, but it is not the contract of the API.
The failing program joins /srv/application/files with /etc/passwd on Unix. The result is /etc/passwd. The base is gone.
This is not a filesystem escape hidden inside an unsafe API. It is normal path composition, and it becomes dangerous only when my application gives join a security meaning that join never promised.
An absolute candidate replaces what came before
Path::join returns an owned path made by joining its argument to the current path. The linked PathBuf::push behaviour is important: if the pushed path is absolute, it replaces the previous path.
On Unix, the operation is easy to visualise:
/srv/application/files + reports/today.txt -> /srv/application/files/reports/today.txt
/srv/application/files + /etc/passwd -> /etc/passwd
The second operand is already rooted, so treating it as a child would produce a strange path with two roots. Rust follows the platform's path rules instead.
The repaired program checks Path::is_absolute before joining. That closes the exact failure demonstrated by the case.
Rejecting absolute paths is only the first check
A relative path can still contain parent components:
../../secrets/key
Joining that value produces a path that is textually based under my directory, but filesystem resolution may walk outside it. This means “not absolute” and “contained” are different properties.
For a deliberately small namespace, I inspect Component values and reject ParentDir, RootDir, and platform prefixes. I may also reject CurDir to keep stored names canonical.
This component policy is often better than searching the string for ... Path components understand separators and target rules; substring checks confuse a filename such as report..txt with a parent directory.
Normalisation is not filesystem containment
Removing . and resolving lexical .. components can make a path easier to compare. It still does not account for symbolic links, mount points, or a directory changed between validation and use.
For example, a path that looks like /srv/application/files/customer/avatar may cross a symlink at customer. A string prefix comparison cannot see that. Even canonicalising both paths can leave a time-of-check/time-of-use gap if an attacker can change the tree after the check.
When the boundary is security-sensitive, I prefer operating relative to an already opened directory with platform facilities that constrain resolution. The precise facility is operating-system specific. The general Rust principle is simpler: a PathBuf describes a path; it is not proof of where a later filesystem operation will land.
Prefix checks have their own trap
I do not use a string check like this:
result.to_string_lossy().starts_with(base.to_string_lossy().as_ref())
/srv/files-backup starts with the text /srv/files, but it is not inside /srv/files. Path-aware starts_with compares complete components and is a better lexical check, after applying an explicit normalisation policy.
That still remains a lexical claim. I name it accordingly in code, for example is_lexically_below, rather than is_safe_path.
Cross-platform code needs target-aware fixtures
The evidence here is intentionally marked as a Unix fixture. Windows has prefixes, drive letters, rooted paths, verbatim forms, and separator rules that do not fit a Unix-only model.
If a service accepts paths produced on one system and consumes them on another, I decide whose syntax is authoritative. std::path interprets paths using the current target's rules; it is not a universal parser for every platform's path syntax.
I test the actual supported targets with candidates covering:
- an ordinary child path;
- an absolute or rooted path;
- one and several parent components;
- empty and current-directory components;
- symlinks when the real filesystem boundary matters;
- target-specific prefixes and separators.
My review sequence
When I see a path built from external input, I now ask:
- Is this merely path construction, or is somebody treating it as containment?
- Can the candidate be absolute or rooted?
- Are parent components allowed?
- Can symbolic links or concurrent filesystem changes cross the boundary?
- Which operating-system path grammar applies?
- Can the application use an opened directory as the capability instead of validating strings?
The core principle extends beyond Rust: composition is not validation. Path::join correctly combines path syntax. My application must separately define and enforce which filesystem locations are allowed.