Private
Public Access
1
0

fix: enforce IP whitelist middleware in request pipeline (closes #11)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 41s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m9s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m11s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 56s
CI/CD Pipeline / Build Debian Package (push) Failing after 3s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 4s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m13s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m17s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m13s

Co-authored-by: git-echo <git-echo@moon-dragon.us>
This commit is contained in:
Draco-Lunaris-Echo
2026-06-06 12:47:24 -05:00
committed by GitHub
parent 130206a3a3
commit d0c0790cbf
3 changed files with 158 additions and 12 deletions

View File

@ -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)]

View File

@ -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<WhitelistManager>
pub fn new(manager: Arc<WhitelistManager>) -> Self {
Self { manager }
}
/// Get the whitelist manager reference
@ -439,6 +455,99 @@ impl WhitelistMiddleware {
}
}
/// Actix-web Transform implementation — wraps WhitelistMiddleware as middleware
impl<S, B> Transform<S, ServiceRequest> for WhitelistMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = WhitelistMiddlewareService<S>;
type Future = futures_util::future::Ready<Result<Self::Transform, Self::InitError>>;
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<S> {
service: S,
manager: Arc<WhitelistManager>,
}
/// Health/system endpoint paths exempt from IP whitelist enforcement
const WHITELIST_EXEMPT_PATHS: &[&str] = &["/health", "/api/v1/system/info"];
impl<S, B> Service<ServiceRequest> for WhitelistMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
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"));
}
}

View File

@ -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");