From d0c0790cbf5b0eb5dc69b6c226d9d719f1f96eeb Mon Sep 17 00:00:00 2001 From: Draco-Lunaris-Echo Date: Sat, 6 Jun 2026 12:47:24 -0500 Subject: [PATCH] fix: enforce IP whitelist middleware in request pipeline (closes #11) Co-authored-by: git-echo --- src/auth/mod.rs | 5 +- src/auth/whitelist.rs | 148 ++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 17 +++-- 3 files changed, 158 insertions(+), 12 deletions(-) diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 32ee577..ce5be04 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -12,7 +12,10 @@ pub mod whitelist; pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState}; pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware}; -pub use whitelist::{WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware}; +pub use whitelist::{ + WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware, + WhitelistMiddlewareService, +}; /// Combined authentication result #[derive(Debug, Clone)] diff --git a/src/auth/whitelist.rs b/src/auth/whitelist.rs index 15fce85..5a882de 100644 --- a/src/auth/whitelist.rs +++ b/src/auth/whitelist.rs @@ -17,6 +17,12 @@ use std::sync::{Arc, RwLock}; use std::time::Duration; use tracing::{debug, info, warn}; +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, +}; +use futures_util::future::LocalBoxFuture; + /// Whitelist entry types #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum WhitelistEntry { @@ -282,6 +288,18 @@ impl WhitelistManager { } } + /// Create a deny-all whitelist manager (fail-closed fallback). + /// + /// Used when the whitelist file cannot be loaded — all IPs are denied + /// except health endpoints (handled at middleware level). + pub fn new_deny_all() -> Self { + Self { + entries: Arc::new(RwLock::new(HashSet::new())), + config_path: String::new(), + watcher: None, + } + } + /// Get the number of entries in the whitelist pub fn entry_count(&self) -> usize { self.entries.read().unwrap().len() @@ -426,11 +444,9 @@ pub struct WhitelistMiddleware { } impl WhitelistMiddleware { - /// Create a new whitelist middleware - pub fn new(manager: WhitelistManager) -> Self { - Self { - manager: Arc::new(manager), - } + /// Create a new whitelist middleware from an Arc + pub fn new(manager: Arc) -> Self { + Self { manager } } /// Get the whitelist manager reference @@ -439,6 +455,99 @@ impl WhitelistMiddleware { } } +/// Actix-web Transform implementation — wraps WhitelistMiddleware as middleware +impl Transform for WhitelistMiddleware +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = WhitelistMiddlewareService; + type Future = futures_util::future::Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + futures_util::future::ok(WhitelistMiddlewareService { + service, + manager: self.manager.clone(), + }) + } +} + +/// Whitelist middleware service — performs per-request IP checks +pub struct WhitelistMiddlewareService { + service: S, + manager: Arc, +} + +/// Health/system endpoint paths exempt from IP whitelist enforcement +const WHITELIST_EXEMPT_PATHS: &[&str] = &["/health", "/api/v1/system/info"]; + +impl Service for WhitelistMiddlewareService +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let path = req.path().to_owned(); + + // Exempt health and system info endpoints from IP whitelist + if WHITELIST_EXEMPT_PATHS.iter().any(|p| path == *p) { + debug!(path = %path, "Path exempt from IP whitelist"); + let fut = self.service.call(req); + return Box::pin(fut); + } + + // Get peer address — fail-closed if unavailable + let peer_addr = req.peer_addr(); + match peer_addr { + Some(addr) => { + if self.manager.is_socket_allowed(&addr) { + debug!( + peer_addr = %addr, + path = %path, + "IP whitelist check passed" + ); + let fut = self.service.call(req); + Box::pin(fut) + } else { + warn!( + peer_addr = %addr, + path = %path, + "IP whitelist denied - connection rejected" + ); + Box::pin(async move { + Err(actix_web::error::ErrorForbidden( + "IP address not in whitelist", + )) + }) + } + } + None => { + // No peer address — fail-closed (deny by default) + warn!( + path = %path, + "No peer address available - denying request (fail-closed)" + ); + Box::pin(async move { + Err(actix_web::error::ErrorForbidden( + "IP address not available - denied by policy", + )) + }) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -511,4 +620,33 @@ mod tests { let ip_outside: Ipv4Addr = "192.168.2.100".parse().unwrap(); assert!(!manager.is_allowed(&ip_outside)); } + + #[test] + fn test_new_deny_all_blocks_everything() { + let manager = WhitelistManager::new_deny_all(); + // No IPs should be allowed in deny-all mode + let ip: Ipv4Addr = "192.168.1.1".parse().unwrap(); + assert!(!manager.is_allowed(&ip)); + + let ip2: Ipv4Addr = "10.0.0.1".parse().unwrap(); + assert!(!manager.is_allowed(&ip2)); + + // Entry count should be 0 + assert_eq!(manager.entry_count(), 0); + } + + #[test] + fn test_is_socket_allowed_ipv6_denied() { + let manager = WhitelistManager::new_deny_all(); + // IPv6 should be denied even with a populated whitelist + let socket_v6: SocketAddr = "[::1]:12345".parse().unwrap(); + assert!(!manager.is_socket_allowed(&socket_v6)); + } + + #[test] + fn test_exempt_paths_constant() { + // Verify the exempt paths include health and system info + assert!(WHITELIST_EXEMPT_PATHS.contains(&"/health")); + assert!(WHITELIST_EXEMPT_PATHS.contains(&"/api/v1/system/info")); + } } diff --git a/src/main.rs b/src/main.rs index 8333602..a3363dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,7 +28,7 @@ use tracing::{error, info, warn}; use linux_patch_api::api::{configure_api_routes, configure_health_route}; use linux_patch_api::auth::crl::{self, CrlStatus}; -use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager}; +use linux_patch_api::auth::{mtls, MtlsMiddleware, WhitelistManager, WhitelistMiddleware}; use linux_patch_api::config::loader::{validate_certs, CertStatus}; use linux_patch_api::enroll; use linux_patch_api::packages::cache::PackageCacheState; @@ -282,11 +282,12 @@ async fn main() -> Result<()> { entries = manager.entry_count(), "Whitelist manager initialized" ); - Some(Arc::new(manager)) + Arc::new(manager) } Err(e) => { - warn!(error = %e, "Failed to load whitelist - continuing with empty whitelist (all denied)"); - None + // Fail-closed: deny all IPs when whitelist cannot be loaded + warn!(error = %e, "Failed to load whitelist - using deny-all mode (fail-closed)"); + Arc::new(WhitelistManager::new_deny_all()) } }; @@ -305,9 +306,13 @@ async fn main() -> Result<()> { // Configure bind address let bind_address = format!("{}:{}", config.server.bind, config.server.port); + // Clone whitelist manager for use inside the HttpServer closure + let wl = whitelist_manager.clone(); + // Create server builder let server_builder = HttpServer::new(move || { let mut app = App::new() + .wrap(WhitelistMiddleware::new(wl.clone())) .wrap(Logger::default()) .app_data(job_manager_data.clone()) .app_data(backend_data.clone()) @@ -338,8 +343,8 @@ async fn main() -> Result<()> { .max_connection_rate(1000); info!( mtls_enabled = config.tls_config().is_some(), - whitelist_enabled = whitelist_manager.is_some(), - "Security layer status" + whitelist_entries = whitelist_manager.entry_count(), + "Security layer status (IP whitelist enforced)" ); info!("Linux Patch API initialized successfully");