Private
Public Access
1
0

fix: remove dead MtlsMiddleware, add security header middleware, document rustls as auth gate (closes #13)
Some checks failed
CI/CD Pipeline / Code Format (push) Successful in 3s
CI/CD Pipeline / Clippy Lints (push) Successful in 42s
CI/CD Pipeline / All Unit Tests (push) Successful in 1m11s
CI/CD Pipeline / Security Audit (push) Successful in 5s
CI/CD Pipeline / Enrollment Tests (push) Successful in 1m13s
CI/CD Pipeline / Verify Enrollment CLI Flag (push) Successful in 58s
CI/CD Pipeline / Build Debian Package (Ubuntu 22.04) (push) Failing after 8s
CI/CD Pipeline / Build Debian Package (push) Failing after 5s
CI/CD Pipeline / Build RPM Package (push) Successful in 2m5s
CI/CD Pipeline / Build Arch Package (push) Successful in 2m16s
CI/CD Pipeline / Build Alpine Package (push) Failing after 3m5s

- Remove dead MtlsMiddleware struct, MtlsMiddlewareService, Transform/Service impls
- Remove validate_client_certificate() stub (returned Ok(()) unconditionally)
- Remove has_duplicate_critical_headers() from mtls.rs (moved to new module)
- Convert build_rustls_config() from method on MtlsMiddleware to free function
- Create SecurityHeadersMiddleware in src/auth/security_headers.rs for VULN-006
- Wire SecurityHeadersMiddleware into Actix-web pipeline in main.rs
- Add ADR documenting rustls as authoritative client-auth gate
- Preserve CrlAwareVerifier, MtlsConfig, MtlsError, ClientCertInfo, build_rustls_config
- Add integration tests for duplicate header detection
- Update HARDENING_REPORT.md and SECURITY_FINDINGS_REPORT.md with ADR

Co-authored-by: git-echo <git-echo@moon-dragon.us>
This commit is contained in:
Draco-Lunaris-Echo
2026-06-06 13:58:01 -05:00
committed by GitHub
parent efaac33c47
commit 6a4c4c95a4
8 changed files with 458 additions and 421 deletions

View File

@ -114,6 +114,10 @@ path = "tests/integration/enrollment_test.rs"
name = "enrollment_e2e"
path = "tests/e2e/test_enrollment_e2e.rs"
[[test]]
name = "auth_test"
path = "tests/integration/auth_test.rs"
[[bench]]
name = "api_benchmarks"
harness = false

View File

@ -20,7 +20,7 @@ This report documents the implementation of 6 security hardening fixes deferred
| VULN-003 | LOW | Input Validation | ✅ RESOLVED | src/api/handlers/packages.rs |
| VULN-004 | MEDIUM | Header Security | ✅ RESOLVED | src/main.rs |
| VULN-005 | LOW | HTTP Protocol | ✅ RESOLVED | src/api/routes.rs |
| VULN-006 | LOW | Header Security | ✅ RESOLVED | src/auth/mtls.rs |
| VULN-006 | LOW | Header Security | ✅ RESOLVED | src/auth/security_headers.rs |
---
@ -176,20 +176,19 @@ web::scope("/api/v1")
**Finding:** Duplicate Content-Type headers were accepted.
**Implementation:**
- Added `has_duplicate_critical_headers()` function to check for duplicate headers
- `has_duplicate_critical_headers()` function checks for duplicate headers on every request
- Monitors critical headers: `content-type`, `authorization`, `host`
- Integrated into mTLS middleware `call()` method
- Rejects requests with duplicate critical headers before further processing
- Implemented as `SecurityHeadersMiddleware` — a dedicated Actix-web middleware
- Wired into the middleware pipeline in `main.rs` between WhitelistMiddleware and Logger
- Rejects requests with duplicate critical headers with HTTP 400 Bad Request
**Code Location:** `src/auth/mtls.rs` (lines 26-49, 203-212)
**Code Location:** `src/auth/security_headers.rs`
```rust
fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
let critical_headers = ["content-type", "authorization", "host"];
for header_name in critical_headers.iter() {
pub fn has_duplicate_critical_headers(headers: &HeaderMap) -> bool {
for header_name in CRITICAL_HEADERS.iter() {
let mut count = 0;
for (name, _) in req.headers().iter() {
for (name, _value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1;
if count > 1 {
@ -202,7 +201,29 @@ fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
}
```
**Response:** HTTP 400 Bad Request with message "Duplicate critical headers not allowed"
**Response:** HTTP 400 Bad Request with error message "Duplicate critical headers not allowed"
**Architecture Note:** The duplicate-header check was originally in `MtlsMiddleware`, which was dead code (never wired into the pipeline). It has been extracted into `SecurityHeadersMiddleware`, which IS wired into the pipeline and runs on every request. Client certificate authentication is handled at the TLS handshake level by rustls via `CrlAwareVerifier` — no application-layer certificate middleware is needed. See `src/auth/mtls.rs` for the ADR documenting this decision.
---
## Architecture Decision Record: rustls as Authoritative Client-Auth Gate
**Decision:** Client certificate authentication is enforced at the TLS handshake level by rustls via `CrlAwareVerifier`, NOT by application-layer middleware.
**Context:** The original `MtlsMiddleware` was never wired into the Actix-web pipeline. It contained both a duplicate-header check (VULN-006) and a `validate_client_certificate()` stub that returned `Ok(())` unconditionally. Meanwhile, the actual client certificate verification was always performed by rustls at the TLS handshake level through `CrlAwareVerifier`, which wraps `WebPkiClientVerifier`.
**Rationale:**
- rustls provides battle-tested X.509 verification at the TLS handshake level
- Enforcing auth at the TLS layer eliminates bypass vulnerabilities (middleware ordering bugs, route-specific skips)
- CRL revocation checking is integrated into the same handshake path via `CrlAwareVerifier`
- Application-layer certificate validation is redundant when the TLS layer already rejects untrusted connections
**Consequences:**
- `MtlsMiddleware` (Transform/Service) and `validate_client_certificate()` have been removed as dead code
- `build_rustls_config()` is now a free function (no longer a method on `MtlsMiddleware`)
- `SecurityHeadersMiddleware` handles VULN-006 (duplicate critical header rejection) as a dedicated, wired middleware
- `ClientCertInfo` struct is preserved for potential future use in extracting certificate details from TLS sessions
---

View File

@ -265,5 +265,39 @@ The Linux_Patch_API Phase 3 is now **SECURE FOR DEPLOYMENT** in an internal netw
---
## Architecture Decision Record: rustls as Authoritative Client-Auth Gate
**Date:** 2026-06-06
**Status:** Accepted
**Context:** Issue #13
### Decision
Client certificate authentication is enforced at the TLS handshake level by rustls via `CrlAwareVerifier`, NOT by application-layer middleware.
### Context
The original `MtlsMiddleware` was never wired into the Actix-web pipeline (dead code). It contained:
1. A duplicate-header check (VULN-006) that never ran
2. A `validate_client_certificate()` stub that returned `Ok(())` unconditionally
Meanwhile, actual client certificate verification was always performed by rustls at the TLS handshake level through `CrlAwareVerifier` (which wraps `WebPkiClientVerifier`), with CRL revocation checking integrated into the same path.
### Changes Made
1. **Removed dead code:** `MtlsMiddleware`, `MtlsMiddlewareService`, `validate_client_certificate()`, and the Transform/Service impls
2. **Extracted VULN-006:** `has_duplicate_critical_headers()` moved to new `SecurityHeadersMiddleware` (wired into pipeline)
3. **Converted `build_rustls_config()`** from method on `MtlsMiddleware` to free function
4. **Preserved:** `CrlAwareVerifier`, `MtlsConfig`, `MtlsError`, `ClientCertInfo`, `build_rustls_config()`, and all CRL infrastructure
### Rationale
- rustls provides battle-tested X.509 verification at the TLS handshake level
- Enforcing auth at the TLS layer eliminates bypass vulnerabilities (middleware ordering bugs, route-specific skips)
- CRL revocation checking is integrated into the same handshake path
- Application-layer certificate validation is redundant when TLS already rejects untrusted connections
---
**Report Generated:** 2026-04-09T22:57:00Z
**Verified By:** Security Verification Agent (Agent Zero)

View File

@ -1,17 +1,27 @@
//! Auth Module - mTLS and IP Whitelist Enforcement
//! Auth Module - mTLS, IP Whitelist, and Security Headers
//!
//! This module provides security authentication and authorization:
//! - mTLS (Mutual TLS) certificate-based authentication
//! - mTLS (Mutual TLS) certificate-based authentication (enforced at TLS handshake by rustls)
//! - IP whitelist enforcement with CIDR subnet support
//! - Security header validation (VULN-006: duplicate critical header rejection)
//! - Silent drop for non-compliant connections
//! - Comprehensive audit logging
//!
//! # Architecture Decision Record: rustls as Authoritative Client-Auth Gate
//!
//! Client certificate authentication is enforced at the TLS handshake level by
//! rustls via `CrlAwareVerifier`. No application-layer certificate validation
//! middleware is needed — rustls rejects connections that fail client-cert
//! verification before any HTTP request is processed. See `mtls.rs` for details.
pub mod crl;
pub mod mtls;
pub mod security_headers;
pub mod whitelist;
pub use crl::{new_shared_state, CrlState, CrlStatus, SharedCrlState};
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError, MtlsMiddleware};
pub use mtls::{ClientCertInfo, MtlsConfig, MtlsError};
pub use security_headers::SecurityHeadersMiddleware;
pub use whitelist::{
WhitelistConfig, WhitelistEntry, WhitelistManager, WhitelistMiddleware,
WhitelistMiddlewareService,

View File

@ -1,20 +1,33 @@
//! mTLS Authentication Module
//! mTLS Configuration Module
//!
//! Provides mutual TLS authentication middleware for Actix-web.
//! Non-mTLS connections are silently dropped (no response).
//! Supports CRL-aware client certificate verification when CRL is available.
//! Provides rustls-based mutual TLS configuration for the API server.
//!
//! # Architecture Decision Record: rustls as Authoritative Client-Auth Gate
//!
//! Client certificate authentication is enforced at the TLS handshake level by
//! rustls via `CrlAwareVerifier` (which wraps `WebPkiClientVerifier`). This means:
//!
//! - **rustls + CrlAwareVerifier IS the authoritative client-auth gate.**
//! - No application-layer certificate validation middleware is needed because
//! rustls rejects connections that fail client-cert verification before any
//! HTTP request is processed.
//! - `build_rustls_config()` configures the TLS listener to require client
//! certificates (`with_client_cert_verifier`), making mTLS enforcement
//! unavoidable at the transport layer.
//! - CRL revocation checking is integrated into the same handshake path via
//! `CrlAwareVerifier`, so revoked certificates are also rejected before any
//! HTTP handler runs.
//!
//! This design was chosen because rustls provides battle-tested X.509
//! verification, and enforcing auth at the TLS layer eliminates an entire
//! class of bypass vulnerabilities that application-layer checks are
//! susceptible to (e.g., middleware ordering bugs, route-specific skips).
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
#[allow(unused_imports)]
use chrono::{DateTime, Duration, Utc};
use futures_util::future::LocalBoxFuture;
use chrono::{DateTime, Utc};
use rustls::{
client::danger::HandshakeSignatureValid,
crypto::aws_lc_rs,
pki_types::{CertificateDer, UnixTime},
pki_types::CertificateDer,
server::{
danger::{ClientCertVerified, ClientCertVerifier},
ServerConfig, WebPkiClientVerifier,
@ -24,35 +37,10 @@ use rustls::{
};
use rustls_pemfile::{certs, private_key};
use std::{fs::File, io::BufReader, sync::Arc};
use tracing::{debug, error, info, warn};
use tracing::{error, info, warn};
use super::crl::{cert_serial_hex, SharedCrlState};
/// Check for duplicate critical headers (VULN-006)
/// Returns true if duplicate headers are detected
fn has_duplicate_critical_headers(req: &ServiceRequest) -> bool {
let critical_headers = ["content-type", "authorization", "host"];
for header_name in critical_headers.iter() {
// Count occurrences of this header
let mut count = 0;
for (name, _) in req.headers().iter() {
if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1;
if count > 1 {
warn!(
peer_addr = ?req.peer_addr(),
header = header_name,
"Duplicate critical header detected - rejecting request"
);
return true;
}
}
}
}
false
}
/// CRL-aware client certificate verifier.
///
/// Wraps WebPkiClientVerifier for chain validation, then checks the
@ -87,7 +75,7 @@ impl ClientCertVerifier for CrlAwareVerifier {
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: UnixTime,
now: rustls::pki_types::UnixTime,
) -> Result<ClientCertVerified, RustlsError> {
// 1. Delegate chain validation to WebPKI
self.inner
@ -163,51 +151,40 @@ pub struct MtlsConfig {
pub min_tls_version: String,
}
/// mTLS Middleware for Actix-web
pub struct MtlsMiddleware {
config: Arc<MtlsConfig>,
cert_store: Arc<RootCertStore>,
}
/// Build a rustls ServerConfig with client certificate verification.
///
/// This is the authoritative mTLS gate — rustls enforces client certificate
/// validation at the TLS handshake level, before any HTTP request is processed.
///
/// When `crl_state` is provided and the CRL is available, wraps the
/// WebPkiClientVerifier with CrlAwareVerifier for revocation checking.
/// When CRL is missing/degraded, falls back to WebPKI-only verification.
pub fn build_rustls_config(
config: &MtlsConfig,
crl_state: Option<SharedCrlState>,
) -> Result<Arc<ServerConfig>, MtlsError> {
let cert_store = load_ca_certs(&config.ca_cert_path)?;
impl MtlsMiddleware {
/// Create a new mTLS middleware
pub fn new(config: MtlsConfig) -> Result<Self, MtlsError> {
let cert_store = load_ca_certs(&config.ca_cert_path)?;
let webpki_verifier = WebPkiClientVerifier::builder(cert_store.clone().into())
.build()
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
Ok(Self {
config: Arc::new(config),
cert_store: Arc::new(cert_store),
})
}
let client_verifier: Arc<dyn ClientCertVerifier> = match crl_state {
Some(state) => {
info!("CRL-aware client verification enabled");
Arc::new(CrlAwareVerifier::new(webpki_verifier, state))
}
None => {
info!("No CRL state provided -- using WebPKI-only client verification");
webpki_verifier
}
};
/// Build rustls server configuration with client certificate verification.
///
/// When `crl_state` is provided and the CRL is available, wraps the
/// WebPkiClientVerifier with CrlAwareVerifier for revocation checking.
/// When CRL is missing/degraded, falls back to WebPKI-only verification.
pub fn build_rustls_config(
&self,
crl_state: Option<SharedCrlState>,
) -> Result<Arc<ServerConfig>, MtlsError> {
let webpki_verifier = WebPkiClientVerifier::builder(self.cert_store.clone())
.build()
.map_err(|e| MtlsError::ClientVerifierError(e.to_string()))?;
let server_cert = load_certs(&config.server_cert_path)?;
let server_key = load_private_key(&config.server_key_path)?;
let client_verifier: Arc<dyn ClientCertVerifier> = match crl_state {
Some(state) => {
info!("CRL-aware client verification enabled");
Arc::new(CrlAwareVerifier::new(webpki_verifier, state))
}
None => {
info!("No CRL state provided -- using WebPKI-only client verification");
webpki_verifier
}
};
let server_cert = load_certs(&self.config.server_cert_path)?;
let server_key = load_private_key(&self.config.server_key_path)?;
let config = ServerConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
let server_config =
ServerConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider()))
.with_protocol_versions(&[&TLS13])
.map_err(|e| {
MtlsError::ServerConfigError(format!("Failed to set TLS 1.3 only: {}", e))
@ -216,8 +193,7 @@ impl MtlsMiddleware {
.with_single_cert(server_cert, server_key)
.map_err(|e| MtlsError::ServerConfigError(e.to_string()))?;
Ok(Arc::new(config))
}
Ok(Arc::new(server_config))
}
/// Load CA certificates from PEM file
@ -268,6 +244,21 @@ fn load_private_key(path: &str) -> Result<rustls::pki_types::PrivateKeyDer<'stat
Ok(key)
}
/// Certificate information extracted from client certificate.
///
/// NOTE: This struct is preserved for potential future use in extracting
/// client certificate details from the TLS session at the application layer.
/// Client authentication is enforced at the TLS handshake level by
/// CrlAwareVerifier — this struct is NOT used for validation.
#[derive(Debug, Clone)]
pub struct ClientCertInfo {
pub subject: String,
pub issuer: String,
pub serial: String,
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
}
/// mTLS Error types
#[derive(Debug, thiserror::Error)]
pub enum MtlsError {
@ -285,229 +276,16 @@ pub enum MtlsError {
ValidationError(String),
}
impl<S, B> Transform<S, ServiceRequest> for MtlsMiddleware
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 = MtlsMiddlewareService<S>;
type Future = futures_util::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
futures_util::future::ok(MtlsMiddlewareService {
service,
config: self.config.clone(),
cert_store: self.cert_store.clone(),
})
}
}
pub struct MtlsMiddlewareService<S> {
service: S,
#[allow(dead_code)]
config: Arc<MtlsConfig>,
cert_store: Arc<RootCertStore>,
}
impl<S, B> Service<ServiceRequest> for MtlsMiddlewareService<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 cert_store = self.cert_store.clone();
let peer_addr = req.peer_addr();
// VULN-006: Check for duplicate critical headers before processing
if has_duplicate_critical_headers(&req) {
warn!(
peer_addr = ?peer_addr,
"Duplicate critical headers detected - rejecting request (VULN-006)"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Duplicate critical headers not allowed",
))
});
}
// Check for client certificate in request extensions
// In a proper mTLS setup with Actix-web + rustls, the certificate
// would be extracted from the TLS connection before reaching this middleware
let has_client_cert = req.extensions().get::<ClientCertInfo>().is_some();
if !has_client_cert {
// No client certificate provided - silent drop
warn!(
peer_addr = ?peer_addr,
"No client certificate provided - dropping connection (mTLS required)"
);
// Return error immediately without calling service
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Client certificate required",
))
});
}
// Certificate present - validate it
let cert_info = req.extensions().get::<ClientCertInfo>().cloned();
if let Some(info) = cert_info {
// Validate certificate against CA store
match validate_client_certificate(&info, &cert_store) {
Ok(_) => {
info!(
subject = %info.subject,
issuer = %info.issuer,
peer_addr = ?peer_addr,
"mTLS client certificate validated successfully"
);
}
Err(e) => {
warn!(
error = %e,
peer_addr = ?peer_addr,
"mTLS client certificate validation failed - dropping connection"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Certificate validation failed",
))
});
}
}
} else {
warn!(
peer_addr = ?peer_addr,
"No client certificate provided - dropping connection (mTLS required)"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Client certificate required",
))
});
}
debug!("mTLS authentication passed for request");
// All checks passed - call the service
let fut = self.service.call(req);
Box::pin(fut)
}
}
/// Certificate information extracted from client certificate
#[derive(Debug, Clone)]
pub struct ClientCertInfo {
pub subject: String,
pub issuer: String,
pub serial: String,
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
}
/// Validate client certificate against CA store
fn validate_client_certificate(
cert_info: &ClientCertInfo,
_cert_store: &RootCertStore,
) -> Result<(), MtlsError> {
// Check certificate validity period
let now = Utc::now();
if now < cert_info.not_before {
return Err(MtlsError::ValidationError(
"Certificate is not yet valid".to_string(),
));
}
if now > cert_info.not_after {
return Err(MtlsError::ValidationError(
"Certificate has expired".to_string(),
));
}
// In production, would verify certificate chain against CA store
// For now, we trust certificates that were extracted from the TLS connection
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
#[test]
fn test_mtls_config_creation() {
let config = MtlsConfig {
ca_cert_path: "/etc/linux_patch_api/certs/ca.pem".to_string(),
server_cert_path: "/etc/linux_patch_api/certs/server.pem".to_string(),
server_key_path: "/etc/linux_patch_api/certs/server.key".to_string(),
min_tls_version: "1.3".to_string(),
};
assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem");
assert_eq!(config.min_tls_version, "1.3");
}
#[test]
fn test_client_cert_info() {
let info = ClientCertInfo {
subject: "CN=test-client".to_string(),
issuer: "CN=Test CA".to_string(),
serial: "12345".to_string(),
not_before: Utc::now() - Duration::days(1),
not_after: Utc::now() + Duration::days(365),
};
assert!(info.subject.contains("CN="));
assert!(info.issuer.contains("CN="));
// Test validation with valid cert
let cert_store = RootCertStore::empty();
assert!(validate_client_certificate(&info, &cert_store).is_ok());
}
#[test]
fn test_client_cert_expired() {
let info = ClientCertInfo {
subject: "CN=expired-client".to_string(),
issuer: "CN=Test CA".to_string(),
serial: "12345".to_string(),
not_before: Utc::now() - Duration::days(365),
not_after: Utc::now() - Duration::days(1),
};
let cert_store = RootCertStore::empty();
let result = validate_client_certificate(&info, &cert_store);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("expired"));
}
// -----------------------------------------------------------------------
// CrlAwareVerifier unit tests
// -----------------------------------------------------------------------
/// Test that CrlAwareVerifier can be constructed with a WebPKI verifier
/// and a SharedCrlState. This verifies the wiring is correct.
#[test]
fn crl_aware_verifier_construction() {
fn init_crypto_provider() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
use super::super::crl::{new_shared_state, CrlState, CrlStatus};
use std::collections::HashSet;
}
// Build a simple CA cert + key for the root store.
fn make_test_ca_and_root_store() -> (rcgen::KeyPair, RootCertStore) {
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
@ -519,18 +297,26 @@ mod tests {
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
// Build root cert store with the CA.
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
// Build WebPKI verifier — build() returns Arc<WebPkiClientVerifier>
// which coerces to Arc<dyn ClientCertVerifier>.
(ca_key, root_store)
}
/// Test that CrlAwareVerifier can be constructed with a WebPKI verifier
/// and a SharedCrlState. This verifies the wiring is correct.
#[test]
fn crl_aware_verifier_construction() {
init_crypto_provider();
use super::super::crl::{new_shared_state, CrlState, CrlStatus};
let (_ca_key, root_store) = make_test_ca_and_root_store();
let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into())
.build()
.unwrap();
// Build CRL state in Valid status.
let crl_state = new_shared_state();
let valid_state = CrlState {
status: CrlStatus::Valid,
@ -540,31 +326,16 @@ mod tests {
};
crl_state.store(Arc::new(valid_state));
// Construct CrlAwareVerifier — should succeed.
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
// If we reach here without panic, construction succeeded.
}
/// Test that CrlAwareVerifier with Missing CRL state can be constructed.
/// Missing CRL means the verifier falls back to WebPKI-only.
#[test]
fn crl_aware_verifier_with_missing_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
init_crypto_provider();
use super::super::crl::new_shared_state;
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test CA for Verifier");
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
let (_ca_key, root_store) = make_test_ca_and_root_store();
let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into())
@ -577,26 +348,12 @@ mod tests {
}
/// Test that CrlAwareVerifier with Invalid CRL state can be constructed.
/// Invalid CRL means the verifier should reject ALL client certificates.
#[test]
fn crl_aware_verifier_with_invalid_crl() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
init_crypto_provider();
use super::super::crl::{new_shared_state, CrlState, CrlStatus};
use std::collections::HashSet;
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test CA for Verifier");
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
let (_ca_key, root_store) = make_test_ca_and_root_store();
let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into())
@ -616,27 +373,13 @@ mod tests {
}
/// Test that CrlAwareVerifier with a revoked serial in Valid CRL state
/// can be constructed. The actual verification logic is tested through
/// integration tests since it requires a full TLS handshake.
/// can be constructed.
#[test]
fn crl_aware_verifier_with_revoked_serial() {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
init_crypto_provider();
use super::super::crl::{new_shared_state, CrlState, CrlStatus};
use std::collections::HashSet;
let ca_key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).unwrap();
let mut ca_params = rcgen::CertificateParams::default();
ca_params.not_before = time::OffsetDateTime::now_utc();
ca_params.not_after = time::OffsetDateTime::now_utc() + time::Duration::days(365);
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
let mut dn = rcgen::DistinguishedName::new();
dn.push(rcgen::DnType::CommonName, "Test CA for Verifier");
ca_params.distinguished_name = dn;
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
let mut root_store = RootCertStore::empty();
root_store.add(ca_cert.der().to_owned()).unwrap();
let (_ca_key, root_store) = make_test_ca_and_root_store();
let webpki_verifier: Arc<dyn ClientCertVerifier> =
WebPkiClientVerifier::builder(root_store.into())
@ -655,6 +398,5 @@ mod tests {
crl_state.store(Arc::new(valid_with_revoked));
let _verifier = CrlAwareVerifier::new(webpki_verifier, crl_state);
// Construction succeeded — the verifier is ready to reject revoked certs.
}
}

View File

@ -0,0 +1,166 @@
//! Security Headers Middleware Module
//!
//! Provides request-level security header validation for Actix-web.
//! Enforces VULN-006: rejects requests with duplicate critical headers
//! (content-type, authorization, host) to prevent HTTP request smuggling
//! and response-splitting attacks.
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error,
};
use futures_util::future::LocalBoxFuture;
use tracing::warn;
/// Critical headers that MUST NOT appear more than once in a request.
/// Duplicate values for these headers can enable request smuggling,
/// response splitting, and other HTTP parsing ambiguities.
const CRITICAL_HEADERS: &[&str] = &["content-type", "authorization", "host"];
/// Security headers middleware for Actix-web.
///
/// Checks every incoming request for duplicate critical headers (VULN-006)
/// and rejects malformed requests with HTTP 400 Bad Request.
pub struct SecurityHeadersMiddleware;
impl SecurityHeadersMiddleware {
/// Create a new security headers middleware instance.
pub fn new() -> Self {
Self
}
}
impl Default for SecurityHeadersMiddleware {
fn default() -> Self {
Self::new()
}
}
/// Actix-web Transform implementation — wraps SecurityHeadersMiddleware as middleware
impl<S, B> Transform<S, ServiceRequest> for SecurityHeadersMiddleware
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 = SecurityHeadersMiddlewareService<S>;
type Future = futures_util::future::Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
futures_util::future::ok(SecurityHeadersMiddlewareService { service })
}
}
/// Security headers middleware service — performs per-request duplicate header checks
pub struct SecurityHeadersMiddlewareService<S> {
service: S,
}
impl<S, B> Service<ServiceRequest> for SecurityHeadersMiddlewareService<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 {
// VULN-006: Check for duplicate critical headers before processing
if has_duplicate_critical_headers(req.headers()) {
let peer_addr = req.peer_addr();
warn!(
peer_addr = ?peer_addr,
"Duplicate critical headers detected - rejecting request (VULN-006)"
);
return Box::pin(async move {
Err(actix_web::error::ErrorBadRequest(
"Duplicate critical headers not allowed",
))
});
}
// All checks passed — call the service
let fut = self.service.call(req);
Box::pin(fut)
}
}
/// Check for duplicate critical headers (VULN-006).
/// Returns true if any critical header appears more than once.
///
/// This function is public for testing purposes.
pub fn has_duplicate_critical_headers(headers: &actix_web::http::header::HeaderMap) -> bool {
for header_name in CRITICAL_HEADERS.iter() {
let mut count = 0;
for (name, _value) in headers.iter() {
if name.as_str().eq_ignore_ascii_case(header_name) {
count += 1;
if count > 1 {
return true;
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use actix_web::http::header;
#[test]
fn test_no_duplicate_headers_passes() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.insert(header::AUTHORIZATION, "Bearer test".parse().unwrap());
headers.insert(header::HOST, "localhost".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_content_type_rejected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.append(header::CONTENT_TYPE, "text/plain".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_authorization_rejected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer test1".parse().unwrap());
headers.append(header::AUTHORIZATION, "Bearer test2".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_host_rejected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::HOST, "localhost".parse().unwrap());
headers.append(header::HOST, "evil.com".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_non_critical_duplicate_headers_allowed() {
// Duplicate non-critical headers should be allowed
let mut headers = header::HeaderMap::new();
headers.insert(header::ACCEPT, "text/html".parse().unwrap());
headers.append(header::ACCEPT, "application/json".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_empty_headers_passes() {
let headers = header::HeaderMap::new();
assert!(!has_duplicate_critical_headers(&headers));
}
}

View File

@ -28,7 +28,9 @@ 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, WhitelistMiddleware};
use linux_patch_api::auth::{
mtls, SecurityHeadersMiddleware, WhitelistManager, WhitelistMiddleware,
};
use linux_patch_api::config::loader::{validate_certs, CertStatus};
use linux_patch_api::enroll;
use linux_patch_api::packages::cache::PackageCacheState;
@ -310,9 +312,14 @@ async fn main() -> Result<()> {
let wl = whitelist_manager.clone();
// Create server builder
// Security middleware stack (order matters):
// 1. WhitelistMiddleware — IP-based access control (deny-by-default)
// 2. SecurityHeadersMiddleware — VULN-006: reject duplicate critical headers
// 3. Logger — request logging (after auth decisions)
let server_builder = HttpServer::new(move || {
let mut app = App::new()
.wrap(WhitelistMiddleware::new(wl.clone()))
.wrap(SecurityHeadersMiddleware::new())
.wrap(Logger::default())
.app_data(job_manager_data.clone())
.app_data(backend_data.clone())
@ -406,63 +413,58 @@ async fn main() -> Result<()> {
info!("No manager URL configured -- CRL auto-refresh disabled");
}
match MtlsMiddleware::new(mtls_config.clone()) {
Ok(middleware) => {
// Build rustls server configuration with CRL-aware verifier
let rustls_config = middleware
.build_rustls_config(Some(shared_crl_state.clone()))
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
// ADR: rustls is the authoritative client-auth gate.
// Client certificate verification happens at the TLS handshake level
// via CrlAwareVerifier (which wraps WebPkiClientVerifier). No
// application-layer certificate validation middleware is needed.
// See src/auth/mtls.rs for the full ADR.
let rustls_config = mtls::build_rustls_config(&mtls_config, Some(shared_crl_state.clone()))
.map_err(|e| anyhow::anyhow!("Failed to build rustls config: {}", e))?;
info!("mTLS middleware and rustls config initialized successfully");
info!(
"mTLS rustls config initialized successfully (client auth enforced at TLS handshake)"
);
// Create TCP listener with SO_REUSEADDR using socket2
// This prevents "Address already in use" errors when restarting after a crash
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.map_err(|e| anyhow::anyhow!("Failed to create socket: {}", e))?;
// Create TCP listener with SO_REUSEADDR using socket2
// This prevents "Address already in use" errors when restarting after a crash
let socket = socket2::Socket::new(
socket2::Domain::IPV4,
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)
.map_err(|e| anyhow::anyhow!("Failed to create socket: {}", e))?;
socket
.set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
socket
.set_reuse_address(true)
.map_err(|e| anyhow::anyhow!("Failed to set SO_REUSEADDR: {}", e))?;
let bind_addr: std::net::SocketAddr = bind_address.parse().map_err(|e| {
anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e)
})?;
let bind_addr: std::net::SocketAddr = bind_address
.parse()
.map_err(|e| anyhow::anyhow!("Invalid bind address '{}': {}", bind_address, e))?;
socket
.bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| {
anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e)
})?;
socket
.bind(&socket2::SockAddr::from(bind_addr))
.map_err(|e| anyhow::anyhow!("Failed to bind socket to {}: {}", bind_address, e))?;
socket
.listen(128)
.map_err(|e| anyhow::anyhow!("Failed to listen on socket: {}", e))?;
socket
.listen(128)
.map_err(|e| anyhow::anyhow!("Failed to listen on socket: {}", e))?;
let tcp_listener: std::net::TcpListener = socket.into();
let tcp_listener: std::net::TcpListener = socket.into();
// Log listening AFTER successful bind
info!("Listening on {} (mTLS enabled)", bind_address);
// Log listening AFTER successful bind
info!("Listening on {} (mTLS enabled)", bind_address);
// Clone the ServerConfig from Arc for listen_rustls_0_23
let server_config = (*rustls_config).clone();
// Clone the ServerConfig from Arc for listen_rustls_0_23
let server_config = (*rustls_config).clone();
info!("Binding server with TLS 1.3 - non-TLS connections will be rejected");
info!("Binding server with TLS 1.3 - non-TLS connections will be rejected");
// Bind with TLS using rustls 0.23 - non-TLS connections fail at handshake
server_builder
.listen_rustls_0_23(tcp_listener, server_config)?
.run()
.await?;
}
Err(e) => {
error!(error = %e, "Failed to initialize mTLS middleware");
return Err(anyhow::anyhow!("mTLS initialization failed: {}", e));
}
}
// Bind with TLS using rustls 0.23 - non-TLS connections fail at handshake
server_builder
.listen_rustls_0_23(tcp_listener, server_config)?
.run()
.await?;
} else {
// Create TCP listener with SO_REUSEADDR for non-TLS mode
let socket = socket2::Socket::new(

View File

@ -19,8 +19,14 @@ mod mtls_tests {
};
assert_eq!(config.ca_cert_path, "/etc/linux_patch_api/certs/ca.pem");
assert_eq!(config.server_cert_path, "/etc/linux_patch_api/certs/server.pem");
assert_eq!(config.server_key_path, "/etc/linux_patch_api/certs/server.key");
assert_eq!(
config.server_cert_path,
"/etc/linux_patch_api/certs/server.pem"
);
assert_eq!(
config.server_key_path,
"/etc/linux_patch_api/certs/server.key"
);
assert_eq!(config.min_tls_version, "1.3");
}
@ -232,9 +238,61 @@ mod auth_result_tests {
assert!(result.is_authenticated());
assert!(result.cert_info.is_some());
assert_eq!(
result.cert_info.unwrap().subject,
"CN=client001"
);
assert_eq!(result.cert_info.unwrap().subject, "CN=client001");
}
}
/// Integration tests for SecurityHeadersMiddleware (VULN-006)
#[cfg(test)]
mod security_headers_tests {
use actix_web::http::header;
use linux_patch_api::auth::security_headers::has_duplicate_critical_headers;
#[test]
fn test_no_duplicate_headers_passes() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.insert(header::AUTHORIZATION, "Bearer test".parse().unwrap());
headers.insert(header::HOST, "localhost".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_content_type_detected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
headers.append(header::CONTENT_TYPE, "text/plain".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_authorization_detected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Bearer test1".parse().unwrap());
headers.append(header::AUTHORIZATION, "Bearer test2".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_duplicate_host_detected() {
let mut headers = header::HeaderMap::new();
headers.insert(header::HOST, "localhost".parse().unwrap());
headers.append(header::HOST, "evil.com".parse().unwrap());
assert!(has_duplicate_critical_headers(&headers));
}
#[test]
fn test_non_critical_duplicates_allowed() {
// Duplicate Accept headers should be fine
let mut headers = header::HeaderMap::new();
headers.insert(header::ACCEPT, "text/html".parse().unwrap());
headers.append(header::ACCEPT, "application/json".parse().unwrap());
assert!(!has_duplicate_critical_headers(&headers));
}
#[test]
fn test_empty_headers_passes() {
let headers = header::HeaderMap::new();
assert!(!has_duplicate_critical_headers(&headers));
}
}