diff --git a/markbase-core/src/ssh_server/forward_acl.rs b/markbase-core/src/ssh_server/forward_acl.rs new file mode 100644 index 0000000..c88101b --- /dev/null +++ b/markbase-core/src/ssh_server/forward_acl.rs @@ -0,0 +1,543 @@ +//! SSH Port Forwarding ACL (Access Control List) +//! +//! Prevents SSH tunnel abuse by restricting which ports/addresses can be forwarded. +//! Based on OpenSSH AllowTcpForwarding, PermitOpen, PermitListen directives. + +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::{Arc, RwLock}; + +/// Forward rule type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ForwardAction { + Allow, + Deny, +} + +/// Forward direction +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ForwardDirection { + /// Local forwarding (-L): Client listens, forwards to server target + Local, + /// Remote forwarding (-R): Server listens, forwards to client target + Remote, +} + +/// Port range specification +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PortSpec { + /// Single port + Single(u16), + /// Port range (start, end) + Range(u16, u16), + /// Any port (wildcard) + Any, +} + +/// Address specification +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AddressSpec { + /// Specific IP address + Ip(IpAddr), + /// Any address (wildcard) + Any, + /// Localhost only + Localhost, + /// Domain name (resolved at runtime) + Domain(String), +} + +/// Forward rule: allow/deny forwarding to specific address/port +#[derive(Debug, Clone)] +pub struct ForwardRule { + /// Action: Allow or Deny + pub action: ForwardAction, + /// Direction: Local or Remote + pub direction: ForwardDirection, + /// Target address + pub address: AddressSpec, + /// Target port + pub port: PortSpec, + /// Rule priority (higher = more specific) + pub priority: u32, +} + +impl ForwardRule { + /// Create a new forward rule + pub fn new( + action: ForwardAction, + direction: ForwardDirection, + address: AddressSpec, + port: PortSpec, + ) -> Self { + // Calculate priority based on specificity + let priority = match (&address, &port) { + (AddressSpec::Ip(_), PortSpec::Single(_)) => 100, // Most specific + (AddressSpec::Ip(_), PortSpec::Range(_, _)) => 90, + (AddressSpec::Ip(_), PortSpec::Any) => 80, + (AddressSpec::Domain(_), PortSpec::Single(_)) => 70, + (AddressSpec::Domain(_), PortSpec::Range(_, _)) => 60, + (AddressSpec::Domain(_), PortSpec::Any) => 50, + (AddressSpec::Localhost, PortSpec::Single(_)) => 40, + (AddressSpec::Localhost, PortSpec::Range(_, _)) => 30, + (AddressSpec::Localhost, PortSpec::Any) => 20, + (AddressSpec::Any, PortSpec::Single(_)) => 15, + (AddressSpec::Any, PortSpec::Range(_, _)) => 10, + (AddressSpec::Any, PortSpec::Any) => 1, // Least specific + }; + + Self { + action, + direction, + address, + port, + priority, + } + } + + /// Check if this rule matches the given forward request + pub fn matches(&self, direction: ForwardDirection, address: &str, port: u16) -> bool { + // Check direction + if self.direction != direction { + return false; + } + + // Check port + match &self.port { + PortSpec::Single(p) => { + if *p != port { + return false; + } + } + PortSpec::Range(start, end) => { + if port < *start || port > *end { + return false; + } + } + PortSpec::Any => {} + } + + // Check address + match &self.address { + AddressSpec::Ip(ip) => { + // Parse target address + let target_ip: Option = address.parse().ok(); + if target_ip != Some(*ip) { + return false; + } + } + AddressSpec::Any => {} + AddressSpec::Localhost => { + // Check if address is localhost + let is_localhost = address == "localhost" + || address == "127.0.0.1" + || address == "::1" + || address == "0.0.0.0"; + if !is_localhost { + return false; + } + } + AddressSpec::Domain(domain) => { + // Domain matching (case-insensitive) + if !address.eq_ignore_ascii_case(domain) { + return false; + } + } + } + + true + } +} + +/// Forward ACL: manages forward rules for a user +pub struct ForwardAcl { + /// Rules indexed by direction + rules: RwLock>>, + /// Default action when no rule matches + default_action: ForwardAction, + /// User name this ACL applies to + pub user: String, +} + +impl ForwardAcl { + /// Create a new forward ACL for a user + pub fn new(user: String, default_action: ForwardAction) -> Self { + Self { + rules: RwLock::new(HashMap::new()), + default_action, + user, + } + } + + /// Add a rule to the ACL + pub fn add_rule(&self, rule: ForwardRule) { + self.rules + .write() + .unwrap() + .entry(rule.direction) + .or_insert_with(Vec::new) + .push(rule); + } + + /// Remove all rules for a direction + pub fn clear_rules(&self, direction: ForwardDirection) { + self.rules.write().unwrap().remove(&direction); + } + + /// Check if a forward request is allowed + pub fn check(&self, direction: ForwardDirection, address: &str, port: u16) -> ForwardAction { + let rules = self.rules.read().unwrap(); + + // Find matching rules for this direction + let direction_rules = rules.get(&direction); + + if let Some(rules_vec) = direction_rules { + // Sort by priority (highest first) + let sorted_rules: Vec<&ForwardRule> = rules_vec + .iter() + .filter(|r| r.matches(direction, address, port)) + .collect(); + + // Return action of highest-priority matching rule + if let Some(rule) = sorted_rules.first() { + return rule.action; + } + } + + // No matching rule: return default action + self.default_action + } + + /// List all rules for a direction + pub fn list_rules(&self, direction: ForwardDirection) -> Vec { + self.rules + .read() + .unwrap() + .get(&direction) + .cloned() + .unwrap_or_default() + } + + /// Parse OpenSSH-style PermitOpen directive + /// Format: "host:port" or "host:port1-port2" or "none" or "any" + pub fn parse_permit_open(spec: &str, user: String) -> Self { + let default_action = ForwardAction::Deny; + + if spec == "none" { + // No forwarding allowed + return Self::new(user, default_action); + } + + if spec == "any" { + // All forwarding allowed + let acl = Self::new(user, ForwardAction::Allow); + acl.add_rule(ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Local, + AddressSpec::Any, + PortSpec::Any, + )); + return acl; + } + + let acl = Self::new(user, default_action); + + // Parse host:port specifications + for entry in spec.split(',') { + let parts: Vec<&str> = entry.split(':').collect(); + if parts.len() != 2 { + continue; + } + + let host = parts[0]; + let port_spec = parts[1]; + + // Parse address + let address = if host == "*" { + AddressSpec::Any + } else if host == "localhost" { + AddressSpec::Localhost + } else { + // Try parsing as IP, fall back to domain + match host.parse::() { + Ok(ip) => AddressSpec::Ip(ip), + Err(_) => AddressSpec::Domain(host.to_string()), + } + }; + + // Parse port + let port = if port_spec == "*" { + PortSpec::Any + } else if port_spec.contains('-') { + // Port range + let range_parts: Vec<&str> = port_spec.split('-').collect(); + if range_parts.len() == 2 { + let start: u16 = range_parts[0].parse().unwrap_or(0); + let end: u16 = range_parts[1].parse().unwrap_or(0); + PortSpec::Range(start, end) + } else { + PortSpec::Any + } + } else { + // Single port + PortSpec::Single(port_spec.parse().unwrap_or(0)) + }; + + acl.add_rule(ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Local, + address, + port, + )); + } + + acl + } + + /// Parse OpenSSH-style PermitListen directive (for remote forwarding) + pub fn parse_permit_listen(spec: &str, user: String) -> Self { + let default_action = ForwardAction::Deny; + + if spec == "none" { + return Self::new(user, default_action); + } + + if spec == "any" { + let acl = Self::new(user, ForwardAction::Allow); + acl.add_rule(ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Remote, + AddressSpec::Any, + PortSpec::Any, + )); + return acl; + } + + let acl = Self::new(user, default_action); + + // Parse port specifications (PermitListen only specifies ports) + for entry in spec.split(',') { + let port_spec = entry.trim(); + + let port = if port_spec == "*" { + PortSpec::Any + } else if port_spec.contains('-') { + let range_parts: Vec<&str> = port_spec.split('-').collect(); + if range_parts.len() == 2 { + let start: u16 = range_parts[0].parse().unwrap_or(0); + let end: u16 = range_parts[1].parse().unwrap_or(0); + PortSpec::Range(start, end) + } else { + PortSpec::Any + } + } else { + PortSpec::Single(port_spec.parse().unwrap_or(0)) + }; + + acl.add_rule(ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Remote, + AddressSpec::Any, + port, + )); + } + + acl + } +} + +/// Forward ACL manager: manages ACLs for all users +pub struct ForwardAclManager { + /// ACLs indexed by user name + acls: RwLock>>, +} + +impl ForwardAclManager { + pub fn new() -> Self { + Self { + acls: RwLock::new(HashMap::new()), + } + } + + /// Add an ACL for a user + pub fn add_acl(&self, user: String, acl: ForwardAcl) { + self.acls.write().unwrap().insert(user, Arc::new(acl)); + } + + /// Get ACL for a user + pub fn get_acl(&self, user: &str) -> Option> { + self.acls.read().unwrap().get(user).cloned() + } + + /// Remove ACL for a user + pub fn remove_acl(&self, user: &str) { + self.acls.write().unwrap().remove(user); + } + + /// Check forward request for a user + pub fn check( + &self, + user: &str, + direction: ForwardDirection, + address: &str, + port: u16, + ) -> ForwardAction { + // Get user ACL + let acl = self.get_acl(user); + + if let Some(acl) = acl { + acl.check(direction, address, port) + } else { + // No ACL for user: default deny + ForwardAction::Deny + } + } +} + +impl Default for ForwardAclManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_forward_rule_single_port() { + let rule = ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Local, + AddressSpec::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), + PortSpec::Single(80), + ); + + assert!(rule.matches(ForwardDirection::Local, "192.168.1.100", 80)); + assert!(!rule.matches(ForwardDirection::Local, "192.168.1.100", 443)); + assert!(!rule.matches(ForwardDirection::Local, "192.168.1.1", 80)); + assert!(!rule.matches(ForwardDirection::Remote, "192.168.1.100", 80)); + } + + #[test] + fn test_forward_rule_port_range() { + let rule = ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Local, + AddressSpec::Any, + PortSpec::Range(8000, 9000), + ); + + assert!(rule.matches(ForwardDirection::Local, "example.com", 8500)); + assert!(!rule.matches(ForwardDirection::Local, "example.com", 7000)); + assert!(!rule.matches(ForwardDirection::Remote, "example.com", 8500)); + } + + #[test] + fn test_forward_rule_localhost() { + let rule = ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Local, + AddressSpec::Localhost, + PortSpec::Any, + ); + + assert!(rule.matches(ForwardDirection::Local, "localhost", 8080)); + assert!(rule.matches(ForwardDirection::Local, "127.0.0.1", 8080)); + assert!(!rule.matches(ForwardDirection::Local, "192.168.1.1", 8080)); + } + + #[test] + fn test_forward_acl_priority() { + let acl = ForwardAcl::new("test_user".to_string(), ForwardAction::Deny); + + // Add specific rule (high priority) + acl.add_rule(ForwardRule::new( + ForwardAction::Allow, + ForwardDirection::Local, + AddressSpec::Ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 100))), + PortSpec::Single(80), + )); + + // Add wildcard rule (low priority) + acl.add_rule(ForwardRule::new( + ForwardAction::Deny, + ForwardDirection::Local, + AddressSpec::Any, + PortSpec::Any, + )); + + // Specific address should be allowed + assert_eq!( + acl.check(ForwardDirection::Local, "192.168.1.100", 80), + ForwardAction::Allow + ); + + // Other addresses should be denied (wildcard rule) + assert_eq!( + acl.check(ForwardDirection::Local, "192.168.1.1", 80), + ForwardAction::Deny + ); + } + + #[test] + fn test_parse_permit_open_any() { + let acl = ForwardAcl::parse_permit_open("any", "test_user".to_string()); + + assert_eq!( + acl.check(ForwardDirection::Local, "192.168.1.1", 443), + ForwardAction::Allow + ); + } + + #[test] + fn test_parse_permit_open_none() { + let acl = ForwardAcl::parse_permit_open("none", "test_user".to_string()); + + assert_eq!( + acl.check(ForwardDirection::Local, "192.168.1.1", 443), + ForwardAction::Deny + ); + } + + #[test] + fn test_parse_permit_open_specific() { + let acl = ForwardAcl::parse_permit_open("localhost:8080,example.com:443", "test_user".to_string()); + + assert_eq!( + acl.check(ForwardDirection::Local, "localhost", 8080), + ForwardAction::Allow + ); + assert_eq!( + acl.check(ForwardDirection::Local, "example.com", 443), + ForwardAction::Allow + ); + assert_eq!( + acl.check(ForwardDirection::Local, "192.168.1.1", 443), + ForwardAction::Deny + ); + } + + #[test] + fn test_forward_acl_manager() { + let manager = ForwardAclManager::new(); + + let acl = ForwardAcl::parse_permit_open("localhost:*", "alice".to_string()); + manager.add_acl("alice".to_string(), acl); + + assert_eq!( + manager.check("alice", ForwardDirection::Local, "localhost", 8080), + ForwardAction::Allow + ); + assert_eq!( + manager.check("alice", ForwardDirection::Local, "192.168.1.1", 8080), + ForwardAction::Deny + ); + + // Unknown user: default deny + assert_eq!( + manager.check("bob", ForwardDirection::Local, "localhost", 8080), + ForwardAction::Deny + ); + } +} \ No newline at end of file diff --git a/markbase-core/src/ssh_server/mod.rs b/markbase-core/src/ssh_server/mod.rs index 838a1e7..2160dfd 100644 --- a/markbase-core/src/ssh_server/mod.rs +++ b/markbase-core/src/ssh_server/mod.rs @@ -7,6 +7,7 @@ pub mod cipher; pub mod compression; pub mod crypto; pub mod data_forwarder; +pub mod forward_acl; pub mod host_key; pub mod kex; pub mod kex_complete;