-
Notifications
You must be signed in to change notification settings - Fork 0
🛡️ Sentinel: [CRITICAL] Fix Path Traversal in FileSystemContext #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| /// Lexically validate path to prevent traversal attacks and symlink escapes | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||
| 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
AI
Apr 22, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FileSystemContext::newmaps canonicalization failures toServiceError::execution_dynamic(...), but failing to canonicalize the base path is a configuration/initialization problem rather than an execution-context failure. Consider usingServiceError::config_dynamic(...)(and include the providedbase_pathin the message) so callers can distinguish misconfiguration from runtime execution errors and get better diagnostics.