Skip to main content

nika_engine/io/
security.rs

1//! Security Module - Path Validation for Artifact Output
2//!
3//! Provides security-critical path validation functions for artifact output.
4//! These functions prevent path traversal attacks and ensure artifacts stay
5//! within the designated output directory.
6//!
7//! # Security Principles
8//!
9//! 1. **Canonical Path Validation**: All paths are canonicalized before comparison
10//! 2. **Strict Boundary Enforcement**: Artifacts must be within the workflow directory
11//! 3. **No Symlink Following**: Symlinks that escape the boundary are rejected
12//! 4. **Fail Closed**: Any validation error results in rejection
13//!
14//! # Example
15//!
16//! ```ignore
17//! use nika::io::security::validate_artifact_path;
18//! use std::path::Path;
19//!
20//! let artifacts_dir = Path::new("/project/.nika/artifacts");
21//! validate_artifact_path(artifacts_dir, Path::new("task1/output.json"))?;
22//! ```
23
24use std::path::{Path, PathBuf};
25
26use crate::error::NikaError;
27
28/// Default artifact output directory relative to workflow
29pub const DEFAULT_ARTIFACT_DIR: &str = ".nika/artifacts";
30
31/// Maximum path length to prevent resource exhaustion attacks
32const MAX_PATH_LENGTH: usize = 4096;
33
34/// Validate that an artifact output path stays within the artifact directory boundary
35///
36/// This is the primary security check for artifact output. It ensures that:
37/// 1. The output path resolves to a location within the artifact directory
38/// 2. Path traversal attacks using `../` are blocked
39/// 3. Symlink attacks are detected and blocked
40///
41/// # Arguments
42///
43/// * `artifact_dir` - The base artifact directory (must be absolute)
44/// * `output_path` - The relative output path from artifact configuration
45///
46/// # Returns
47///
48/// The validated absolute path, or an error if validation fails.
49///
50/// # Errors
51///
52/// - `NikaError::ArtifactPathError` if the path escapes the artifact directory
53/// - `NikaError::ArtifactPathError` if the path contains invalid characters
54///
55/// # Security Notes
56///
57/// CRITICAL: This function must be called before any file write operation.
58/// Skipping this validation can lead to arbitrary file write vulnerabilities.
59pub fn validate_artifact_path(
60    artifact_dir: &Path,
61    output_path: &Path,
62) -> Result<PathBuf, NikaError> {
63    let output_str = output_path.to_string_lossy();
64
65    // Validate path length
66    if output_str.len() > MAX_PATH_LENGTH {
67        return Err(NikaError::ArtifactPathError {
68            path: output_str.to_string(),
69            reason: format!(
70                "Path exceeds maximum length of {} characters",
71                MAX_PATH_LENGTH
72            ),
73        });
74    }
75
76    // Reject absolute paths in output specification
77    if output_path.is_absolute() {
78        return Err(NikaError::ArtifactPathError {
79            path: output_str.to_string(),
80            reason: "Absolute paths are not allowed in artifact output".to_string(),
81        });
82    }
83
84    // Resolve the full path
85    let full_path = artifact_dir.join(output_path);
86
87    // Create parent directories if they don't exist (needed for canonicalize)
88    // We validate the path components first without creating
89    validate_path_components(output_path)?;
90
91    // For paths that don't exist yet, we validate the normalized path
92    // instead of using canonicalize (which requires the path to exist)
93    let normalized = normalize_path(&full_path);
94
95    // Ensure the artifact directory exists and is canonicalized
96    let canonical_base = if artifact_dir.exists() {
97        artifact_dir
98            .canonicalize()
99            .map_err(|e| NikaError::ArtifactPathError {
100                path: artifact_dir.display().to_string(),
101                reason: format!("Failed to canonicalize artifact directory: {}", e),
102            })?
103    } else {
104        // If artifact dir doesn't exist, normalize it
105        normalize_path(artifact_dir)
106    };
107
108    // Check that normalized path starts with the canonical base
109    if !normalized.starts_with(&canonical_base) {
110        return Err(NikaError::ArtifactPathError {
111            path: output_str.to_string(),
112            reason: format!(
113                "Path traversal detected: '{}' would escape artifact directory '{}'",
114                output_path.display(),
115                artifact_dir.display()
116            ),
117        });
118    }
119
120    Ok(full_path)
121}
122
123/// Validate individual path components for security issues
124///
125/// Checks each component of the path for potentially dangerous patterns:
126/// - Null bytes (can truncate paths in some systems)
127/// - Leading dots in directory names (hidden files, except . and ..)
128/// - Control characters
129fn validate_path_components(path: &Path) -> Result<(), NikaError> {
130    for component in path.components() {
131        let component_str = component.as_os_str().to_string_lossy();
132
133        // Check for null bytes
134        if component_str.contains('\0') {
135            return Err(NikaError::ArtifactPathError {
136                path: path.display().to_string(),
137                reason: "Path contains null bytes".to_string(),
138            });
139        }
140
141        // Check for control characters
142        if component_str.chars().any(|c| c.is_control() && c != '\t') {
143            return Err(NikaError::ArtifactPathError {
144                path: path.display().to_string(),
145                reason: "Path contains control characters".to_string(),
146            });
147        }
148    }
149
150    Ok(())
151}
152
153/// Normalize a path by resolving `.` and `..` components without filesystem access
154///
155/// This is used when we need to validate paths that don't exist yet.
156///
157/// # Safety
158///
159/// For absolute paths, `..` at the root is a no-op (can't go above `/`).
160/// For relative paths, unresolvable `..` components are preserved to ensure
161/// the boundary check detects traversal attempts rather than silently swallowing them.
162fn normalize_path(path: &Path) -> PathBuf {
163    let mut components: Vec<std::path::Component<'_>> = Vec::new();
164
165    for component in path.components() {
166        match component {
167            std::path::Component::ParentDir => {
168                // Pop only if the last component is a Normal directory.
169                // Preserve `..` if we'd go above the starting point (prevents
170                // silent traversal swallowing on relative paths).
171                match components.last() {
172                    Some(std::path::Component::Normal(_)) => {
173                        components.pop();
174                    }
175                    Some(std::path::Component::RootDir) | Some(std::path::Component::Prefix(_)) => {
176                        // At root — can't go higher, skip this `..`
177                    }
178                    _ => {
179                        // Empty or already has `..` — preserve for boundary detection
180                        components.push(component);
181                    }
182                }
183            }
184            std::path::Component::CurDir => {
185                // Skip current directory references
186            }
187            _ => {
188                components.push(component);
189            }
190        }
191    }
192
193    components.iter().collect()
194}
195
196/// Error type for path boundary validation
197///
198/// Contains the target path and reason for conversion to context-specific error types.
199#[derive(Debug, Clone)]
200pub struct PathBoundaryError {
201    /// The target path that failed validation
202    pub target_path: PathBuf,
203    /// Human-readable reason for the failure
204    pub reason: String,
205}
206
207impl std::fmt::Display for PathBoundaryError {
208    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209        write!(f, "{}", self.reason)
210    }
211}
212
213impl std::error::Error for PathBoundaryError {}
214
215/// Validate that a path stays within a base directory boundary using canonicalization
216///
217/// This is the primary security function for validating file paths. It uses
218/// `canonicalize()` to resolve symlinks and verify the real path stays within bounds.
219///
220/// # Security
221///
222/// - Both paths must exist for canonicalization to work
223/// - Symlinks are resolved to their real targets
224/// - Prevents path traversal attacks using `../` or symlinks
225///
226/// # Arguments
227///
228/// * `base_path` - The boundary directory (must exist)
229/// * `target_path` - The path to validate (must exist)
230///
231/// # Returns
232///
233/// `Ok(())` if the target is within the base, or `PathBoundaryError` with details.
234///
235/// # Example
236///
237/// ```ignore
238/// use nika::io::security::validate_canonicalized_boundary;
239///
240/// let project = Path::new("/project");
241/// let file = Path::new("/project/data/file.txt");
242/// validate_canonicalized_boundary(project, file)?;
243/// ```
244pub fn validate_canonicalized_boundary(
245    base_path: &Path,
246    target_path: &Path,
247) -> Result<(), PathBoundaryError> {
248    let canonical_base = base_path.canonicalize().map_err(|e| PathBoundaryError {
249        target_path: target_path.to_path_buf(),
250        reason: format!("Cannot resolve base path '{}': {}", base_path.display(), e),
251    })?;
252
253    let canonical_target = target_path.canonicalize().map_err(|e| PathBoundaryError {
254        target_path: target_path.to_path_buf(),
255        reason: format!(
256            "Cannot resolve target path '{}': {}",
257            target_path.display(),
258            e
259        ),
260    })?;
261
262    if !canonical_target.starts_with(&canonical_base) {
263        return Err(PathBoundaryError {
264            target_path: target_path.to_path_buf(),
265            reason: format!(
266                "Path traversal detected: '{}' is outside project boundary '{}'",
267                target_path.display(),
268                base_path.display()
269            ),
270        });
271    }
272
273    Ok(())
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use std::fs;
280    use tempfile::tempdir;
281
282    #[test]
283    fn test_validate_artifact_path_simple() {
284        let artifact_dir = PathBuf::from("/project/artifacts");
285        let result = validate_artifact_path(&artifact_dir, Path::new("task1/output.json"));
286        assert!(result.is_ok());
287        assert_eq!(
288            result.unwrap(),
289            PathBuf::from("/project/artifacts/task1/output.json")
290        );
291    }
292
293    #[test]
294    fn test_validate_artifact_path_nested() {
295        let artifact_dir = PathBuf::from("/project/artifacts");
296        let result = validate_artifact_path(&artifact_dir, Path::new("2024/01/15/report.json"));
297        assert!(result.is_ok());
298    }
299
300    #[test]
301    fn test_validate_artifact_path_traversal_blocked() {
302        let artifact_dir = PathBuf::from("/project/artifacts");
303        let result = validate_artifact_path(&artifact_dir, Path::new("../../../etc/passwd"));
304        assert!(result.is_err());
305        let err = result.unwrap_err();
306        assert!(matches!(err, NikaError::ArtifactPathError { .. }));
307    }
308
309    #[test]
310    fn test_validate_artifact_path_absolute_rejected() {
311        let artifact_dir = PathBuf::from("/project/artifacts");
312        let result = validate_artifact_path(&artifact_dir, Path::new("/etc/passwd"));
313        assert!(result.is_err());
314        let err = result.unwrap_err();
315        if let NikaError::ArtifactPathError { reason, .. } = err {
316            assert!(reason.contains("Absolute paths"));
317        } else {
318            panic!("Expected ArtifactPathError");
319        }
320    }
321
322    #[test]
323    fn test_validate_artifact_path_null_byte_rejected() {
324        let artifact_dir = PathBuf::from("/project/artifacts");
325        let result = validate_artifact_path(&artifact_dir, Path::new("file\0.txt"));
326        assert!(result.is_err());
327        let err = result.unwrap_err();
328        if let NikaError::ArtifactPathError { reason, .. } = err {
329            assert!(reason.contains("null bytes"));
330        } else {
331            panic!("Expected ArtifactPathError");
332        }
333    }
334
335    #[test]
336    fn test_validate_path_components_clean() {
337        let result = validate_path_components(Path::new("task1/output.json"));
338        assert!(result.is_ok());
339    }
340
341    #[test]
342    fn test_validate_path_components_with_dots() {
343        let result = validate_path_components(Path::new("../parent"));
344        assert!(result.is_ok()); // Components are valid, boundary check is separate
345    }
346
347    #[test]
348    fn test_normalize_path_removes_parent_refs() {
349        let path = PathBuf::from("/project/artifacts/../output");
350        let normalized = normalize_path(&path);
351        assert_eq!(normalized, PathBuf::from("/project/output"));
352    }
353
354    #[test]
355    fn test_normalize_path_removes_current_refs() {
356        let path = PathBuf::from("/project/./artifacts/./output");
357        let normalized = normalize_path(&path);
358        assert_eq!(normalized, PathBuf::from("/project/artifacts/output"));
359    }
360
361    #[test]
362    fn test_normalize_path_complex() {
363        let path = PathBuf::from("/project/a/b/../c/./d/../e");
364        let normalized = normalize_path(&path);
365        assert_eq!(normalized, PathBuf::from("/project/a/c/e"));
366    }
367
368    #[test]
369    fn test_max_path_length_enforced() {
370        let artifact_dir = PathBuf::from("/project/artifacts");
371        let long_path = "a".repeat(MAX_PATH_LENGTH + 1);
372        let result = validate_artifact_path(&artifact_dir, Path::new(&long_path));
373        assert!(result.is_err());
374        let err = result.unwrap_err();
375        if let NikaError::ArtifactPathError { reason, .. } = err {
376            assert!(reason.contains("maximum length"));
377        } else {
378            panic!("Expected ArtifactPathError");
379        }
380    }
381
382    #[test]
383    fn test_validate_with_existing_dir() {
384        let temp = tempdir().unwrap();
385        let artifact_dir = temp.path().join("artifacts");
386        fs::create_dir_all(&artifact_dir).unwrap();
387
388        // Use canonicalized path to avoid symlink issues (macOS: /var -> /private/var)
389        let canonical_artifact_dir = artifact_dir.canonicalize().unwrap();
390        let result = validate_artifact_path(&canonical_artifact_dir, Path::new("output.json"));
391        assert!(result.is_ok());
392    }
393
394    #[test]
395    fn test_hidden_parent_escape() {
396        let artifact_dir = PathBuf::from("/project/artifacts");
397        // Try to escape using nested parent references
398        let result = validate_artifact_path(&artifact_dir, Path::new("a/../../b"));
399        assert!(result.is_err());
400    }
401
402    // ═══════════════════════════════════════════════════════════════
403    // SECURITY: Symlink attack detection tests
404    // ═══════════════════════════════════════════════════════════════
405
406    #[cfg(unix)]
407    #[test]
408    fn test_validate_canonicalized_boundary_detects_symlink_escape() {
409        use std::os::unix::fs::symlink;
410
411        let temp = tempdir().unwrap();
412        let base_dir = temp.path().join("artifacts");
413        fs::create_dir_all(&base_dir).unwrap();
414
415        let escape_target = temp.path().join("outside");
416        fs::create_dir_all(&escape_target).unwrap();
417        let secret_file = escape_target.join("secret.txt");
418        fs::write(&secret_file, "sensitive data").unwrap();
419
420        let symlink_path = base_dir.join("evil");
421        symlink(&escape_target, &symlink_path).unwrap();
422
423        let result = validate_canonicalized_boundary(&base_dir, &symlink_path.join("secret.txt"));
424        assert!(
425            result.is_err(),
426            "validate_canonicalized_boundary must detect symlink-based escape"
427        );
428        assert!(
429            result.unwrap_err().reason.contains("traversal"),
430            "Error should mention path traversal"
431        );
432    }
433
434    #[cfg(unix)]
435    #[test]
436    fn test_validate_artifact_path_does_not_resolve_symlinks() {
437        // validate_artifact_path uses normalize_path (logical) not canonicalize.
438        // This documents the known limitation: symlinks inside the artifact dir
439        // that point outside are NOT detected by this function.
440        use std::os::unix::fs::symlink;
441
442        let temp = tempdir().unwrap();
443        let artifact_dir = temp.path().join("artifacts");
444        fs::create_dir_all(&artifact_dir).unwrap();
445        let canonical_dir = artifact_dir.canonicalize().unwrap();
446
447        let escape_target = temp.path().join("outside");
448        fs::create_dir_all(&escape_target).unwrap();
449        let symlink_dir = canonical_dir.join("escape_link");
450        symlink(&escape_target, &symlink_dir).unwrap();
451
452        let result = validate_artifact_path(&canonical_dir, Path::new("escape_link/file.txt"));
453        assert!(
454            result.is_ok(),
455            "validate_artifact_path does not resolve symlinks (known limitation)"
456        );
457    }
458
459    #[test]
460    fn test_validate_artifact_path_dot_dot_in_middle() {
461        let artifact_dir = PathBuf::from("/project/artifacts");
462        let result = validate_artifact_path(&artifact_dir, Path::new("subdir/../../escape"));
463        assert!(
464            result.is_err(),
465            "Path with .. escaping via subdirectory must be blocked"
466        );
467    }
468
469    #[test]
470    fn test_validate_artifact_path_deep_traversal() {
471        let artifact_dir = PathBuf::from("/project/artifacts");
472        let result = validate_artifact_path(
473            &artifact_dir,
474            Path::new("a/b/c/d/../../../../../../../../etc/passwd"),
475        );
476        assert!(result.is_err(), "Deep path traversal must be blocked");
477    }
478
479    #[test]
480    fn test_validate_artifact_path_control_chars_blocked() {
481        let artifact_dir = PathBuf::from("/project/artifacts");
482        let result = validate_artifact_path(&artifact_dir, Path::new("file\r\ninjection"));
483        assert!(
484            result.is_err(),
485            "Control characters in path must be blocked"
486        );
487    }
488
489    #[test]
490    fn test_normalize_path_preserves_unresolvable_parent() {
491        // Relative path with leading `..` — should be preserved, not swallowed
492        let path = PathBuf::from("../../etc/passwd");
493        let normalized = normalize_path(&path);
494        assert_eq!(
495            normalized,
496            PathBuf::from("../../etc/passwd"),
497            "`..` at start of relative path must be preserved for boundary checks"
498        );
499    }
500
501    #[test]
502    fn test_normalize_path_absolute_root_clamp() {
503        // Absolute path with `..` past root — clamps at root
504        let path = PathBuf::from("/a/../../b");
505        let normalized = normalize_path(&path);
506        assert_eq!(normalized, PathBuf::from("/b"));
507    }
508
509    #[test]
510    fn test_normalize_path_mixed_relative() {
511        // Relative path: resolvable `..` removed, unresolvable preserved
512        let path = PathBuf::from("a/b/../../c/../../../etc");
513        let normalized = normalize_path(&path);
514        // a/b/../.. → (empty), c/.. → (empty), ../etc → ../etc  but leading .. preserved
515        assert_eq!(normalized, PathBuf::from("../../etc"));
516    }
517}