Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-05-24 - Pre-canonicalize Base Path to Prevent TOCTOU Bypass
**Vulnerability:** Path traversal bypass in `FileSystemContext`. A physical check against symlink escapes was entirely dependent on `self.base_path.canonicalize()` succeeding within `secure_path`. If canonicalization failed, the check was silently skipped.
**Learning:** Security checks that depend on file system operations like canonicalization must be performed at initialization, not lazily during use, to avoid Time-of-Check to Time-of-Use (TOCTOU) issues and silent bypasses.
**Prevention:** Canonicalize the base path during constructor initialization (`FileSystemContext::new`) and fail fast if it is invalid, returning a `Result`. Subsequent security checks can then rely on the pre-canonicalized path.
43 changes: 22 additions & 21 deletions crates/services/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,13 @@ pub struct FileSystemContext {
}

impl FileSystemContext {
pub fn new<P: AsRef<Path>>(base_path: P) -> Self {
Self {
base_path: base_path.as_ref().to_path_buf(),
}
pub fn new<P: AsRef<Path>>(base_path: P) -> ServiceResult<Self> {
let canonical_base = base_path.as_ref().canonicalize().map_err(|e| {
ServiceError::execution_dynamic(format!("Failed to canonicalize base path: {}", e))
})?;
Ok(Self {
base_path: canonical_base,
})
Comment on lines +145 to +151
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileSystemContext::new maps canonicalization failures to ServiceError::execution_dynamic(...), but failing to canonicalize the base path is a configuration/initialization problem rather than an execution-context failure. Consider using ServiceError::config_dynamic(...) (and include the provided base_path in the message) so callers can distinguish misconfiguration from runtime execution errors and get better diagnostics.

Copilot uses AI. Check for mistakes.
}

/// Lexically validate path to prevent traversal attacks and symlink escapes
Expand Down Expand Up @@ -196,24 +199,22 @@ impl FileSystemContext {
// 2. Physical security check: verify symlinks don't escape base_path
// We catch escapes by checking the longest existing prefix of the path.
// This is robust even for new files (write_content).
if let Ok(canonical_base) = self.base_path.canonicalize() {
let mut current = final_path.as_path();
while !current.exists() {
if let Some(parent) = current.parent() {
current = parent;
} else {
break;
}
let mut current = final_path.as_path();
while !current.exists() {
if let Some(parent) = current.parent() {
current = parent;
} else {
break;
}
}

if current.exists()
&& let Ok(canonical_prefix) = current.canonicalize()
&& !canonical_prefix.starts_with(&canonical_base)
{
return Err(ServiceError::execution_dynamic(format!(
"Path validation failed: {source} resolves outside base path via symlinks"
)));
}
if current.exists()
&& let Ok(canonical_prefix) = current.canonicalize()
&& !canonical_prefix.starts_with(&self.base_path)
{
return Err(ServiceError::execution_dynamic(format!(
"Path validation failed: {source} resolves outside base path via symlinks"
)));
Comment on lines +211 to +217
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The physical symlink-escape check silently skips validation when current.canonicalize() fails (let Ok(canonical_prefix) = ...). For a security boundary, this should fail closed; otherwise, a permission/IO error during canonicalization can bypass the symlink-escape check. Consider returning an error when current.exists() but canonicalize() returns Err, instead of treating it as a pass.

Suggested change
if current.exists()
&& let Ok(canonical_prefix) = current.canonicalize()
&& !canonical_prefix.starts_with(&self.base_path)
{
return Err(ServiceError::execution_dynamic(format!(
"Path validation failed: {source} resolves outside base path via symlinks"
)));
if current.exists() {
let canonical_prefix = current.canonicalize().map_err(|e| {
ServiceError::execution_dynamic(format!(
"Path validation failed: could not canonicalize existing path prefix for {source}: {e}"
))
})?;
if !canonical_prefix.starts_with(&self.base_path) {
return Err(ServiceError::execution_dynamic(format!(
"Path validation failed: {source} resolves outside base path via symlinks"
)));
}

Copilot uses AI. Check for mistakes.
}

Ok(final_path)
Expand Down Expand Up @@ -310,7 +311,7 @@ mod tests {
#[test]
fn test_file_system_context_security() {
let temp = std::env::temp_dir();
let ctx = FileSystemContext::new(&temp);
let ctx = FileSystemContext::new(&temp).unwrap();

Comment on lines 312 to 315
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unit tests exercise lexical traversal cases, but they don't cover the symlink-escape scenario this change is addressing (e.g., base path provided via symlink that is swapped after context creation, or a symlink inside the sandbox pointing outside). Adding a regression test here would help prevent reintroducing the bypass and validate the physical check behavior.

Copilot uses AI. Check for mistakes.
// Valid paths
assert!(ctx.secure_path("test.txt").is_ok());
Expand Down
2 changes: 1 addition & 1 deletion crates/thread/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ fn test_service_reexports_work() {
use thread::services::FileSystemContext;

// Just check if we can use the types
let _ctx = FileSystemContext::new(".");
let _ctx = FileSystemContext::new(".").unwrap();
}
Loading