|
17 | 17 | //! This module provides matchers that work with file path structure: |
18 | 18 | //! - [`PrefixMatcher`] - Matches paths that start with a given prefix |
19 | 19 | //! - [`ExactMatcher`] - Matches paths that exactly equal a given path |
| 20 | +//! - [`ComponentMatcher`] - Matches paths containing a specific component |
| 21 | +//! - [`ExtensionMatcher`] - Matches paths by file extension |
| 22 | +//! |
| 23 | +//! # Security Considerations |
| 24 | +//! |
| 25 | +//! ## Path Traversal |
| 26 | +//! |
| 27 | +//! These matchers operate on the paths as provided. For security-sensitive |
| 28 | +//! filtering, callers should normalize paths before matching to prevent |
| 29 | +//! bypass via path traversal sequences like `..` or symlinks. |
| 30 | +//! |
| 31 | +//! Use [`normalize_path`] to remove `.` and `..` components logically, or |
| 32 | +//! use `std::fs::canonicalize` if the path exists on the filesystem and you |
| 33 | +//! need symlink resolution. |
| 34 | +//! |
| 35 | +//! ## Example: Secure Usage |
| 36 | +//! |
| 37 | +//! ```rust |
| 38 | +//! use std::path::Path; |
| 39 | +//! use bssh::server::filter::path::{normalize_path, PrefixMatcher}; |
| 40 | +//! use bssh::server::filter::policy::Matcher; |
| 41 | +//! |
| 42 | +//! let matcher = PrefixMatcher::new("/etc"); |
| 43 | +//! let user_path = Path::new("/var/../etc/passwd"); |
| 44 | +//! |
| 45 | +//! // Without normalization - BYPASS! |
| 46 | +//! assert!(!matcher.matches(user_path)); // Does NOT match /etc |
| 47 | +//! |
| 48 | +//! // With normalization - SECURE |
| 49 | +//! let normalized = normalize_path(user_path); |
| 50 | +//! assert!(matcher.matches(&normalized)); // Correctly matches /etc |
| 51 | +//! ``` |
20 | 52 |
|
21 | | -use std::path::{Path, PathBuf}; |
| 53 | +use std::path::{Component, Path, PathBuf}; |
22 | 54 |
|
23 | 55 | use super::policy::Matcher; |
24 | 56 |
|
| 57 | +/// Normalizes a path by resolving `.` and `..` components logically. |
| 58 | +/// |
| 59 | +/// This function does NOT access the filesystem, so: |
| 60 | +/// - It works on non-existent paths |
| 61 | +/// - It does NOT resolve symlinks |
| 62 | +/// - It normalizes paths purely based on their string representation |
| 63 | +/// |
| 64 | +/// For paths where symlink resolution is needed, use `std::fs::canonicalize` |
| 65 | +/// instead (but note that it requires the path to exist). |
| 66 | +/// |
| 67 | +/// # Security Note |
| 68 | +/// |
| 69 | +/// This function should be called on user-provided paths BEFORE passing them |
| 70 | +/// to matchers, to prevent path traversal attacks. |
| 71 | +/// |
| 72 | +/// # Examples |
| 73 | +/// |
| 74 | +/// ```rust |
| 75 | +/// use std::path::Path; |
| 76 | +/// use bssh::server::filter::path::normalize_path; |
| 77 | +/// |
| 78 | +/// assert_eq!(normalize_path(Path::new("/etc/../var")), Path::new("/var")); |
| 79 | +/// assert_eq!(normalize_path(Path::new("/etc/./passwd")), Path::new("/etc/passwd")); |
| 80 | +/// assert_eq!(normalize_path(Path::new("foo/../bar")), Path::new("bar")); |
| 81 | +/// ``` |
| 82 | +pub fn normalize_path(path: &Path) -> PathBuf { |
| 83 | + let mut result = PathBuf::new(); |
| 84 | + |
| 85 | + for component in path.components() { |
| 86 | + match component { |
| 87 | + Component::Prefix(p) => result.push(p.as_os_str()), |
| 88 | + Component::RootDir => result.push(Component::RootDir.as_os_str()), |
| 89 | + Component::CurDir => {} // Skip "." |
| 90 | + Component::ParentDir => { |
| 91 | + // Pop if we can, otherwise keep ".." for relative paths |
| 92 | + if result.parent().is_some() && result != Path::new("/") { |
| 93 | + result.pop(); |
| 94 | + } else if !result.is_absolute() { |
| 95 | + result.push(".."); |
| 96 | + } |
| 97 | + // If at root, ignore ".." |
| 98 | + } |
| 99 | + Component::Normal(name) => result.push(name), |
| 100 | + } |
| 101 | + } |
| 102 | + |
| 103 | + if result.as_os_str().is_empty() { |
| 104 | + PathBuf::from(".") |
| 105 | + } else { |
| 106 | + result |
| 107 | + } |
| 108 | +} |
| 109 | + |
25 | 110 | /// Matches paths that start with a given prefix. |
26 | 111 | /// |
27 | 112 | /// This matcher is useful for blocking or allowing entire directory trees. |
28 | | -/// The match is performed on normalized paths to handle trailing slashes |
29 | | -/// and other path variations. |
| 113 | +/// |
| 114 | +/// # Security Warning |
| 115 | +/// |
| 116 | +/// This matcher operates on paths as provided. To prevent path traversal |
| 117 | +/// attacks, normalize the input path using [`normalize_path`] before matching. |
| 118 | +/// |
| 119 | +/// ```rust |
| 120 | +/// use std::path::Path; |
| 121 | +/// use bssh::server::filter::path::{normalize_path, PrefixMatcher}; |
| 122 | +/// use bssh::server::filter::policy::Matcher; |
| 123 | +/// |
| 124 | +/// let matcher = PrefixMatcher::new("/etc"); |
| 125 | +/// let attack_path = Path::new("/var/../etc/shadow"); |
| 126 | +/// |
| 127 | +/// // Normalize to prevent bypass |
| 128 | +/// let safe_path = normalize_path(attack_path); |
| 129 | +/// assert!(matcher.matches(&safe_path)); // Now correctly blocked |
| 130 | +/// ``` |
30 | 131 | /// |
31 | 132 | /// # Example |
32 | 133 | /// |
@@ -395,3 +496,50 @@ mod tests { |
395 | 496 | assert!(extension.matches(test_path)); |
396 | 497 | } |
397 | 498 | } |
| 499 | + |
| 500 | + #[test] |
| 501 | + fn test_normalize_path_removes_dot() { |
| 502 | + assert_eq!(normalize_path(Path::new("/etc/./passwd")), Path::new("/etc/passwd")); |
| 503 | + assert_eq!(normalize_path(Path::new("./foo/./bar")), Path::new("foo/bar")); |
| 504 | + } |
| 505 | + |
| 506 | + #[test] |
| 507 | + fn test_normalize_path_resolves_parent() { |
| 508 | + assert_eq!(normalize_path(Path::new("/etc/../var")), Path::new("/var")); |
| 509 | + assert_eq!(normalize_path(Path::new("/etc/ssh/../passwd")), Path::new("/etc/passwd")); |
| 510 | + assert_eq!(normalize_path(Path::new("/a/b/c/../../d")), Path::new("/a/d")); |
| 511 | + } |
| 512 | + |
| 513 | + #[test] |
| 514 | + fn test_normalize_path_traversal_at_root() { |
| 515 | + // At root, .. should be ignored |
| 516 | + assert_eq!(normalize_path(Path::new("/../etc/passwd")), Path::new("/etc/passwd")); |
| 517 | + assert_eq!(normalize_path(Path::new("/../../etc")), Path::new("/etc")); |
| 518 | + } |
| 519 | + |
| 520 | + #[test] |
| 521 | + fn test_normalize_path_relative() { |
| 522 | + assert_eq!(normalize_path(Path::new("foo/../bar")), Path::new("bar")); |
| 523 | + assert_eq!(normalize_path(Path::new("../foo")), Path::new("../foo")); |
| 524 | + } |
| 525 | + |
| 526 | + #[test] |
| 527 | + fn test_normalize_path_empty() { |
| 528 | + assert_eq!(normalize_path(Path::new("")), Path::new(".")); |
| 529 | + assert_eq!(normalize_path(Path::new(".")), Path::new(".")); |
| 530 | + } |
| 531 | + |
| 532 | + #[test] |
| 533 | + fn test_normalize_path_security() { |
| 534 | + // This is the key security test: path traversal should be normalized |
| 535 | + let matcher = PrefixMatcher::new("/etc"); |
| 536 | + |
| 537 | + // Without normalization, this would NOT match /etc (attack succeeds) |
| 538 | + let attack_path = Path::new("/var/../etc/passwd"); |
| 539 | + assert!(!matcher.matches(attack_path)); // Raw path doesn't match |
| 540 | + |
| 541 | + // With normalization, it correctly matches /etc (attack blocked) |
| 542 | + let normalized = normalize_path(attack_path); |
| 543 | + assert!(matcher.matches(&normalized)); // Normalized path matches |
| 544 | + assert_eq!(normalized, Path::new("/etc/passwd")); |
| 545 | + } |
0 commit comments