Add SSH Port Forwarding ACL (Phase 1-3): prevent SSH tunnel abuse
Features: - ForwardRule: Allow/Deny rules with address/port specifications - ForwardAcl: User-specific ACL with priority-based rule matching - ForwardAclManager: Global ACL manager for all users - OpenSSH-style PermitOpen/PermitListen parsing - 8 unit tests for all operations Security: - Prevent unauthorized SSH tunnel creation - Restrict forwarding to specific hosts/ports - Default deny policy for unknown users Files: - markbase-core/src/ssh_server/forward_acl.rs (493 lines) - markbase-core/src/ssh_server/mod.rs (+1 line) Tests: 317 passed (+8)
This commit is contained in:
543
markbase-core/src/ssh_server/forward_acl.rs
Normal file
543
markbase-core/src/ssh_server/forward_acl.rs
Normal file
@@ -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<IpAddr> = 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<HashMap<ForwardDirection, Vec<ForwardRule>>>,
|
||||
/// 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<ForwardRule> {
|
||||
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::<IpAddr>() {
|
||||
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<HashMap<String, Arc<ForwardAcl>>>,
|
||||
}
|
||||
|
||||
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<Arc<ForwardAcl>> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user