Add SSH Port Forwarding ACL (Phase 1-3): prevent SSH tunnel abuse
Some checks failed
Test / test (push) Has been cancelled
Test / build (push) Has been cancelled

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:
Warren
2026-06-21 12:48:56 +08:00
parent a28b7f0929
commit a475de45c9
2 changed files with 544 additions and 0 deletions

View 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
);
}
}

View File

@@ -7,6 +7,7 @@ pub mod cipher;
pub mod compression; pub mod compression;
pub mod crypto; pub mod crypto;
pub mod data_forwarder; pub mod data_forwarder;
pub mod forward_acl;
pub mod host_key; pub mod host_key;
pub mod kex; pub mod kex;
pub mod kex_complete; pub mod kex_complete;