diff --git a/docs/architecture/server-configuration.md b/docs/architecture/server-configuration.md index 432a65ae..cf60beb2 100644 --- a/docs/architecture/server-configuration.md +++ b/docs/architecture/server-configuration.md @@ -150,12 +150,69 @@ scp: # File transfer filtering filter: enabled: false # Default: false + default_action: allow # Default action when no rules match: allow, deny, log + rules: - - pattern: "*.exe" + # Match by glob pattern + - name: "block-exe" + pattern: "*.exe" action: deny - - path_prefix: "/tmp/" + + # Match by path prefix (directory tree) + - name: "log-tmp" + path_prefix: "/tmp/" action: log + # Match by multiple file extensions + - name: "block-executables" + extensions: ["exe", "bat", "sh", "ps1"] + action: deny + + # Match by directory component (anywhere in path) + - name: "block-git" + directory: ".git" + action: deny + + # Composite rule with AND logic + - name: "protect-env-outside-home" + composite: + type: and + matchers: + - pattern: "*.env" + - not: + path_prefix: "/home" + action: deny + + # Composite rule with OR logic + - name: "block-secrets" + composite: + type: or + matchers: + - pattern: "*.key" + - pattern: "*.pem" + - extensions: ["crt", "p12", "pfx"] + action: deny + + # Composite rule with NOT logic (whitelist pattern) + - name: "whitelist-data" + composite: + type: not + matcher: + path_prefix: "/data" + action: deny + + # Rule with operation restriction + - name: "readonly-logs" + pattern: "*.log" + action: deny + operations: ["upload", "delete"] + + # Rule with user restriction + - name: "admin-only-config" + path_prefix: "/etc" + action: deny + users: ["guest", "readonly"] + # Audit logging configuration audit: enabled: false # Default: false @@ -647,6 +704,217 @@ scp -rp ./data/ user@bssh-server:/storage/backup/ --- +## File Transfer Filtering + +The bssh-server provides a comprehensive policy-based system for controlling file transfers in SFTP and SCP operations. The filter system allows administrators to allow, deny, or log file operations based on various criteria. + +### Filter Architecture + +``` +Filter Request Flow: +┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ +│ File Operation │ --> │ Normalize Path │ --> │ Match Rules │ +│ (SFTP/SCP) │ │ (prevent bypass) │ │ (in order) │ +└─────────────────┘ └──────────────────┘ └────────────────┘ + │ + v +┌─────────────────┐ ┌──────────────────┐ ┌────────────────┐ +│ First Match │ --> │ Apply Action │ --> │ Allow/Deny/Log │ +│ Wins │ │ (or default) │ │ │ +└─────────────────┘ └──────────────────┘ └────────────────┘ +``` + +### Matcher Types + +The filter system supports multiple matcher types that can be combined for flexible rule definitions: + +| Matcher | Config Key | Description | Example | +|---------|------------|-------------|---------| +| **Glob** | `pattern` | Shell-style glob patterns | `*.exe`, `secret*` | +| **Prefix** | `path_prefix` | Directory tree matching | `/etc`, `/home/user` | +| **Extension** | `extensions` | Multiple file extensions | `["exe", "bat", "sh"]` | +| **Directory** | `directory` | Component anywhere in path | `.git`, `.ssh` | +| **Composite** | `composite` | AND/OR/NOT logic | See below | + +### Glob Pattern Matching + +Glob patterns support standard wildcards: +- `*` - matches any sequence of characters +- `?` - matches any single character +- `[abc]` - matches any character in the set +- `[!abc]` - matches any character not in the set + +```yaml +rules: + - pattern: "*.key" # All .key files + - pattern: "secret?.txt" # secret1.txt, secretA.txt, etc. + - pattern: "[0-9]*.log" # Log files starting with a digit +``` + +### Extension Matching + +Multi-extension matching is case-insensitive by default: + +```yaml +rules: + - name: "block-executables" + extensions: ["exe", "bat", "sh", "ps1", "cmd"] + action: deny + + - name: "block-archives" + extensions: ["zip", "tar", "gz", "rar", "7z"] + action: deny +``` + +### Composite Rules + +Composite rules allow combining multiple matchers with logical operators: + +**AND Logic** - All matchers must match: +```yaml +- name: "env-outside-home" + composite: + type: and + matchers: + - pattern: "*.env" + - not: + path_prefix: "/home" + action: deny +``` + +**OR Logic** - Any matcher must match: +```yaml +- name: "sensitive-files" + composite: + type: or + matchers: + - pattern: "*.key" + - pattern: "*.pem" + - pattern: "*.p12" + action: deny +``` + +**NOT Logic** - Invert the match (whitelist pattern): +```yaml +- name: "whitelist-data-only" + composite: + type: not + matcher: + path_prefix: "/data" + action: deny # Deny everything NOT in /data +``` + +### Operation and User Restrictions + +Rules can be limited to specific operations or users: + +```yaml +rules: + # Prevent deletion of log files + - name: "protect-logs" + pattern: "*.log" + action: deny + operations: ["delete"] + + # Block uploads of executables for guest users + - name: "guest-no-executables" + extensions: ["exe", "sh", "bat"] + action: deny + operations: ["upload"] + users: ["guest", "anonymous"] +``` + +**Available Operations:** +- `upload` - File uploads +- `download` - File downloads +- `delete` - File deletion +- `rename` - File rename/move +- `createdir` - Directory creation +- `listdir` - Directory listing +- `stat` - Reading file attributes +- `setstat` - Modifying file attributes +- `symlink` - Creating symbolic links +- `readlink` - Reading symbolic link targets + +### Security Features + +**Path Traversal Protection:** +All paths are normalized before matching to prevent bypass attempts: +``` +/var/../etc/passwd -> /etc/passwd +/home/user/../../etc -> /etc +``` + +**First Match Wins:** +Rules are evaluated in order. The first matching rule determines the action. If no rules match, the default action (configurable, defaults to `allow`) is used. + +### SizeAwareFilter Trait + +For size-based filtering (e.g., blocking large uploads), the `SizeAwareFilter` trait provides: + +```rust +use bssh::server::filter::{SizeAwareFilter, FilterResult, Operation}; +use bssh::server::filter::path::SizeMatcher; + +// Create a size matcher for files over 100MB +let large_file_matcher = SizeMatcher::min(100 * 1024 * 1024); + +// Check if the given size matches +assert!(large_file_matcher.matches_size(200 * 1024 * 1024)); // 200MB matches +assert!(!large_file_matcher.matches_size(50 * 1024 * 1024)); // 50MB doesn't match +``` + +**Note:** Size-based filtering in configuration requires implementation integration with the actual file transfer handlers. + +### Complete Filter Configuration Example + +```yaml +filter: + enabled: true + default_action: allow + + rules: + # Block dangerous executables + - name: "block-executables" + extensions: ["exe", "bat", "sh", "ps1", "cmd", "com"] + action: deny + + # Block private keys and certificates + - name: "block-secrets" + composite: + type: or + matchers: + - pattern: "*.key" + - pattern: "*.pem" + - pattern: "*.p12" + - pattern: "id_rsa*" + - pattern: "id_ed25519*" + action: deny + + # Block hidden directories + - name: "block-hidden" + directory: ".git" + action: deny + + - name: "block-ssh-config" + directory: ".ssh" + action: deny + + # Log access to configuration files + - name: "log-config-access" + path_prefix: "/etc" + action: log + + # Restrict guests to read-only access in /data + - name: "guest-read-only" + path_prefix: "/data" + operations: ["upload", "delete", "rename", "createdir", "setstat"] + users: ["guest"] + action: deny +``` + +--- + ## Session Management The server implements comprehensive session management with per-user limits, idle timeout detection, and session tracking. diff --git a/src/server/config/types.rs b/src/server/config/types.rs index f26b0085..9a7d07d4 100644 --- a/src/server/config/types.rs +++ b/src/server/config/types.rs @@ -289,7 +289,7 @@ pub struct FilterConfig { } /// A single file transfer filter rule. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Default)] pub struct FilterRule { /// Rule name (for logging and debugging). /// @@ -309,6 +309,37 @@ pub struct FilterRule { #[serde(default)] pub path_prefix: Option, + /// File extensions to match. + /// + /// Example: ["exe", "sh", "bat"] blocks executable files + #[serde(default)] + pub extensions: Option>, + + /// Directory component to match. + /// + /// Matches if any path component equals this value. + /// Example: ".git" matches /project/.git/config + #[serde(default)] + pub directory: Option, + + /// Minimum file size in bytes. + /// + /// Files smaller than this size will not match. + #[serde(default)] + pub min_size: Option, + + /// Maximum file size in bytes. + /// + /// Files larger than this size will not match. + #[serde(default)] + pub max_size: Option, + + /// Composite rule configuration. + /// + /// Allows combining multiple matchers with AND/OR/NOT logic. + #[serde(default)] + pub composite: Option, + /// Action to take when rule matches. pub action: FilterAction, @@ -326,6 +357,59 @@ pub struct FilterRule { pub users: Option>, } +/// Configuration for composite filter rules. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CompositeRuleConfig { + /// Type of composite logic: "and", "or", or "not" + #[serde(rename = "type")] + pub logic_type: CompositeLogicType, + + /// List of matchers for AND/OR logic. + #[serde(default)] + pub matchers: Vec, + + /// Single matcher for NOT logic. + #[serde(default)] + pub matcher: Option>, +} + +/// Type of composite logic. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CompositeLogicType { + /// All matchers must match + And, + /// Any matcher must match + Or, + /// Invert the matcher result + Not, +} + +/// Configuration for a single matcher within a composite rule. +#[derive(Debug, Clone, Deserialize, Serialize, Default)] +#[serde(default)] +pub struct MatcherConfig { + /// Glob pattern + #[serde(default)] + pub pattern: Option, + + /// Path prefix + #[serde(default)] + pub path_prefix: Option, + + /// File extensions + #[serde(default)] + pub extensions: Option>, + + /// Directory component + #[serde(default)] + pub directory: Option, + + /// Nested NOT matcher + #[serde(default)] + pub not: Option>, +} + /// Action to take when a filter rule matches. #[derive(Debug, Clone, Deserialize, Serialize, Default)] #[serde(rename_all = "lowercase")] diff --git a/src/server/filter/mod.rs b/src/server/filter/mod.rs index 8661f6b6..9b38c997 100644 --- a/src/server/filter/mod.rs +++ b/src/server/filter/mod.rs @@ -60,9 +60,14 @@ pub mod policy; use std::fmt; use std::path::Path; -pub use self::path::{ExactMatcher, PrefixMatcher}; -pub use self::pattern::{GlobMatcher, RegexMatcher}; -pub use self::policy::{FilterPolicy, FilterRule, Matcher}; +pub use self::path::{ + normalize_path, ComponentMatcher, ExactMatcher, ExtensionMatcher, MultiExtensionMatcher, + PrefixMatcher, SizeMatcher, +}; +pub use self::pattern::{ + AllMatcher, CombinedMatcher, CompositeMatcher, GlobMatcher, NotMatcher, RegexMatcher, +}; +pub use self::policy::{FilterPolicy, FilterRule, Matcher, SharedFilterPolicy}; /// File transfer operation type. /// @@ -244,6 +249,90 @@ impl TransferFilter for NoOpFilter { } } +/// Trait for size-aware file transfer filters. +/// +/// This extends the basic `TransferFilter` trait to include file size +/// in the filtering decision. Use this when you need to filter based +/// on file size (e.g., block uploads larger than 100MB). +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::{FilterResult, Operation, SizeAwareFilter, TransferFilter}; +/// use bssh::server::filter::path::SizeMatcher; +/// use std::path::Path; +/// +/// struct MaxUploadSizeFilter { +/// max_bytes: u64, +/// } +/// +/// impl TransferFilter for MaxUploadSizeFilter { +/// fn check(&self, _path: &Path, _operation: Operation, _user: &str) -> FilterResult { +/// // Without size info, we allow by default +/// FilterResult::Allow +/// } +/// } +/// +/// impl SizeAwareFilter for MaxUploadSizeFilter { +/// fn check_with_size( +/// &self, +/// _path: &Path, +/// size: u64, +/// operation: Operation, +/// _user: &str, +/// ) -> FilterResult { +/// if operation == Operation::Upload && size > self.max_bytes { +/// FilterResult::Deny +/// } else { +/// FilterResult::Allow +/// } +/// } +/// } +/// ``` +pub trait SizeAwareFilter: TransferFilter { + /// Check if an operation is allowed, taking file size into account. + /// + /// # Arguments + /// + /// * `path` - The file path being operated on + /// * `size` - The file size in bytes + /// * `operation` - The type of operation + /// * `user` - The username performing the operation + /// + /// # Returns + /// + /// A `FilterResult` indicating whether to allow, deny, or log the operation. + fn check_with_size( + &self, + path: &Path, + size: u64, + operation: Operation, + user: &str, + ) -> FilterResult; + + /// Check a two-path operation with size information. + /// + /// Used for rename/copy operations where both source and destination + /// are considered. + fn check_with_size_dest( + &self, + src: &Path, + src_size: u64, + dest: &Path, + operation: Operation, + user: &str, + ) -> FilterResult { + let src_result = self.check_with_size(src, src_size, operation, user); + let dest_result = self.check(dest, operation, user); + + match (src_result, dest_result) { + (FilterResult::Deny, _) | (_, FilterResult::Deny) => FilterResult::Deny, + (FilterResult::Log, _) | (_, FilterResult::Log) => FilterResult::Log, + _ => FilterResult::Allow, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -426,7 +515,7 @@ mod tests { #[test] fn test_noop_filter_default() { - let filter = NoOpFilter::default(); + let filter = NoOpFilter; assert!(!filter.is_enabled()); } diff --git a/src/server/filter/path.rs b/src/server/filter/path.rs index e1958858..117ce0e3 100644 --- a/src/server/filter/path.rs +++ b/src/server/filter/path.rs @@ -352,6 +352,182 @@ impl Matcher for ExtensionMatcher { } } +/// Matches paths based on multiple file extensions. +/// +/// This matcher supports multiple extensions and optional case sensitivity, +/// useful for blocking groups of file types like executables or archives. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::path::MultiExtensionMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// let matcher = MultiExtensionMatcher::new(vec!["exe", "bat", "ps1"], false); +/// +/// assert!(matcher.matches(Path::new("/uploads/malware.exe"))); +/// assert!(matcher.matches(Path::new("/scripts/script.BAT"))); // Case insensitive +/// assert!(matcher.matches(Path::new("/scripts/script.ps1"))); +/// assert!(!matcher.matches(Path::new("/home/user/document.pdf"))); +/// ``` +#[derive(Debug, Clone)] +pub struct MultiExtensionMatcher { + extensions: Vec, + case_sensitive: bool, +} + +impl MultiExtensionMatcher { + /// Create a new multi-extension matcher. + /// + /// # Arguments + /// + /// * `extensions` - List of file extensions to match (without the dot) + /// * `case_sensitive` - Whether to match case-sensitively + pub fn new>(extensions: Vec, case_sensitive: bool) -> Self { + let extensions: Vec = if case_sensitive { + extensions.into_iter().map(|e| e.into()).collect() + } else { + extensions + .into_iter() + .map(|e| e.into().to_lowercase()) + .collect() + }; + Self { + extensions, + case_sensitive, + } + } + + /// Create a case-insensitive multi-extension matcher. + pub fn case_insensitive>(extensions: Vec) -> Self { + Self::new(extensions, false) + } + + /// Get the extensions being matched. + pub fn extensions(&self) -> &[String] { + &self.extensions + } + + /// Check if matching is case-sensitive. + pub fn is_case_sensitive(&self) -> bool { + self.case_sensitive + } +} + +impl Matcher for MultiExtensionMatcher { + fn matches(&self, path: &Path) -> bool { + if let Some(ext) = path.extension() { + if let Some(ext_str) = ext.to_str() { + let ext_cmp = if self.case_sensitive { + ext_str.to_string() + } else { + ext_str.to_lowercase() + }; + return self.extensions.contains(&ext_cmp); + } + } + false + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + format!( + "extensions:[{}]{}", + self.extensions.join(", "), + if self.case_sensitive { + " (case-sensitive)" + } else { + "" + } + ) + } +} + +/// Matches files based on size. +/// +/// This matcher is used for size-based filtering rules. Unlike other matchers, +/// it requires the file size to be provided separately since paths don't contain +/// size information. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::path::SizeMatcher; +/// +/// // Match files larger than 100MB +/// let large_file_matcher = SizeMatcher::min(100 * 1024 * 1024); +/// +/// // Match files smaller than 1KB +/// let small_file_matcher = SizeMatcher::max(1024); +/// +/// // Match files between 1MB and 100MB +/// let range_matcher = SizeMatcher::between(1024 * 1024, 100 * 1024 * 1024); +/// ``` +#[derive(Debug, Clone)] +pub struct SizeMatcher { + min_size: Option, + max_size: Option, +} + +impl SizeMatcher { + /// Create a new size matcher with optional min and max bounds. + /// + /// # Arguments + /// + /// * `min` - Minimum file size in bytes (inclusive) + /// * `max` - Maximum file size in bytes (inclusive) + pub fn new(min: Option, max: Option) -> Self { + Self { + min_size: min, + max_size: max, + } + } + + /// Create a matcher for files larger than or equal to the given size. + pub fn min(min_bytes: u64) -> Self { + Self::new(Some(min_bytes), None) + } + + /// Create a matcher for files smaller than or equal to the given size. + pub fn max(max_bytes: u64) -> Self { + Self::new(None, Some(max_bytes)) + } + + /// Create a matcher for files within a size range. + pub fn between(min_bytes: u64, max_bytes: u64) -> Self { + Self::new(Some(min_bytes), Some(max_bytes)) + } + + /// Check if the given size matches. + pub fn matches_size(&self, size: u64) -> bool { + if let Some(min) = self.min_size { + if size < min { + return false; + } + } + if let Some(max) = self.max_size { + if size > max { + return false; + } + } + true + } + + /// Get the minimum size bound. + pub fn min_size(&self) -> Option { + self.min_size + } + + /// Get the maximum size bound. + pub fn max_size(&self) -> Option { + self.max_size + } +} + #[cfg(test)] mod tests { use super::*; @@ -582,4 +758,116 @@ mod tests { // Extension is stored in lowercase assert_eq!(matcher.extension(), "pdf"); } + + // Tests for MultiExtensionMatcher + #[test] + fn test_multi_extension_matcher_basic() { + let matcher = MultiExtensionMatcher::new(vec!["exe", "bat", "ps1"], false); + + assert!(matcher.matches(Path::new("/uploads/malware.exe"))); + assert!(matcher.matches(Path::new("/scripts/script.bat"))); + assert!(matcher.matches(Path::new("/scripts/script.ps1"))); + assert!(!matcher.matches(Path::new("/home/user/document.pdf"))); + } + + #[test] + fn test_multi_extension_matcher_case_insensitive() { + let matcher = MultiExtensionMatcher::case_insensitive(vec!["exe", "bat"]); + + assert!(matcher.matches(Path::new("/uploads/MALWARE.EXE"))); + assert!(matcher.matches(Path::new("/scripts/Script.Bat"))); + assert!(!matcher.is_case_sensitive()); + } + + #[test] + fn test_multi_extension_matcher_case_sensitive() { + let matcher = MultiExtensionMatcher::new(vec!["EXE", "BAT"], true); + + assert!(matcher.matches(Path::new("/uploads/malware.EXE"))); + assert!(!matcher.matches(Path::new("/uploads/malware.exe"))); // Case matters + assert!(matcher.is_case_sensitive()); + } + + #[test] + fn test_multi_extension_matcher_accessors() { + let matcher = MultiExtensionMatcher::new(vec!["exe", "bat"], false); + + assert_eq!(matcher.extensions(), &["exe", "bat"]); + assert!(!matcher.is_case_sensitive()); + } + + #[test] + fn test_multi_extension_matcher_no_extension() { + let matcher = MultiExtensionMatcher::new(vec!["txt"], false); + + assert!(!matcher.matches(Path::new("/bin/bash"))); + assert!(!matcher.matches(Path::new("/etc/passwd"))); + } + + #[test] + fn test_multi_extension_matcher_pattern_description() { + let matcher = MultiExtensionMatcher::new(vec!["exe", "bat"], false); + assert!(matcher.pattern_description().contains("exe")); + assert!(matcher.pattern_description().contains("bat")); + + let case_sensitive = MultiExtensionMatcher::new(vec!["EXE"], true); + assert!(case_sensitive + .pattern_description() + .contains("case-sensitive")); + } + + // Tests for SizeMatcher + #[test] + fn test_size_matcher_min() { + let matcher = SizeMatcher::min(1024); + + assert!(matcher.matches_size(1024)); // Equal to min + assert!(matcher.matches_size(2048)); // Greater than min + assert!(!matcher.matches_size(512)); // Less than min + } + + #[test] + fn test_size_matcher_max() { + let matcher = SizeMatcher::max(1024); + + assert!(matcher.matches_size(1024)); // Equal to max + assert!(matcher.matches_size(512)); // Less than max + assert!(!matcher.matches_size(2048)); // Greater than max + } + + #[test] + fn test_size_matcher_between() { + let matcher = SizeMatcher::between(1024, 2048); + + assert!(matcher.matches_size(1024)); // Equal to min + assert!(matcher.matches_size(1536)); // In range + assert!(matcher.matches_size(2048)); // Equal to max + assert!(!matcher.matches_size(512)); // Less than min + assert!(!matcher.matches_size(4096)); // Greater than max + } + + #[test] + fn test_size_matcher_no_limits() { + let matcher = SizeMatcher::new(None, None); + + // With no limits, everything matches + assert!(matcher.matches_size(0)); + assert!(matcher.matches_size(u64::MAX)); + } + + #[test] + fn test_size_matcher_accessors() { + let matcher = SizeMatcher::between(100, 200); + + assert_eq!(matcher.min_size(), Some(100)); + assert_eq!(matcher.max_size(), Some(200)); + + let min_only = SizeMatcher::min(50); + assert_eq!(min_only.min_size(), Some(50)); + assert_eq!(min_only.max_size(), None); + + let max_only = SizeMatcher::max(150); + assert_eq!(max_only.min_size(), None); + assert_eq!(max_only.max_size(), Some(150)); + } } diff --git a/src/server/filter/pattern.rs b/src/server/filter/pattern.rs index 0f5647ea..b55f34e1 100644 --- a/src/server/filter/pattern.rs +++ b/src/server/filter/pattern.rs @@ -413,6 +413,176 @@ impl Matcher for NotMatcher { } } +/// A matcher that combines multiple matchers with AND logic. +/// +/// The combined matcher returns true only if ALL of its inner matchers match. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::pattern::{GlobMatcher, AllMatcher}; +/// use bssh::server::filter::path::PrefixMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// // Match .env files only in /home directory +/// let matcher = AllMatcher::new(vec![ +/// Box::new(GlobMatcher::new("*.env").unwrap()), +/// Box::new(PrefixMatcher::new("/home")), +/// ]); +/// +/// assert!(matcher.matches(Path::new("/home/user/.env"))); +/// assert!(!matcher.matches(Path::new("/etc/.env"))); // Not in /home +/// assert!(!matcher.matches(Path::new("/home/user/config.txt"))); // Not .env +/// ``` +#[derive(Debug, Clone)] +pub struct AllMatcher { + matchers: Vec>, +} + +impl AllMatcher { + /// Create a new AND matcher. + /// + /// # Arguments + /// + /// * `matchers` - The matchers to combine with AND logic + pub fn new(matchers: Vec>) -> Self { + Self { matchers } + } + + /// Add a matcher to the combination. + pub fn with_matcher(mut self, matcher: Box) -> Self { + self.matchers.push(matcher); + self + } + + /// Get the number of matchers in this combination. + pub fn len(&self) -> usize { + self.matchers.len() + } + + /// Check if the combination is empty. + pub fn is_empty(&self) -> bool { + self.matchers.is_empty() + } +} + +impl Matcher for AllMatcher { + fn matches(&self, path: &Path) -> bool { + // Empty matcher matches nothing + if self.matchers.is_empty() { + return false; + } + self.matchers.iter().all(|m| m.matches(path)) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + let descriptions: Vec<_> = self + .matchers + .iter() + .map(|m| m.pattern_description()) + .collect(); + format!("all_of:[{}]", descriptions.join(", ")) + } +} + +/// Enum representing composite matcher logic. +/// +/// This provides a unified interface for creating AND, OR, and NOT matchers. +/// +/// # Example +/// +/// ```rust +/// use bssh::server::filter::pattern::{GlobMatcher, CompositeMatcher}; +/// use bssh::server::filter::path::PrefixMatcher; +/// use bssh::server::filter::policy::Matcher; +/// use std::path::Path; +/// +/// // Create an AND matcher +/// let and_matcher = CompositeMatcher::and(vec![ +/// Box::new(GlobMatcher::new("*.env").unwrap()), +/// Box::new(PrefixMatcher::new("/home")), +/// ]); +/// +/// // Create an OR matcher +/// let or_matcher = CompositeMatcher::or(vec![ +/// Box::new(GlobMatcher::new("*.key").unwrap()), +/// Box::new(GlobMatcher::new("*.pem").unwrap()), +/// ]); +/// +/// // Create a NOT matcher +/// let not_matcher = CompositeMatcher::not( +/// Box::new(PrefixMatcher::new("/home")), +/// ); +/// ``` +#[derive(Debug, Clone)] +pub enum CompositeMatcher { + /// All matchers must match (AND logic) + And(Vec>), + /// Any matcher must match (OR logic) + Or(Vec>), + /// Invert the inner matcher (NOT logic) + Not(Box), +} + +impl CompositeMatcher { + /// Create an AND composite matcher. + pub fn and(matchers: Vec>) -> Self { + CompositeMatcher::And(matchers) + } + + /// Create an OR composite matcher. + pub fn or(matchers: Vec>) -> Self { + CompositeMatcher::Or(matchers) + } + + /// Create a NOT composite matcher. + pub fn not(matcher: Box) -> Self { + CompositeMatcher::Not(matcher) + } +} + +impl Matcher for CompositeMatcher { + fn matches(&self, path: &Path) -> bool { + match self { + CompositeMatcher::And(matchers) => { + if matchers.is_empty() { + return false; + } + matchers.iter().all(|m| m.matches(path)) + } + CompositeMatcher::Or(matchers) => matchers.iter().any(|m| m.matches(path)), + CompositeMatcher::Not(matcher) => !matcher.matches(path), + } + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } + + fn pattern_description(&self) -> String { + match self { + CompositeMatcher::And(matchers) => { + let descriptions: Vec<_> = + matchers.iter().map(|m| m.pattern_description()).collect(); + format!("and:[{}]", descriptions.join(", ")) + } + CompositeMatcher::Or(matchers) => { + let descriptions: Vec<_> = + matchers.iter().map(|m| m.pattern_description()).collect(); + format!("or:[{}]", descriptions.join(", ")) + } + CompositeMatcher::Not(matcher) => { + format!("not({})", matcher.pattern_description()) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -690,4 +860,150 @@ mod tests { assert_ne!(GlobMatchMode::PathOrFilename, GlobMatchMode::FilenameOnly); assert_ne!(GlobMatchMode::FullPathOnly, GlobMatchMode::FilenameOnly); } + + // Tests for AllMatcher (AND logic) + #[test] + fn test_all_matcher_basic() { + use crate::server::filter::path::PrefixMatcher; + + // Match .env files only in /home directory + let matcher = AllMatcher::new(vec![ + Box::new(GlobMatcher::new("*.env").unwrap()), + Box::new(PrefixMatcher::new("/home")), + ]); + + assert!(matcher.matches(Path::new("/home/user/.env"))); + assert!(!matcher.matches(Path::new("/etc/.env"))); // Not in /home + assert!(!matcher.matches(Path::new("/home/user/config.txt"))); // Not .env + } + + #[test] + fn test_all_matcher_empty() { + let matcher = AllMatcher::new(vec![]); + + assert!(matcher.is_empty()); + assert_eq!(matcher.len(), 0); + // Empty AllMatcher matches nothing + assert!(!matcher.matches(Path::new("/any/path"))); + } + + #[test] + fn test_all_matcher_single() { + let matcher = AllMatcher::new(vec![Box::new(GlobMatcher::new("*.key").unwrap())]); + + assert_eq!(matcher.len(), 1); + assert!(matcher.matches(Path::new("secret.key"))); + assert!(!matcher.matches(Path::new("secret.txt"))); + } + + #[test] + fn test_all_matcher_with_matcher() { + let matcher = AllMatcher::new(vec![Box::new(GlobMatcher::new("*.key").unwrap())]) + .with_matcher(Box::new(GlobMatcher::new("secret*").unwrap())); + + assert_eq!(matcher.len(), 2); + assert!(matcher.matches(Path::new("secret.key"))); // Both match + assert!(!matcher.matches(Path::new("public.key"))); // Only first matches + assert!(!matcher.matches(Path::new("secret.txt"))); // Only second matches + } + + #[test] + fn test_all_matcher_clone() { + use crate::server::filter::path::PrefixMatcher; + + let matcher = AllMatcher::new(vec![ + Box::new(GlobMatcher::new("*.log").unwrap()), + Box::new(PrefixMatcher::new("/var/log")), + ]); + let cloned = matcher.clone_box(); + + assert!(cloned.matches(Path::new("/var/log/app.log"))); + assert!(cloned.pattern_description().contains("all_of:")); + } + + // Tests for CompositeMatcher (unified AND/OR/NOT) + #[test] + fn test_composite_matcher_and() { + use crate::server::filter::path::PrefixMatcher; + + let matcher = CompositeMatcher::and(vec![ + Box::new(GlobMatcher::new("*.env").unwrap()), + Box::new(PrefixMatcher::new("/home")), + ]); + + assert!(matcher.matches(Path::new("/home/user/.env"))); + assert!(!matcher.matches(Path::new("/etc/.env"))); + assert!(matcher.pattern_description().contains("and:")); + } + + #[test] + fn test_composite_matcher_or() { + let matcher = CompositeMatcher::or(vec![ + Box::new(GlobMatcher::new("*.key").unwrap()), + Box::new(GlobMatcher::new("*.pem").unwrap()), + ]); + + assert!(matcher.matches(Path::new("secret.key"))); + assert!(matcher.matches(Path::new("cert.pem"))); + assert!(!matcher.matches(Path::new("document.txt"))); + assert!(matcher.pattern_description().contains("or:")); + } + + #[test] + fn test_composite_matcher_not() { + use crate::server::filter::path::PrefixMatcher; + + let matcher = CompositeMatcher::not(Box::new(PrefixMatcher::new("/home"))); + + assert!(!matcher.matches(Path::new("/home/user/file"))); + assert!(matcher.matches(Path::new("/etc/passwd"))); + assert!(matcher.pattern_description().contains("not(")); + } + + #[test] + fn test_composite_matcher_empty_and() { + let matcher = CompositeMatcher::And(vec![]); + + // Empty AND should match nothing + assert!(!matcher.matches(Path::new("/any/path"))); + } + + #[test] + fn test_composite_matcher_empty_or() { + let matcher = CompositeMatcher::Or(vec![]); + + // Empty OR should match nothing + assert!(!matcher.matches(Path::new("/any/path"))); + } + + #[test] + fn test_composite_matcher_complex() { + use crate::server::filter::path::PrefixMatcher; + + // Complex rule: (.env files NOT in /home) OR (.key files) + let env_not_home = CompositeMatcher::and(vec![ + Box::new(GlobMatcher::new("*.env").unwrap()), + Box::new(CompositeMatcher::not(Box::new(PrefixMatcher::new("/home")))), + ]); + + let key_files = GlobMatcher::new("*.key").unwrap(); + + let matcher = CompositeMatcher::or(vec![Box::new(env_not_home), Box::new(key_files)]); + + assert!(matcher.matches(Path::new("/etc/.env"))); // .env not in /home + assert!(!matcher.matches(Path::new("/home/user/.env"))); // .env in /home + assert!(matcher.matches(Path::new("/home/user/secret.key"))); // .key file + } + + #[test] + fn test_composite_matcher_clone() { + let matcher = CompositeMatcher::and(vec![ + Box::new(GlobMatcher::new("*.a").unwrap()), + Box::new(GlobMatcher::new("test*").unwrap()), + ]); + let cloned = matcher.clone_box(); + + assert!(cloned.matches(Path::new("test.a"))); + assert!(!cloned.matches(Path::new("test.b"))); + } } diff --git a/src/server/filter/policy.rs b/src/server/filter/policy.rs index 3987ec8a..2a544c60 100644 --- a/src/server/filter/policy.rs +++ b/src/server/filter/policy.rs @@ -24,9 +24,13 @@ use std::sync::Arc; use anyhow::{Context, Result}; use super::{FilterResult, Operation, TransferFilter}; -use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; -use crate::server::filter::path::PrefixMatcher; -use crate::server::filter::pattern::GlobMatcher; +use crate::server::config::{ + CompositeLogicType, FilterAction, FilterConfig, FilterRule as FilterRuleConfig, MatcherConfig, +}; +use crate::server::filter::path::{ + normalize_path, ComponentMatcher, MultiExtensionMatcher, PrefixMatcher, +}; +use crate::server::filter::pattern::{AllMatcher, CombinedMatcher, GlobMatcher, NotMatcher}; /// Trait for path matchers. /// @@ -227,16 +231,24 @@ impl FilterPolicy { /// Create a rule from configuration. fn rule_from_config(config: &FilterRuleConfig) -> Result { - // Create matcher based on config - let matcher: Box = if let Some(pattern) = config.pattern.as_ref() { + // Create matcher based on config - try each type in order + let matcher: Box = if let Some(ref composite) = config.composite { + Self::matcher_from_composite(composite)? + } else if let Some(pattern) = config.pattern.as_ref() { Box::new( GlobMatcher::new(pattern) .with_context(|| format!("Invalid glob pattern: {}", pattern))?, ) } else if let Some(prefix) = config.path_prefix.as_ref() { Box::new(PrefixMatcher::new(prefix.as_str())) + } else if let Some(extensions) = config.extensions.as_ref() { + Box::new(MultiExtensionMatcher::case_insensitive(extensions.clone())) + } else if let Some(directory) = config.directory.as_ref() { + Box::new(ComponentMatcher::new(directory.as_str())) } else { - anyhow::bail!("Filter rule must have either 'pattern' or 'path_prefix'"); + anyhow::bail!( + "Filter rule must have one of: 'pattern', 'path_prefix', 'extensions', 'directory', or 'composite'" + ); }; // Convert action @@ -273,6 +285,67 @@ impl FilterPolicy { users: config.users.clone(), }) } + + /// Create a matcher from composite rule configuration. + fn matcher_from_composite( + config: &crate::server::config::CompositeRuleConfig, + ) -> Result> { + match config.logic_type { + CompositeLogicType::And => { + let matchers: Result>> = config + .matchers + .iter() + .map(Self::matcher_from_config) + .collect(); + Ok(Box::new(AllMatcher::new(matchers?))) + } + CompositeLogicType::Or => { + let matchers: Result>> = config + .matchers + .iter() + .map(Self::matcher_from_config) + .collect(); + Ok(Box::new(CombinedMatcher::new(matchers?))) + } + CompositeLogicType::Not => { + if let Some(ref matcher_config) = config.matcher { + let inner = Self::matcher_from_config(matcher_config)?; + Ok(Box::new(NotMatcher::new(inner))) + } else if let Some(first) = config.matchers.first() { + let inner = Self::matcher_from_config(first)?; + Ok(Box::new(NotMatcher::new(inner))) + } else { + anyhow::bail!("NOT composite rule requires a matcher") + } + } + } + } + + /// Create a matcher from a MatcherConfig. + fn matcher_from_config(config: &MatcherConfig) -> Result> { + // Handle nested NOT first + if let Some(ref not_config) = config.not { + let inner = Self::matcher_from_config(not_config)?; + return Ok(Box::new(NotMatcher::new(inner))); + } + + // Try each matcher type + if let Some(ref pattern) = config.pattern { + Ok(Box::new(GlobMatcher::new(pattern).with_context(|| { + format!("Invalid glob pattern: {}", pattern) + })?)) + } else if let Some(ref prefix) = config.path_prefix { + Ok(Box::new(PrefixMatcher::new(prefix.as_str()))) + } else if let Some(ref extensions) = config.extensions { + Ok(Box::new(MultiExtensionMatcher::case_insensitive( + extensions.clone(), + ))) + } else if let Some(ref directory) = config.directory { + Ok(Box::new(ComponentMatcher::new(directory.as_str()))) + } else { + anyhow::bail!("Matcher config must have one of: 'pattern', 'path_prefix', 'extensions', 'directory', or 'not'") + } + } } impl TransferFilter for FilterPolicy { @@ -281,11 +354,18 @@ impl TransferFilter for FilterPolicy { return FilterResult::Allow; } + // Normalize path to prevent path traversal attacks (e.g., /var/../etc/passwd -> /etc/passwd) + // This is a defense-in-depth measure - callers should also validate paths, + // but we normalize here to ensure consistent security behavior. + let normalized = normalize_path(path); + let check_path = normalized.as_path(); + for rule in &self.rules { - if rule.matches(path, operation, user) { + if rule.matches(check_path, operation, user) { tracing::debug!( rule_name = ?rule.name, - path = %path.display(), + path = %check_path.display(), + original_path = %path.display(), operation = %operation, user = %user, action = %rule.action, @@ -297,7 +377,7 @@ impl TransferFilter for FilterPolicy { } tracing::trace!( - path = %path.display(), + path = %check_path.display(), operation = %operation, user = %user, action = %self.default_action, @@ -622,10 +702,10 @@ mod tests { rules: vec![FilterRuleConfig { name: Some("block-keys".to_string()), pattern: Some("*.key".to_string()), - path_prefix: None, action: FilterAction::Deny, operations: Some(vec!["download".to_string()]), users: Some(vec!["alice".to_string()]), + ..Default::default() }], }; @@ -661,11 +741,9 @@ mod tests { default_action: Some(FilterAction::Deny), rules: vec![FilterRuleConfig { name: Some("allow-home".to_string()), - pattern: None, path_prefix: Some("/home".to_string()), action: FilterAction::Allow, - operations: None, - users: None, + ..Default::default() }], }; @@ -695,11 +773,8 @@ mod tests { default_action: None, rules: vec![FilterRuleConfig { name: Some("invalid".to_string()), - pattern: None, - path_prefix: None, action: FilterAction::Deny, - operations: None, - users: None, + ..Default::default() }], }; @@ -715,12 +790,9 @@ mod tests { enabled: true, default_action: None, rules: vec![FilterRuleConfig { - name: None, pattern: Some("[".to_string()), // Invalid glob pattern - path_prefix: None, action: FilterAction::Deny, - operations: None, - users: None, + ..Default::default() }], }; @@ -736,12 +808,9 @@ mod tests { enabled: false, default_action: Some(FilterAction::Deny), rules: vec![FilterRuleConfig { - name: None, pattern: Some("*".to_string()), - path_prefix: None, action: FilterAction::Deny, - operations: None, - users: None, + ..Default::default() }], }; @@ -873,4 +942,269 @@ mod tests { assert!(rule.matches(Path::new("/etc/secret.key"), Operation::Upload, "anyuser")); assert!(rule.matches(Path::new("/etc/secret.key"), Operation::Delete, "anyuser")); } + + #[test] + fn test_from_config_with_extensions() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Allow), + rules: vec![FilterRuleConfig { + name: Some("block-executables".to_string()), + extensions: Some(vec!["exe".to_string(), "bat".to_string(), "sh".to_string()]), + action: FilterAction::Deny, + ..Default::default() + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + assert_eq!( + policy.check(Path::new("/uploads/malware.exe"), Operation::Upload, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/scripts/script.bat"), Operation::Upload, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/scripts/script.sh"), Operation::Upload, "user"), + FilterResult::Deny + ); + // Different extension should be allowed + assert_eq!( + policy.check(Path::new("/docs/document.pdf"), Operation::Upload, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_from_config_with_directory() { + use crate::server::config::{FilterAction, FilterConfig, FilterRule as FilterRuleConfig}; + + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Allow), + rules: vec![FilterRuleConfig { + name: Some("block-git".to_string()), + directory: Some(".git".to_string()), + action: FilterAction::Deny, + ..Default::default() + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + assert_eq!( + policy.check( + Path::new("/project/.git/config"), + Operation::Download, + "user" + ), + FilterResult::Deny + ); + assert_eq!( + policy.check( + Path::new("/home/user/.git/HEAD"), + Operation::Download, + "user" + ), + FilterResult::Deny + ); + // File without .git component should be allowed + assert_eq!( + policy.check( + Path::new("/project/src/main.rs"), + Operation::Download, + "user" + ), + FilterResult::Allow + ); + } + + #[test] + fn test_from_config_with_composite_and() { + use crate::server::config::{ + CompositeLogicType, CompositeRuleConfig, FilterAction, FilterConfig, + FilterRule as FilterRuleConfig, MatcherConfig, + }; + + // Deny .env files that are NOT in /home + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Allow), + rules: vec![FilterRuleConfig { + name: Some("protect-env".to_string()), + composite: Some(CompositeRuleConfig { + logic_type: CompositeLogicType::And, + matchers: vec![ + MatcherConfig { + pattern: Some("*.env".to_string()), + ..Default::default() + }, + MatcherConfig { + not: Some(Box::new(MatcherConfig { + path_prefix: Some("/home".to_string()), + ..Default::default() + })), + ..Default::default() + }, + ], + matcher: None, + }), + action: FilterAction::Deny, + ..Default::default() + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + // .env outside /home should be denied + assert_eq!( + policy.check(Path::new("/etc/app/.env"), Operation::Download, "user"), + FilterResult::Deny + ); + // .env inside /home should be allowed + assert_eq!( + policy.check(Path::new("/home/user/.env"), Operation::Download, "user"), + FilterResult::Allow + ); + // Non-.env file outside /home should be allowed + assert_eq!( + policy.check(Path::new("/etc/passwd"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_from_config_with_composite_or() { + use crate::server::config::{ + CompositeLogicType, CompositeRuleConfig, FilterAction, FilterConfig, + FilterRule as FilterRuleConfig, MatcherConfig, + }; + + // Block .key OR .pem files + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Allow), + rules: vec![FilterRuleConfig { + name: Some("block-secrets".to_string()), + composite: Some(CompositeRuleConfig { + logic_type: CompositeLogicType::Or, + matchers: vec![ + MatcherConfig { + pattern: Some("*.key".to_string()), + ..Default::default() + }, + MatcherConfig { + pattern: Some("*.pem".to_string()), + ..Default::default() + }, + ], + matcher: None, + }), + action: FilterAction::Deny, + ..Default::default() + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + assert_eq!( + policy.check(Path::new("/etc/secret.key"), Operation::Download, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/etc/cert.pem"), Operation::Download, "user"), + FilterResult::Deny + ); + assert_eq!( + policy.check(Path::new("/etc/file.txt"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_from_config_with_composite_not() { + use crate::server::config::{ + CompositeLogicType, CompositeRuleConfig, FilterAction, FilterConfig, + FilterRule as FilterRuleConfig, MatcherConfig, + }; + + // Allow only files in /data (deny everything else) + let config = FilterConfig { + enabled: true, + default_action: Some(FilterAction::Allow), + rules: vec![FilterRuleConfig { + name: Some("whitelist-data".to_string()), + composite: Some(CompositeRuleConfig { + logic_type: CompositeLogicType::Not, + matchers: vec![], + matcher: Some(Box::new(MatcherConfig { + path_prefix: Some("/data".to_string()), + ..Default::default() + })), + }), + action: FilterAction::Deny, + ..Default::default() + }], + }; + + let policy = FilterPolicy::from_config(&config).unwrap(); + + // Files outside /data should be denied + assert_eq!( + policy.check(Path::new("/etc/passwd"), Operation::Download, "user"), + FilterResult::Deny + ); + // Files inside /data should be allowed + assert_eq!( + policy.check(Path::new("/data/file.csv"), Operation::Download, "user"), + FilterResult::Allow + ); + } + + #[test] + fn test_policy_path_traversal_protection() { + // Test that path traversal attempts are properly normalized and matched + let policy = FilterPolicy::new() + .add_rule(FilterRule::new( + Box::new(PrefixMatcher::new("/etc")), + FilterResult::Deny, + )) + .with_default(FilterResult::Allow); + + // Direct path should be denied + assert_eq!( + policy.check(Path::new("/etc/passwd"), Operation::Download, "user"), + FilterResult::Deny + ); + + // Path traversal attempt should also be denied (normalized to /etc/passwd) + assert_eq!( + policy.check(Path::new("/var/../etc/passwd"), Operation::Download, "user"), + FilterResult::Deny + ); + + // Another traversal pattern + assert_eq!( + policy.check( + Path::new("/home/user/../../etc/shadow"), + Operation::Download, + "user" + ), + FilterResult::Deny + ); + + // Path outside /etc should be allowed + assert_eq!( + policy.check( + Path::new("/home/user/file.txt"), + Operation::Download, + "user" + ), + FilterResult::Allow + ); + } }