diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d8068..58a7fe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Removed the distributable Kubernetes administrator `Secret` and historical placeholder credential. Production deployments must provision `waf-ids-ai-soc-admin` / `ADMIN_TOKEN` through the external secret-management control plane; the workload's `secretKeyRef` is explicitly non-optional. - Added a structural regression contract that rejects shipped administrator Secret objects, placeholder credentials, decoy workloads, init-container false positives, and optional administrator Secret references. +- Forwarded client IP headers are now ignored unless the direct peer matches `TRUSTED_PROXY_CIDRS`. Trusted chains are parsed right to left, malformed chains fail closed to the peer address, and IPv4-mapped trusted peers normalize correctly before rate limiting and DNSBL attribution. ### Operations diff --git a/docs/papers/trusted-proxy-client-ip-attribution-sources.md b/docs/papers/trusted-proxy-client-ip-attribution-sources.md new file mode 100644 index 0000000..b7b325f --- /dev/null +++ b/docs/papers/trusted-proxy-client-ip-attribution-sources.md @@ -0,0 +1,50 @@ +# Trusted proxy client IP attribution sources + +This note records the standards, operational guidance, and peer-reviewed +security evidence that ground Wardnet's trusted-proxy client IP attribution +behavior. The implementation anchors trust in the direct transport peer and +considers forwarded metadata only when that peer belongs to an explicitly +configured trusted range. + +## Evidence synthesis + +RFC 7239 defines forwarding metadata as information added by intermediaries; it +is not self-authenticating client truth. NGINX documents the trusted-proxy rule +explicitly and resolves recursive address chains to the last non-trusted hop. +Envoy documents the same right-to-left trust model for `X-Forwarded-For` with +trusted CIDR lists. + +Pletinckx, Kruegel, and Vigna's NDSS 2025 Internet-scale measurement provides +independent empirical security evidence for the same boundary. Their study +shows that backends accepting proxy-supplied source identity from arbitrary +network sources can permit access-control bypass and other security failures. +For Wardnet, that supports direct-peer verification before forwarded metadata +can affect rate limiting, DNSBL decisions, or event attribution. The paper does +not prescribe Wardnet's exact HTTP malformed-chain algorithm; Wardnet's choice +to reject an incomplete or unparsable `X-Forwarded-For` chain and fall back to +the direct peer is a conservative fail-closed policy derived from the broader +untrusted-metadata threat. + +## References + +- Nottingham, M. (Ed.), & Kamp, P. H. (Ed.). (2014). *Forwarded HTTP + extension* (RFC 7239). Internet Engineering Task Force. + https://datatracker.ietf.org/doc/html/rfc7239 +- NGINX, Inc. (n.d.). *Module ngx_http_realip_module*. + https://nginx.org/en/docs/http/ngx_http_realip_module.html +- Envoy contributors. (n.d.). *HTTP header manipulation*. + https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_conn_man/headers +- Pletinckx, S., Kruegel, C., & Vigna, G. (2025). A large-scale measurement + study of the PROXY protocol and its security implications. *Network and + Distributed System Security Symposium 2025*. + https://doi.org/10.14722/ndss.2025.242247 + Open-access paper: https://www.ndss-symposium.org/wp-content/uploads/2025-2247-paper.pdf + +## Redistribution note + +The IETF, NGINX, Envoy, and NDSS source locations are linked directly so the +repository preserves authoritative origin and version context. The NDSS paper +is openly readable from the symposium site, but this repository does not vendor +a copy until redistribution terms for storing a derivative repository copy are +explicitly verified. Linkability and free access are not treated as permission +to redistribute a binary artifact. diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 9b6b701..61183c6 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -53,6 +53,42 @@ cargo run Health reports `credentials_source` (`file` / `env` / `none`) and `admin_auth_configured` (boolean) without exposing secret values. +## Trusted proxy client IP attribution + +Forwarded client IP headers are untrusted by default. Gateway rate limiting, +DNSBL matching, and event attribution use the direct peer address unless that +peer matches `TRUSTED_PROXY_CIDRS`. + +```bash +TRUSTED_PROXY_CIDRS=192.0.2.0/24,2001:db8::/32 \ +cargo run +``` + +When a peer is trusted, Wardnet parses the complete `X-Forwarded-For` chain +from right to left and picks the first hop that is not itself a trusted proxy. +If the header is absent, Wardnet may use `X-Real-IP` from that same trusted +context. If any forwarded hop is empty or invalid, the whole chain is rejected +and Wardnet falls back to the direct peer without consulting `X-Real-IP`. + +Wardnet follows the trust-boundary model documented by RFC 7239 and common +edge proxies. RFC 7239 defines forwarded metadata as proxy-added information, +which means the application must first trust the direct peer before treating +the header as evidence. NGINX documents recursive resolution as "the last +non-trusted address" in the chain, and Envoy documents trusted CIDR handling +from the right side of `X-Forwarded-For` toward the client. + +Reference links: + +- RFC 7239, *Forwarded HTTP extension*: + +- NGINX `ngx_http_realip_module`: + +- Envoy HTTP header manipulation: + + +For an implementation-oriented source note, see +`docs/papers/trusted-proxy-client-ip-attribution-sources.md`. + ## Health Check ```bash diff --git a/src/credentials.rs b/src/credentials.rs index bcc07e5..99e3e91 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -7,7 +7,11 @@ //! [`CredentialRegistry::get_credential`]. use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, io::ErrorKind, path::Path}; +use std::{ + collections::HashMap, + io::ErrorKind, + path::{Path, PathBuf}, +}; /// Well-known credentials loaded into the registry at bootstrap. pub const CRED_ADMIN_TOKEN: &str = "admin_token"; @@ -49,6 +53,19 @@ impl CredentialRegistry { Self::default() } + /// Bootstrap the registry from process-edge delivery inputs. + pub fn bootstrap_from_env() -> Result<(Self, Option), String> { + let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") + .ok() + .map(PathBuf::from); + let registry = Self::bootstrap_secrets( + credentials_path.as_deref(), + std::env::var("ADMIN_TOKEN").ok(), + std::env::var("ADMIN_TOKENS").ok(), + )?; + Ok((registry, credentials_path)) + } + pub fn get_credential(&self, name: &str) -> Option<&str> { self.values.get(name).map(String::as_str) } diff --git a/src/lib.rs b/src/lib.rs index ab902ca..9ed3493 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,8 @@ use axum::{ Json, Router, - body::Bytes, - extract::{DefaultBodyLimit, Path as PathParam, Query, State}, - http::{HeaderMap, Method, StatusCode, Uri}, + body::{Bytes, to_bytes}, + extract::{ConnectInfo, DefaultBodyLimit, Path as PathParam, Query, Request, State}, + http::{HeaderMap, Method, StatusCode}, response::{Html, IntoResponse, Response}, routing::{any, get, post}, }; @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; use std::{ collections::{HashMap, HashSet}, io::ErrorKind, - net::{IpAddr, Ipv4Addr}, + net::{IpAddr, Ipv4Addr, SocketAddr}, path::{Path, PathBuf}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, @@ -41,10 +41,12 @@ mod credentials; mod kev_import; mod misp_import; mod opencti_import; +mod runtime_config; mod stix_import; mod suricata_eve; mod taxii; pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use runtime_config::RuntimeConfiguration; #[derive(Clone)] pub struct AppState { @@ -66,6 +68,7 @@ pub struct AppState { rate_limiter: Arc>>, rate_limit: u32, rate_limit_window: u64, + trusted_proxies: Vec, // Max accepted request body size in bytes; oversized requests get 413. max_body_bytes: usize, // Optional Clearfolio document-viewer integration. `None` unless configured. @@ -138,6 +141,7 @@ impl AppState { rate_limiter: Arc::new(Mutex::new(HashMap::new())), rate_limit: 0, rate_limit_window: 60, + trusted_proxies: config.trusted_proxies, max_body_bytes: 1_048_576, clearfolio: None, soc_llm: None, @@ -196,6 +200,12 @@ impl AppState { self } + /// Trust forwarded client IP headers only from these proxy source ranges. + pub fn with_trusted_proxies(mut self, trusted_proxies: Vec) -> Self { + self.trusted_proxies = trusted_proxies; + self + } + /// Configure RBAC admin tokens (token -> principal). A non-empty map takes /// precedence over the single `admin_token`. Builder-style. pub fn with_admin_tokens(mut self, tokens: HashMap) -> Self { @@ -293,6 +303,7 @@ pub struct AppConfig { pub state_path: Option, pub dnsbl_origin: String, pub event_limit: usize, + pub trusted_proxies: Vec, } impl AppConfig { @@ -305,8 +316,98 @@ impl AppConfig { state_path: None, dnsbl_origin: Self::DEFAULT_DNSBL_ORIGIN.to_string(), event_limit: Self::DEFAULT_EVENT_LIMIT, + trusted_proxies: Vec::new(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IpNet { + addr: IpAddr, + prefix_len: u8, +} + +impl IpNet { + /// Parse one IP or CIDR entry used by trusted-proxy admission. + pub fn parse(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + return Err("trusted proxy entry must not be empty".to_string()); + } + let (addr, prefix_len) = match raw.split_once('/') { + Some((addr, prefix)) => { + let addr = parse_ip_with_mapped_ipv4(addr)?; + let max_prefix = match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + let prefix_len = prefix + .trim() + .parse::() + .map_err(|error| format!("invalid trusted proxy prefix {raw:?}: {error}"))?; + if prefix_len > max_prefix { + return Err(format!( + "trusted proxy prefix {prefix_len} is too large for {raw:?}" + )); + } + (addr, prefix_len) + } + None => { + let addr = parse_ip_with_mapped_ipv4(raw)?; + let prefix_len = match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + }; + (addr, prefix_len) + } + }; + Ok(Self { addr, prefix_len }) + } + + fn contains(&self, ip: IpAddr) -> bool { + ip_in_network(self.addr, self.prefix_len, normalize_ip(ip)) + } +} + +impl std::str::FromStr for IpNet { + type Err = String; + + fn from_str(raw: &str) -> Result { + Self::parse(raw) + } +} + +fn parse_ip_with_mapped_ipv4(raw: &str) -> Result { + raw.trim() + .parse::() + .map(normalize_ip) + .map_err(|error| format!("invalid trusted proxy address {raw:?}: {error}")) +} + +fn normalize_ip(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V4(ipv4) => IpAddr::V4(ipv4), + IpAddr::V6(ipv6) => ipv6 + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ipv6)), + } +} + +/// Parse the trusted-proxy bootstrap value into normalized host/CIDR entries. +pub(crate) fn parse_trusted_proxies(raw: Option<&str>) -> Result, String> { + let Some(raw) = raw else { + return Ok(Vec::new()); + }; + let mut proxies = Vec::new(); + for entry in raw.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; } + proxies.push(IpNet::parse(entry)?); } + Ok(proxies) } async fn load_or_seed_state(path: &Path) -> Result { @@ -2319,13 +2420,19 @@ async fn dnsbl_zone(State(state): State) -> impl IntoResponse { ) } -async fn gateway( - State(state): State, - method: Method, - uri: Uri, - headers: HeaderMap, - body: Bytes, -) -> Response { +async fn gateway(State(state): State, request: Request) -> Response { + let peer_ip = request + .extensions() + .get::>() + .map(|peer| peer.0.ip()); + let (parts, body) = request.into_parts(); + let method = parts.method; + let uri = parts.uri; + let headers = parts.headers; + let body = match to_bytes(body, state.max_body_bytes).await { + Ok(body) => body, + Err(_) => return error(StatusCode::PAYLOAD_TOO_LARGE, "request body too large"), + }; let gateway_path = uri .path() .strip_prefix("/gateway") @@ -2343,7 +2450,7 @@ async fn gateway( (route.clone(), data.threats.clone(), data.dnsbl.clone()) }; - let client_ip = client_ip_from_headers(&headers); + let client_ip = client_ip_from_request(&headers, peer_ip, &state.trusted_proxies); // Rate limiting runs before scoring/proxying so floods are shed cheaply. if !state.allow_request(client_ip).await { @@ -2440,18 +2547,89 @@ async fn gateway( } } -fn client_ip_from_headers(headers: &HeaderMap) -> Option { - headers - .get("x-forwarded-for") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.split(',').next()) - .map(str::trim) - .or_else(|| { - headers - .get("x-real-ip") - .and_then(|value| value.to_str().ok()) - }) - .and_then(|value| value.parse().ok()) +/// Read one request header as UTF-8 text, ignoring invalid byte sequences. +fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +/// Resolve the effective client IP for gateway enforcement. +/// +/// Wardnet trusts forwarded client metadata only from configured proxy ranges, +/// then walks `X-Forwarded-For` right to left until it reaches the first hop +/// outside that trusted set. That matches the operational model used by common +/// reverse proxies and the trust boundary described by RFC 7239's proxy-added +/// forwarding parameters: each intermediary may add or alter the chain, so the +/// application must anchor trust in the direct peer rather than in header +/// presence alone. +/// +/// Sources: +/// - RFC 7239: +/// - NGINX realip recursion: +/// - Envoy trusted XFF handling: +pub fn effective_client_ip( + peer_ip: Option, + x_forwarded_for: Option<&str>, + x_real_ip: Option<&str>, + trusted_proxies: &[IpNet], +) -> Option { + let peer_ip = peer_ip.map(normalize_ip)?; + if !trusted_proxies.iter().any(|proxy| proxy.contains(peer_ip)) { + return Some(peer_ip); + } + + match trusted_forwarded_chain_client_ip_checked(x_forwarded_for, trusted_proxies) { + Ok(Some(client_ip)) => Some(client_ip), + Ok(None) => trusted_real_ip_value(x_real_ip).or(Some(peer_ip)), + Err(()) => Some(peer_ip), + } +} + +/// Resolve client IP headers from the request's direct peer and trusted ranges. +fn client_ip_from_request( + headers: &HeaderMap, + peer_ip: Option, + trusted_proxies: &[IpNet], +) -> Option { + effective_client_ip( + peer_ip, + header_str(headers, "x-forwarded-for"), + header_str(headers, "x-real-ip"), + trusted_proxies, + ) +} + +/// Parse and validate a trusted `X-Forwarded-For` chain. +/// +/// Any malformed or empty hop invalidates the entire chain and forces a +/// fail-closed fallback to the direct peer. When the chain is valid, the +/// rightmost untrusted hop is the client address nearest the trust boundary. +fn trusted_forwarded_chain_client_ip_checked( + x_forwarded_for: Option<&str>, + trusted_proxies: &[IpNet], +) -> Result, ()> { + let Some(forwarded) = x_forwarded_for else { + return Ok(None); + }; + let mut hops = Vec::new(); + for hop in forwarded.split(',') { + let hop = hop.trim(); + if hop.is_empty() { + return Err(()); + } + let ip = hop.parse::().map(normalize_ip).map_err(|_| ())?; + hops.push(ip); + } + Ok(hops + .into_iter() + .rev() + .find(|ip| !trusted_proxies.iter().any(|proxy| proxy.contains(*ip)))) +} + +/// Parse `X-Real-IP` only after the direct peer has already been trusted. +fn trusted_real_ip_value(x_real_ip: Option<&str>) -> Option { + x_real_ip + .and_then(|value| value.trim().parse::().ok()) + .map(normalize_ip) } async fn proxy_request( @@ -3276,42 +3454,18 @@ pub fn parse_u64_env( pub async fn run_from_env( shutdown: std::pin::Pin + Send>>, ) -> Result<(), Box> { - let bind_addr = std::env::var("BIND_ADDR").unwrap_or_else(|_| "127.0.0.1:8080".to_string()); - // Secret-bearing values go through the credential registry (env/file are - // bootstrap transports only). Operational config remains env for now. - let credentials_path = std::env::var("WAF_IDS_CREDENTIALS_PATH") - .ok() - .map(PathBuf::from); - let credentials = CredentialRegistry::bootstrap_secrets( - credentials_path.as_deref(), - std::env::var("ADMIN_TOKEN").ok(), - std::env::var("ADMIN_TOKENS").ok(), - )?; - let config = AppConfig { - admin_token: credentials - .get_credential(CRED_ADMIN_TOKEN) - .map(str::to_owned), - state_path: std::env::var("WAF_IDS_STATE_PATH").ok().map(PathBuf::from), - dnsbl_origin: std::env::var("DNSBL_ORIGIN") - .unwrap_or_else(|_| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()), - event_limit: parse_event_limit(std::env::var("EVENT_LIMIT").ok().as_deref())?, - }; - let rate_limit = parse_u32_env("RATE_LIMIT", std::env::var("RATE_LIMIT").ok().as_deref(), 0)?; - let rate_limit_window = parse_u64_env( - "RATE_LIMIT_WINDOW", - std::env::var("RATE_LIMIT_WINDOW").ok().as_deref(), - 60, - )?; + let runtime = RuntimeConfiguration::from_env()?; + let bind_addr = runtime.bind_addr.clone(); + let (credentials, _credentials_path) = CredentialRegistry::bootstrap_from_env()?; + let config = runtime.app_config(&credentials); + let rate_limit = runtime.rate_limit; + let rate_limit_window = runtime.rate_limit_window; let admin_tokens = parse_admin_tokens( credentials .get_credential(CRED_ADMIN_TOKENS) .unwrap_or_default(), ); - let max_body_bytes = parse_u64_env( - "MAX_BODY_BYTES", - std::env::var("MAX_BODY_BYTES").ok().as_deref(), - 1_048_576, - )? as usize; + let max_body_bytes = runtime.max_body_bytes; let listener = tokio::net::TcpListener::bind(&bind_addr).await?; let local_addr = listener.local_addr()?; println!("waf-ids-ai-soc listening on http://{local_addr}"); @@ -3325,9 +3479,12 @@ pub async fn run_from_env( .with_admin_tokens(admin_tokens) .with_credentials_source(credentials.source()) .with_max_body_size(max_body_bytes); - let served = axum::serve(listener, build_app(state)) - .with_graceful_shutdown(shutdown) - .await; + let served = axum::serve( + listener, + build_app(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await; served?; Ok(()) } @@ -3368,6 +3525,7 @@ mod tests { "RATE_LIMIT", "RATE_LIMIT_WINDOW", "MAX_BODY_BYTES", + "TRUSTED_PROXY_CIDRS", ] { unsafe { std::env::remove_var(name) }; } @@ -3401,6 +3559,22 @@ mod tests { assert!(parse_u64_env("RATE_LIMIT_WINDOW", Some("abc"), 60).is_err()); } + #[test] + fn parse_trusted_proxies_accepts_hosts_and_cidrs() { + let parsed = + parse_trusted_proxies(Some("192.0.2.0/24, ::ffff:192.0.2.44, 2001:db8::/32")).unwrap(); + assert_eq!(parsed.len(), 3); + assert!(parsed[0].contains("192.0.2.77".parse().unwrap())); + assert!(parsed[1].contains("192.0.2.44".parse().unwrap())); + assert!(parsed[2].contains("2001:db8::10".parse().unwrap())); + } + + #[test] + fn parse_trusted_proxies_rejects_invalid_entries() { + assert!(parse_trusted_proxies(Some("192.0.2.0/40")).is_err()); + assert!(parse_trusted_proxies(Some("bad-ip")).is_err()); + } + #[test] fn parses_and_limits_phishing_database_feeds() { let domains = parse_phishing_domains( @@ -3443,6 +3617,22 @@ mod tests { clear_run_env(); } + #[tokio::test] + async fn run_from_env_rejects_malformed_trusted_proxy_cidrs() { + let _guard = ENV_GUARD.lock().await; + clear_run_env(); + unsafe { + std::env::set_var("BIND_ADDR", "127.0.0.1:0"); + std::env::set_var("TRUSTED_PROXY_CIDRS", "192.0.2.0/40"); + } + assert!( + run_from_env(Box::pin(std::future::ready(()))) + .await + .is_err() + ); + clear_run_env(); + } + #[tokio::test] async fn run_from_env_rejects_malformed_rate_limit_window() { let _guard = ENV_GUARD.lock().await; @@ -3636,12 +3826,14 @@ mod tests { } fn gateway_get_from_ip(uri: &str, ip: &str) -> Request { - Request::builder() - .method(Method::GET) - .uri(uri) - .header("x-forwarded-for", ip) - .body(Body::empty()) - .unwrap() + with_peer_ip( + Request::builder() + .method(Method::GET) + .uri(uri) + .body(Body::empty()) + .unwrap(), + ip, + ) } #[test] @@ -3674,6 +3866,116 @@ mod tests { assert_eq!(other.status(), StatusCode::OK); } + fn with_peer_ip(mut request: Request, peer_ip: &str) -> Request { + let peer = SocketAddr::new(peer_ip.parse().unwrap(), 443); + request.extensions_mut().insert(ConnectInfo(peer)); + request + } + + #[test] + fn client_ip_from_request_defaults_to_peer_and_ignores_forwarded_headers() { + let mut headers = HeaderMap::new(); + headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.113.9")); + headers.insert("x-real-ip", HeaderValue::from_static("203.0.113.10")); + assert_eq!( + client_ip_from_request(&headers, Some("198.51.100.7".parse().unwrap()), &[]), + Some("198.51.100.7".parse().unwrap()) + ); + } + + #[test] + fn client_ip_from_request_uses_forwarded_headers_from_trusted_proxy() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("198.51.100.77, 203.0.113.9, 192.0.2.10"), + ); + assert_eq!( + client_ip_from_request( + &headers, + Some("192.0.2.44".parse().unwrap()), + &[IpNet::parse("192.0.2.0/24").unwrap()], + ), + Some("203.0.113.9".parse().unwrap()) + ); + } + + #[test] + fn client_ip_from_request_accepts_ipv4_mapped_trusted_proxy_peer() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("198.51.100.77, 203.0.113.9, 192.0.2.10"), + ); + assert_eq!( + client_ip_from_request( + &headers, + Some("::ffff:192.0.2.44".parse().unwrap()), + &[IpNet::parse("192.0.2.0/24").unwrap()], + ), + Some("203.0.113.9".parse().unwrap()) + ); + } + + #[test] + fn malformed_forwarded_chain_falls_back_to_direct_peer() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("not-an-ip, 203.0.113.9"), + ); + headers.insert("x-real-ip", HeaderValue::from_static("198.51.100.88")); + assert_eq!( + client_ip_from_request( + &headers, + Some("192.0.2.44".parse().unwrap()), + &[IpNet::parse("192.0.2.0/24").unwrap()], + ), + Some("192.0.2.44".parse().unwrap()), + ); + } + + #[tokio::test] + async fn gateway_uses_forwarded_for_from_trusted_proxy_ranges() { + let app = build_app( + AppState::seeded(None) + .with_rate_limit(1, 60) + .with_trusted_proxies(vec![IpNet::parse("192.0.2.0/24").unwrap()]), + ); + + let first = app_request( + &app, + with_peer_ip( + Request::builder() + .method(Method::GET) + .uri("/gateway/demo") + .header("x-forwarded-for", "203.0.113.9") + .header("x-real-ip", "203.0.113.10") + .body(Body::empty()) + .unwrap(), + "192.0.2.44", + ), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let second = app_request( + &app, + with_peer_ip( + Request::builder() + .method(Method::GET) + .uri("/gateway/demo") + .header("x-forwarded-for", "198.51.100.7") + .header("x-real-ip", "198.51.100.8") + .body(Body::empty()) + .unwrap(), + "192.0.2.44", + ), + ) + .await; + assert_eq!(second.status(), StatusCode::OK); + } + async fn body_text(response: Response) -> String { let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); String::from_utf8(bytes.to_vec()).unwrap() @@ -4327,6 +4629,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -4475,12 +4778,15 @@ mod tests { .await; assert_eq!(saved_dnsbl.code, "127.0.0.9"); - let gateway_request = Request::builder() - .method(Method::POST) - .uri("/gateway/secure/login?q=DROP%20TABLE") - .header("x-forwarded-for", "198.51.100.7, 10.0.0.1") - .body(Body::from("payload")) - .unwrap(); + let gateway_request = with_peer_ip( + Request::builder() + .method(Method::POST) + .uri("/gateway/secure/login?q=DROP%20TABLE") + .header("x-forwarded-for", "198.51.100.7, 10.0.0.1") + .body(Body::from("payload")) + .unwrap(), + "198.51.100.7", + ); let response = app_request(&app, gateway_request).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(body_text(response).await.contains("\"action\":\"blocked\"")); @@ -4537,6 +4843,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -4605,6 +4912,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -6577,6 +6885,7 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.local".to_string(), event_limit: 20, + trusted_proxies: Vec::new(), }, ); let app = build_app(state); @@ -6659,6 +6968,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -6681,6 +6991,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -7267,6 +7578,7 @@ mod tests { state_path: None, dnsbl_origin: " . ".to_string(), event_limit: 0, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -7300,7 +7612,7 @@ mod tests { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-for", HeaderValue::from_static("not-an-ip")); - assert_eq!(client_ip_from_headers(&headers), None); + assert_eq!(client_ip_from_request(&headers, None, &[]), None); let valid_path = temp_state_path("valid-load"); fs::write( @@ -7314,6 +7626,7 @@ mod tests { state_path: Some(valid_path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -7340,6 +7653,7 @@ mod tests { state_path: Some(invalid_path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await; assert!(result.is_err()); @@ -7392,6 +7706,7 @@ mod tests { state_path: Some(read_only_file.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await; assert!( @@ -7412,6 +7727,7 @@ mod tests { state_path: Some(read_only_dir.join("state.json")), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await; assert!( @@ -7454,6 +7770,7 @@ mod tests { state_path: Some(failing_path.clone()), dnsbl_origin: "dnsbl.local".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }, ); let app = build_app(state); @@ -7573,6 +7890,7 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.example".to_string(), event_limit: 2, + trusted_proxies: Vec::new(), }, ); @@ -7623,6 +7941,7 @@ mod tests { state_path: Some(PathBuf::from("state.json")), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 25, + trusted_proxies: Vec::new(), }, ); diff --git a/src/runtime_config.rs b/src/runtime_config.rs new file mode 100644 index 0000000..227e624 --- /dev/null +++ b/src/runtime_config.rs @@ -0,0 +1,267 @@ +//! Bootstrap adapter for non-secret runtime configuration. +//! +//! Environment variables remain an outer delivery concern. Runtime code reads a +//! validated snapshot instead of scattering `std::env::var` across handlers and +//! gateway admission logic. + +use crate::{AppConfig, CRED_ADMIN_TOKEN, CredentialRegistry, IpNet, parse_trusted_proxies}; +#[cfg(test)] +use std::path::Path; +use std::path::PathBuf; + +/// Immutable bootstrap snapshot for non-secret Wardnet runtime settings. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeConfiguration { + /// Socket address the gateway binds during process startup. + pub bind_addr: String, + /// Optional standalone state file used by the gateway process. + pub state_path: Option, + /// DNSBL zone origin published by the gateway. + pub dnsbl_origin: String, + /// Maximum retained security-event count. + pub event_limit: usize, + /// Per-client request allowance for the local limiter; zero disables it. + pub rate_limit: u32, + /// Local limiter fixed-window duration in seconds. + pub rate_limit_window: u64, + /// Maximum accepted HTTP request body size in bytes. + pub max_body_bytes: usize, + /// Proxy source ranges that may assert forwarded client IP metadata. + pub trusted_proxies: Vec, +} + +impl RuntimeConfiguration { + /// Default loopback listener for standalone operation. + pub const DEFAULT_BIND_ADDR: &'static str = "127.0.0.1:8080"; + /// Default local limiter allowance; zero keeps rate limiting disabled. + pub const DEFAULT_RATE_LIMIT: u32 = 0; + /// Default local limiter fixed-window duration in seconds. + pub const DEFAULT_RATE_LIMIT_WINDOW: u64 = 60; + /// Default maximum accepted request body size in bytes. + pub const DEFAULT_MAX_BODY_BYTES: usize = 1_048_576; + + /// Load one validated runtime snapshot from process-edge bootstrap input. + pub fn from_env() -> Result> { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + /// Build one runtime snapshot from a caller-supplied bootstrap lookup seam. + fn from_lookup( + mut lookup: impl FnMut(&str) -> Option, + ) -> Result> { + let bind_addr = lookup("BIND_ADDR").unwrap_or_else(|| Self::DEFAULT_BIND_ADDR.to_string()); + let state_path = lookup("WAF_IDS_STATE_PATH").map(PathBuf::from); + let dnsbl_origin = + lookup("DNSBL_ORIGIN").unwrap_or_else(|| AppConfig::DEFAULT_DNSBL_ORIGIN.to_string()); + let event_limit_raw = lookup("EVENT_LIMIT"); + let rate_limit_raw = lookup("RATE_LIMIT"); + let rate_limit_window_raw = lookup("RATE_LIMIT_WINDOW"); + let max_body_bytes_raw = lookup("MAX_BODY_BYTES"); + let trusted_proxy_cidrs_raw = lookup("TRUSTED_PROXY_CIDRS"); + + Ok(Self { + bind_addr, + state_path, + dnsbl_origin, + event_limit: crate::parse_event_limit(event_limit_raw.as_deref())?, + rate_limit: crate::parse_u32_env( + "RATE_LIMIT", + rate_limit_raw.as_deref(), + Self::DEFAULT_RATE_LIMIT, + )?, + rate_limit_window: crate::parse_u64_env( + "RATE_LIMIT_WINDOW", + rate_limit_window_raw.as_deref(), + Self::DEFAULT_RATE_LIMIT_WINDOW, + )?, + max_body_bytes: crate::parse_u64_env( + "MAX_BODY_BYTES", + max_body_bytes_raw.as_deref(), + Self::DEFAULT_MAX_BODY_BYTES as u64, + )? as usize, + trusted_proxies: parse_trusted_proxies(trusted_proxy_cidrs_raw.as_deref()).map_err( + |message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message), + )?, + }) + } + + /// Derive the application config from the runtime snapshot and secret registry. + pub fn app_config(&self, credentials: &CredentialRegistry) -> AppConfig { + AppConfig { + admin_token: credentials + .get_credential(CRED_ADMIN_TOKEN) + .map(str::to_owned), + state_path: self.state_path.clone(), + dnsbl_origin: self.dnsbl_origin.clone(), + event_limit: self.event_limit, + trusted_proxies: self.trusted_proxies.clone(), + } + } +} + +#[cfg(test)] +/// Recursively report Rust source files that read runtime env outside bootstrap adapters. +fn direct_runtime_env_read_offenders(root: &Path) -> Vec { + /// Walk the source tree and collect files with direct env reads. + fn visit(root: &Path, current: &Path, offenders: &mut Vec) { + for entry in std::fs::read_dir(current).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(root, &path, offenders); + continue; + } + if path.extension().and_then(|ext| ext.to_str()) != Some("rs") { + continue; + } + let rel = path.strip_prefix(root).unwrap().to_path_buf(); + let source = std::fs::read_to_string(&path).unwrap(); + if (source.contains("std::env::var(") || source.contains("std::env::var_os(")) + && rel != Path::new("credentials.rs") + && rel != Path::new("runtime_config.rs") + { + offenders.push(rel); + } + } + } + + let mut offenders = Vec::new(); + visit(root, root, &mut offenders); + offenders.sort(); + offenders +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CRED_ADMIN_TOKENS; + use std::collections::HashMap; + + /// Build a runtime snapshot from deterministic in-memory key/value pairs. + fn runtime_from_pairs( + pairs: &[(&str, &str)], + ) -> Result> { + let values = pairs + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect::>(); + RuntimeConfiguration::from_lookup(|name| values.get(name).cloned()) + } + + #[test] + /// Defaults apply when no bootstrap values are supplied. + fn runtime_configuration_defaults_when_bootstrap_input_is_unset() { + let config = runtime_from_pairs(&[]).unwrap(); + assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); + assert_eq!(config.state_path, None); + assert_eq!(config.dnsbl_origin, AppConfig::DEFAULT_DNSBL_ORIGIN); + assert_eq!(config.event_limit, AppConfig::DEFAULT_EVENT_LIMIT); + assert_eq!(config.rate_limit, RuntimeConfiguration::DEFAULT_RATE_LIMIT); + assert_eq!( + config.rate_limit_window, + RuntimeConfiguration::DEFAULT_RATE_LIMIT_WINDOW + ); + assert_eq!( + config.max_body_bytes, + RuntimeConfiguration::DEFAULT_MAX_BODY_BYTES + ); + assert!(config.trusted_proxies.is_empty()); + } + + #[test] + /// The snapshot reads each non-secret runtime field exactly once. + fn runtime_configuration_reads_one_non_secret_bootstrap_snapshot() { + let config = runtime_from_pairs(&[ + ("BIND_ADDR", "127.0.0.1:9090"), + ("WAF_IDS_STATE_PATH", "/tmp/state.json"), + ("DNSBL_ORIGIN", "wardnet.example."), + ("EVENT_LIMIT", "25"), + ("RATE_LIMIT", "5"), + ("RATE_LIMIT_WINDOW", "30"), + ("MAX_BODY_BYTES", "4096"), + ("TRUSTED_PROXY_CIDRS", "192.0.2.0/24,2001:db8::/32"), + ]) + .unwrap(); + + assert_eq!(config.bind_addr, "127.0.0.1:9090"); + assert_eq!(config.state_path, Some(PathBuf::from("/tmp/state.json"))); + assert_eq!(config.dnsbl_origin, "wardnet.example."); + assert_eq!(config.event_limit, 25); + assert_eq!(config.rate_limit, 5); + assert_eq!(config.rate_limit_window, 30); + assert_eq!(config.max_body_bytes, 4096); + assert_eq!(config.trusted_proxies.len(), 2); + } + + #[test] + /// Runtime configuration excludes secret bootstrap selectors and values. + fn runtime_configuration_never_reads_secret_bootstrap_locator() { + let config = RuntimeConfiguration::from_lookup(|name| { + assert_ne!( + name, "WAF_IDS_CREDENTIALS_PATH", + "credential-file selection belongs exclusively to CredentialRegistry bootstrap" + ); + assert_ne!( + name, "ADMIN_TOKEN", + "admin secrets belong exclusively to CredentialRegistry bootstrap" + ); + assert_ne!( + name, "ADMIN_TOKENS", + "admin secrets belong exclusively to CredentialRegistry bootstrap" + ); + None + }) + .unwrap(); + assert_eq!(config.bind_addr, RuntimeConfiguration::DEFAULT_BIND_ADDR); + } + + #[test] + /// Malformed bounds and malformed trusted CIDRs fail closed at bootstrap. + fn runtime_configuration_rejects_malformed_bounds() { + assert!(runtime_from_pairs(&[("EVENT_LIMIT", "0")]).is_err()); + assert!(runtime_from_pairs(&[("RATE_LIMIT_WINDOW", "abc")]).is_err()); + assert!(runtime_from_pairs(&[("TRUSTED_PROXY_CIDRS", "192.0.2.0/40")]).is_err()); + } + + #[test] + /// AppConfig derives non-secret settings from runtime and secrets from the registry. + fn runtime_configuration_builds_app_config_from_registry() { + let runtime = RuntimeConfiguration { + bind_addr: RuntimeConfiguration::DEFAULT_BIND_ADDR.to_string(), + state_path: Some(PathBuf::from("state.json")), + dnsbl_origin: "dnsbl.example".to_string(), + event_limit: 42, + rate_limit: 7, + rate_limit_window: 90, + max_body_bytes: 1024, + trusted_proxies: vec![IpNet::parse("192.0.2.0/24").unwrap()], + }; + let credentials = CredentialRegistry::bootstrap_secrets( + None, + Some("secret".to_string()), + Some("tok:ops".to_string()), + ) + .unwrap(); + + let app = runtime.app_config(&credentials); + assert_eq!(app.admin_token.as_deref(), Some("secret")); + assert_eq!(app.state_path, Some(PathBuf::from("state.json"))); + assert_eq!(app.dnsbl_origin, "dnsbl.example"); + assert_eq!(app.event_limit, 42); + assert_eq!(app.trusted_proxies.len(), 1); + assert_eq!( + credentials.get_credential(CRED_ADMIN_TOKENS), + Some("tok:ops") + ); + } + + #[test] + /// Source scans reject new direct env reads outside the approved bootstrap modules. + fn runtime_env_reads_stay_in_bootstrap_adapters_recursively() { + let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); + let offenders = direct_runtime_env_read_offenders(&src_dir); + assert!( + offenders.is_empty(), + "direct runtime env reads escaped bootstrap adapters: {offenders:?}" + ); + } +} diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs new file mode 100644 index 0000000..2638399 --- /dev/null +++ b/tests/trusted_forwarded_fail_closed.rs @@ -0,0 +1,83 @@ +//! Hostile regression coverage for trusted forwarding metadata. +//! +//! A trusted proxy may supply client identity only when the entire +//! `X-Forwarded-For` chain parses cleanly. Any malformed hop invalidates the +//! header as attribution evidence, and Wardnet must fall back to the direct +//! peer without trusting `X-Real-IP`. + +use std::net::IpAddr; +use waf_ids_ai_soc::{IpNet, effective_client_ip}; + +/// Parse one test IP literal. +fn ip(value: &str) -> IpAddr { + value.parse().expect("test IP must parse") +} + +/// Shared trusted proxy range for forwarded-header regression tests. +fn trusted_proxy_range() -> Vec { + vec![IpNet::parse("192.0.2.0/24").expect("test CIDR must parse")] +} + +#[test] +/// Any malformed hop invalidates the entire forwarded chain. +fn malformed_middle_hop_falls_back_to_direct_peer() { + let direct_peer = ip("192.0.2.44"); + let resolved = effective_client_ip( + Some(direct_peer), + Some("198.51.100.77, bad-ip, 192.0.2.10"), + Some("203.0.113.99"), + &trusted_proxy_range(), + ); + + assert_eq!(resolved, Some(direct_peer)); +} + +#[test] +/// Empty hops are treated as malformed forwarding metadata. +fn empty_middle_hop_falls_back_to_direct_peer() { + let direct_peer = ip("192.0.2.44"); + let resolved = effective_client_ip( + Some(direct_peer), + Some("198.51.100.77, , 192.0.2.10"), + Some("203.0.113.99"), + &trusted_proxy_range(), + ); + + assert_eq!(resolved, Some(direct_peer)); +} + +#[test] +/// A valid chain resolves to the client nearest the trust boundary. +fn valid_chain_selects_rightmost_untrusted_hop() { + let resolved = effective_client_ip( + Some(ip("192.0.2.44")), + Some("198.51.100.77, 203.0.113.9, 192.0.2.10"), + Some("203.0.113.99"), + &trusted_proxy_range(), + ); + + assert_eq!(resolved, Some(ip("203.0.113.9"))); +} + +#[test] +/// Non-canonical mapped IPv6 CIDRs must be rewritten or rejected. +fn noncanonical_ipv4_mapped_cidr_configuration_fails_closed() { + let error = IpNet::parse("::ffff:192.0.2.0/120") + .expect_err("mapped IPv6 CIDR syntax must be rewritten as canonical IPv4 CIDR"); + + assert!(error.contains("prefix 120 is too large")); + assert!(IpNet::parse("192.0.2.0/24").is_ok()); +} + +#[test] +/// Prefix lengths above the address-family bound fail closed. +fn out_of_range_ipv6_prefixes_fail_closed() { + for value in ["2001:db8::/129", "::ffff:192.0.2.77/129"] { + let error = IpNet::parse(value) + .expect_err("an IPv6 prefix above 128 must not be silently reinterpreted"); + assert!( + error.contains("prefix 129 is too large"), + "unexpected error for {value}: {error}" + ); + } +}