fix(terminal): validate fnm PATH resolution against symlink escape#84
Merged
matej21 merged 1 commit intocontember:mainfrom Mar 27, 2026
Merged
Conversation
Canonicalize the fnm alias target and verify it stays within the fnm directory before adding it to PATH. This prevents an attacker from placing a symlink at ~/.local/share/fnm/aliases/default that points outside the fnm directory to inject a malicious directory into PATH. Closes contember#77 Co-Authored-By: Claude Code
There was a problem hiding this comment.
Pull request overview
Hardens get_extended_path()’s fnm integration by preventing ~/.local/share/fnm/aliases/default from resolving to a path outside the fnm directory (mitigating PATH-hijack via symlink escape, per #77).
Changes:
- Canonicalizes
fnm_dirand the computed Nodeinstallation/binpath before adding it to PATH. - Skips adding the path (and logs a warning) if the canonicalized bin path is outside the canonical fnm directory.
Comments suppressed due to low confidence (2)
crates/okena-terminal/src/session_backend.rs:706
node_bin.canonicalize()succeeds for non-directory paths too, so this can add a file path to PATH (regression vs the previousnode_bin.is_dir()guard). Add an explicit directory check (e.g.,canonical_bin.is_dir()) before inserting intoresult/seen.
// Validate the resolved path stays within fnm directory to prevent symlink escape
if let Ok(canonical_bin) = node_bin.canonicalize() {
if !canonical_bin.starts_with(&fnm_canonical) {
log::warn!("fnm alias points outside fnm directory, skipping: {:?}", node_bin);
return;
}
if let Some(s) = canonical_bin.to_str() {
if seen.insert(s.to_string()) {
result.push(s.to_string());
}
}
}
crates/okena-terminal/src/session_backend.rs:706
- This change adds security-critical behavior (accept valid alias paths within
fnm_dir, reject symlink escapes, and emit a warning). There are existing unit tests in this file, but none coverresolve_fnm_path; please add Unix-only tests using a temp dir + symlinks to assert (1) a normal alias adds the expected bin dir and (2) an alias that resolves outsidefnm_diris skipped.
let fnm_canonical = match fnm_dir.canonicalize() {
Ok(p) => p,
Err(_) => return,
};
// fnm aliases: default → specific version
let default_alias = fnm_dir.join("aliases/default");
if let Ok(version) = std::fs::read_link(&default_alias)
.or_else(|_| std::fs::read_to_string(&default_alias).map(std::path::PathBuf::from))
{
// version is either an absolute path or just a version string like "v22.14.0"
let node_bin = if version.is_absolute() {
version.join("installation/bin")
} else {
fnm_dir.join("node-versions").join(version.to_string_lossy().trim()).join("installation/bin")
};
// Validate the resolved path stays within fnm directory to prevent symlink escape
if let Ok(canonical_bin) = node_bin.canonicalize() {
if !canonical_bin.starts_with(&fnm_canonical) {
log::warn!("fnm alias points outside fnm directory, skipping: {:?}", node_bin);
return;
}
if let Some(s) = canonical_bin.to_str() {
if seen.insert(s.to_string()) {
result.push(s.to_string());
}
}
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Validate the resolved path stays within fnm directory to prevent symlink escape | ||
| if let Ok(canonical_bin) = node_bin.canonicalize() { | ||
| if !canonical_bin.starts_with(&fnm_canonical) { | ||
| log::warn!("fnm alias points outside fnm directory, skipping: {:?}", node_bin); |
There was a problem hiding this comment.
The warning logs node_bin (pre-canonicalization), which may be relative and make it hard to see where it actually escaped to. Consider logging the canonicalized path (and/or both) so the warning is actionable when diagnosing PATH issues.
Suggested change
| log::warn!("fnm alias points outside fnm directory, skipping: {:?}", node_bin); | |
| log::warn!( | |
| "fnm alias points outside fnm directory, skipping: canonical={:?}, original={:?}", | |
| canonical_bin, | |
| node_bin | |
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
~/.local/share/fnm/aliases/defaultfrom injecting an attacker-controlled directory into PATHCloses #77
Test plan
cargo buildsucceedscargo testpasses (32 tests)Co-Authored-By: Claude Code