Skip to content

Commit 8583162

Browse files
committed
fix(security): add normalize_path for path traversal protection
Priority: CRITICAL Issue: Path matchers operated on raw paths without normalization, allowing bypass via path traversal sequences like ../ - Add normalize_path() function for logical path normalization - Add comprehensive security documentation - Document that callers should normalize paths before matching - Add tests demonstrating the security issue and fix Review-Iteration: 1
1 parent 6aea238 commit 8583162

1 file changed

Lines changed: 151 additions & 3 deletions

File tree

src/server/filter/path.rs

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,117 @@
1717
//! This module provides matchers that work with file path structure:
1818
//! - [`PrefixMatcher`] - Matches paths that start with a given prefix
1919
//! - [`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+
//! ```
2052
21-
use std::path::{Path, PathBuf};
53+
use std::path::{Component, Path, PathBuf};
2254

2355
use super::policy::Matcher;
2456

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+
25110
/// Matches paths that start with a given prefix.
26111
///
27112
/// 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+
/// ```
30131
///
31132
/// # Example
32133
///
@@ -395,3 +496,50 @@ mod tests {
395496
assert!(extension.matches(test_path));
396497
}
397498
}
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

Comments
 (0)