Skip to main content

do_memory_mcp/sandbox/
fs.rs

1//! File system access restrictions for sandboxed code
2//!
3//! Implements whitelist-based file system access control with:
4//! - Path validation and sanitization
5//! - Read-only mode enforcement
6//! - Path traversal attack prevention
7//! - Symlink resolution and validation
8
9use anyhow::{Context, Result, bail};
10use std::path::{Path, PathBuf};
11use tracing::{debug, warn};
12
13/// File system restrictions configuration
14#[derive(Debug, Clone)]
15pub struct FileSystemRestrictions {
16    /// Allowed paths (whitelist) - only these paths and subdirectories are accessible
17    pub allowed_paths: Vec<PathBuf>,
18    /// Read-only mode - no write operations allowed
19    pub read_only: bool,
20    /// Maximum path depth to prevent deep directory attacks
21    pub max_path_depth: usize,
22    /// Follow symlinks (risky if enabled)
23    pub follow_symlinks: bool,
24}
25
26impl Default for FileSystemRestrictions {
27    fn default() -> Self {
28        Self {
29            allowed_paths: vec![],
30            read_only: true,
31            max_path_depth: 10,
32            follow_symlinks: false,
33        }
34    }
35}
36
37impl FileSystemRestrictions {
38    /// Create a restrictive configuration (deny all)
39    pub fn deny_all() -> Self {
40        Self {
41            allowed_paths: vec![],
42            read_only: true,
43            max_path_depth: 10,
44            follow_symlinks: false,
45        }
46    }
47
48    /// Create a read-only configuration for specific paths
49    pub fn read_only(allowed_paths: Vec<PathBuf>) -> Self {
50        Self {
51            allowed_paths,
52            read_only: true,
53            max_path_depth: 10,
54            follow_symlinks: false,
55        }
56    }
57
58    /// Create a read-write configuration for specific paths (use with caution)
59    pub fn read_write(allowed_paths: Vec<PathBuf>) -> Self {
60        Self {
61            allowed_paths,
62            read_only: false,
63            max_path_depth: 10,
64            follow_symlinks: false,
65        }
66    }
67
68    /// Validate a path for read access
69    ///
70    /// # Security
71    ///
72    /// This method performs:
73    /// 1. Path canonicalization to resolve .. and symlinks
74    /// 2. Path depth validation
75    /// 3. Whitelist checking
76    /// 4. Path traversal attack detection
77    pub fn validate_read_path(&self, path: &Path) -> Result<PathBuf> {
78        self.validate_path(path, false)
79    }
80
81    /// Validate a path for write access
82    pub fn validate_write_path(&self, path: &Path) -> Result<PathBuf> {
83        if self.read_only {
84            bail!(SecurityError::WriteAccessDenied {
85                path: path.to_string_lossy().to_string()
86            });
87        }
88        self.validate_path(path, true)
89    }
90
91    /// Internal path validation
92    fn validate_path(&self, path: &Path, _is_write: bool) -> Result<PathBuf> {
93        // Check if any paths are allowed
94        if self.allowed_paths.is_empty() {
95            bail!(SecurityError::FileSystemAccessDenied {
96                reason: "No file system access allowed (empty whitelist)".to_string()
97            });
98        }
99
100        // Sanitize path - remove . and .. components
101        let sanitized = sanitize_path(path)?;
102
103        // Check path depth
104        let depth = sanitized.components().count();
105        if depth > self.max_path_depth {
106            bail!(SecurityError::PathTooDeep {
107                path: sanitized.to_string_lossy().to_string(),
108                depth,
109                max_depth: self.max_path_depth
110            });
111        }
112
113        // Resolve symlinks if needed (and allowed)
114        let resolved = if self.follow_symlinks {
115            canonicalize_path(&sanitized)?
116        } else {
117            sanitized.clone()
118        };
119
120        // Check if path is within allowed paths
121        let allowed = self.is_path_allowed(&resolved)?;
122        if !allowed {
123            warn!(
124                "Path access denied: {} (not in whitelist)",
125                resolved.display()
126            );
127            bail!(SecurityError::PathNotInWhitelist {
128                path: resolved.to_string_lossy().to_string(),
129                allowed_paths: self
130                    .allowed_paths
131                    .iter()
132                    .map(|p| p.to_string_lossy().to_string())
133                    .collect()
134            });
135        }
136
137        debug!("Path validated: {}", resolved.display());
138        Ok(resolved)
139    }
140
141    /// Check if a path is within allowed paths
142    fn is_path_allowed(&self, path: &Path) -> Result<bool> {
143        // Always canonicalize the path being checked for consistent comparison
144        let canonical_path = canonicalize_path(path)?;
145
146        for allowed_path in &self.allowed_paths {
147            // Canonicalize allowed path for consistent comparison
148            let canonical_allowed = canonicalize_path(allowed_path)?;
149
150            // Check if path starts with allowed path
151            if canonical_path.starts_with(&canonical_allowed) {
152                return Ok(true);
153            }
154
155            // Also check if they're the same path
156            if canonical_path == canonical_allowed {
157                return Ok(true);
158            }
159        }
160
161        Ok(false)
162    }
163}
164
165/// Sanitize a path by removing . and .. components
166fn sanitize_path(path: &Path) -> Result<PathBuf> {
167    let mut sanitized = PathBuf::new();
168    let mut depth = 0i32;
169
170    for component in path.components() {
171        match component {
172            std::path::Component::Prefix(_) => {
173                // Windows prefix
174                sanitized.push(component);
175            }
176            std::path::Component::RootDir => {
177                sanitized.push(component);
178                depth = 0;
179            }
180            std::path::Component::CurDir => {
181                // Skip . components
182                continue;
183            }
184            std::path::Component::ParentDir => {
185                // Handle .. components
186                if depth > 0 {
187                    sanitized.pop();
188                    depth -= 1;
189                } else {
190                    // Attempted to traverse above root - security violation
191                    bail!(SecurityError::PathTraversalAttempt {
192                        path: path.to_string_lossy().to_string()
193                    });
194                }
195            }
196            std::path::Component::Normal(name) => {
197                // Check for suspicious names
198                let name_str = name.to_string_lossy();
199                if is_suspicious_filename(&name_str) {
200                    bail!(SecurityError::SuspiciousFilename {
201                        filename: name_str.to_string()
202                    });
203                }
204
205                sanitized.push(component);
206                depth += 1;
207            }
208        }
209    }
210
211    Ok(sanitized)
212}
213
214/// Canonicalize a path, handling non-existent paths by canonicalizing the first existing ancestor
215fn canonicalize_path(path: &Path) -> Result<PathBuf> {
216    // If the path exists, canonicalize it directly
217    if path.exists() {
218        return path
219            .canonicalize()
220            .context("Failed to canonicalize existing path");
221    }
222
223    // Otherwise, find the first existing ancestor and canonicalize that
224    let mut current = path.to_path_buf();
225    let mut missing_components = Vec::new();
226
227    loop {
228        if current.exists() {
229            // Found an existing ancestor, canonicalize it
230            let canonical_base = current
231                .canonicalize()
232                .context("Failed to canonicalize ancestor path")?;
233
234            // Rebuild the path with the missing components
235            let mut result = canonical_base;
236            for component in missing_components.iter().rev() {
237                result.push(component);
238            }
239
240            return Ok(result);
241        }
242
243        // Try the parent
244        if let Some(file_name) = current.file_name() {
245            missing_components.push(file_name.to_os_string());
246            if let Some(parent) = current.parent() {
247                current = parent.to_path_buf();
248            } else {
249                // No parent - this shouldn't happen with absolute paths
250                // Just return the original path
251                return Ok(path.to_path_buf());
252            }
253        } else {
254            // No file name - we're at the root
255            return Ok(path.to_path_buf());
256        }
257    }
258}
259
260/// Check if a filename is suspicious
261fn is_suspicious_filename(name: &str) -> bool {
262    // Check for null bytes
263    if name.contains('\0') {
264        return true;
265    }
266
267    // Check for control characters
268    if name.chars().any(|c| c.is_control()) {
269        return true;
270    }
271
272    // Check for hidden Unicode characters
273    if name.chars().any(|c| {
274        matches!(
275            c,
276            '\u{200B}' // Zero-width space
277            | '\u{200C}' // Zero-width non-joiner
278            | '\u{200D}' // Zero-width joiner
279            | '\u{FEFF}' // Zero-width no-break space
280        )
281    }) {
282        return true;
283    }
284
285    false
286}
287
288/// Security errors for file system operations
289#[derive(Debug, thiserror::Error)]
290pub enum SecurityError {
291    #[error("File system access denied: {reason}")]
292    FileSystemAccessDenied { reason: String },
293
294    #[error("Write access denied for path: {path}")]
295    WriteAccessDenied { path: String },
296
297    #[error("Path not in whitelist: {path} (allowed: {allowed_paths:?})")]
298    PathNotInWhitelist {
299        path: String,
300        allowed_paths: Vec<String>,
301    },
302
303    #[error("Path too deep: {path} (depth: {depth}, max: {max_depth})")]
304    PathTooDeep {
305        path: String,
306        depth: usize,
307        max_depth: usize,
308    },
309
310    #[error("Path traversal attempt detected: {path}")]
311    PathTraversalAttempt { path: String },
312
313    #[error("Suspicious filename detected: {filename}")]
314    SuspiciousFilename { filename: String },
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use std::fs;
321    use tempfile::TempDir;
322
323    #[test]
324    fn test_sanitize_path_basic() {
325        let path = Path::new("/tmp/test");
326        let sanitized = sanitize_path(path).unwrap();
327        assert_eq!(sanitized, PathBuf::from("/tmp/test"));
328    }
329
330    #[test]
331    fn test_sanitize_path_removes_current_dir() {
332        let path = Path::new("/tmp/./test");
333        let sanitized = sanitize_path(path).unwrap();
334        assert_eq!(sanitized, PathBuf::from("/tmp/test"));
335    }
336
337    #[test]
338    fn test_sanitize_path_handles_parent_dir() {
339        let path = Path::new("/tmp/foo/../test");
340        let sanitized = sanitize_path(path).unwrap();
341        assert_eq!(sanitized, PathBuf::from("/tmp/test"));
342    }
343
344    #[test]
345    fn test_sanitize_path_prevents_traversal_above_root() {
346        let path = Path::new("/../etc/passwd");
347        let result = sanitize_path(path);
348        assert!(result.is_err());
349    }
350
351    #[test]
352    fn test_is_suspicious_filename_null_byte() {
353        assert!(is_suspicious_filename("file\0name"));
354    }
355
356    #[test]
357    fn test_is_suspicious_filename_control_chars() {
358        assert!(is_suspicious_filename("file\nname"));
359        assert!(is_suspicious_filename("file\rname"));
360    }
361
362    #[test]
363    fn test_is_suspicious_filename_zero_width() {
364        assert!(is_suspicious_filename("file\u{200B}name"));
365    }
366
367    #[test]
368    fn test_is_suspicious_filename_normal() {
369        assert!(!is_suspicious_filename("normal_file.txt"));
370        assert!(!is_suspicious_filename("file-name.json"));
371    }
372
373    #[test]
374    fn test_deny_all() {
375        let restrictions = FileSystemRestrictions::deny_all();
376        let result = restrictions.validate_read_path(Path::new("/tmp/test"));
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn test_read_only_mode_denies_writes() {
382        let temp_dir = TempDir::new().unwrap();
383        let restrictions = FileSystemRestrictions::read_only(vec![temp_dir.path().to_path_buf()]);
384
385        let test_path = temp_dir.path().join("test.txt");
386        let result = restrictions.validate_write_path(&test_path);
387        assert!(result.is_err());
388    }
389
390    #[test]
391    fn test_whitelist_allows_subdirectories() {
392        let temp_dir = TempDir::new().unwrap();
393        let sub_dir = temp_dir.path().join("subdir");
394        fs::create_dir_all(&sub_dir).unwrap();
395
396        let restrictions = FileSystemRestrictions::read_write(vec![temp_dir.path().to_path_buf()]);
397
398        let test_path = sub_dir.join("test.txt");
399        let result = restrictions.validate_write_path(&test_path);
400        assert!(result.is_ok());
401    }
402
403    #[test]
404    fn test_whitelist_denies_outside_paths() {
405        let temp_dir = TempDir::new().unwrap();
406        let restrictions = FileSystemRestrictions::read_only(vec![temp_dir.path().to_path_buf()]);
407
408        let outside_path = Path::new("/etc/passwd");
409        let result = restrictions.validate_read_path(outside_path);
410        assert!(result.is_err());
411    }
412
413    #[test]
414    fn test_path_depth_limit() {
415        let temp_dir = TempDir::new().unwrap();
416        let mut restrictions =
417            FileSystemRestrictions::read_only(vec![temp_dir.path().to_path_buf()]);
418        restrictions.max_path_depth = 2;
419
420        // Create deep path
421        let deep_path = temp_dir.path().join("a/b/c/d/e/f/g");
422
423        let result = restrictions.validate_read_path(&deep_path);
424        assert!(result.is_err());
425    }
426}