From 6e2e5eedaf7bb74449e5db0f0d71d8f68b14186e Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 4 Sep 2026 20:07:27 +0900 Subject: [PATCH 1/9] feat(gateway): trust forwarded client IPs only from trusted proxies --- CHANGELOG.md | 1 + docs/runbooks/operations.md | 17 ++ src/lib.rs | 402 ++++++++++++++++++++++--- tests/trusted_forwarded_fail_closed.rs | 55 ++++ 4 files changed, 435 insertions(+), 40 deletions(-) create mode 100644 tests/trusted_forwarded_fail_closed.rs 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/runbooks/operations.md b/docs/runbooks/operations.md index 9b6b701..87a4a68 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -53,6 +53,23 @@ 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`. + ## Health Check ```bash diff --git a/src/lib.rs b/src/lib.rs index ab902ca..e95023e 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}, @@ -66,6 +66,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 +139,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 +198,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 +301,7 @@ pub struct AppConfig { pub state_path: Option, pub dnsbl_origin: String, pub event_limit: usize, + pub trusted_proxies: Vec, } impl AppConfig { @@ -305,10 +314,99 @@ 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)), + } +} + +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 { match fs::read_to_string(path).await { Ok(content) => serde_json::from_str(&content) @@ -2319,13 +2417,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 +2447,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 +2544,67 @@ 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()) +fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +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), + } +} + +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, + ) +} + +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)))) +} + +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( @@ -3295,6 +3448,10 @@ pub async fn run_from_env( 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())?, + trusted_proxies: parse_trusted_proxies( + std::env::var("TRUSTED_PROXY_CIDRS").ok().as_deref(), + ) + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?, }; let rate_limit = parse_u32_env("RATE_LIMIT", std::env::var("RATE_LIMIT").ok().as_deref(), 0)?; let rate_limit_window = parse_u64_env( @@ -3325,9 +3482,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 +3528,7 @@ mod tests { "RATE_LIMIT", "RATE_LIMIT_WINDOW", "MAX_BODY_BYTES", + "TRUSTED_PROXY_CIDRS", ] { unsafe { std::env::remove_var(name) }; } @@ -3401,6 +3562,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 +3620,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 +3829,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 +3869,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 +4632,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 +4781,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 +4846,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 +4915,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 +6888,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 +6971,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 +6994,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 +7581,7 @@ mod tests { state_path: None, dnsbl_origin: " . ".to_string(), event_limit: 0, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -7300,7 +7615,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 +7629,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 +7656,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 +7709,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 +7730,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 +7773,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 +7893,7 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.example".to_string(), event_limit: 2, + trusted_proxies: Vec::new(), }, ); @@ -7623,6 +7944,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/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs new file mode 100644 index 0000000..3b79613 --- /dev/null +++ b/tests/trusted_forwarded_fail_closed.rs @@ -0,0 +1,55 @@ +//! 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}; + +fn ip(value: &str) -> IpAddr { + value.parse().expect("test IP must parse") +} + +fn trusted_proxy_range() -> Vec { + vec![IpNet::parse("192.0.2.0/24").expect("test CIDR must parse")] +} + +#[test] +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] +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] +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"))); +} From 3e75535fe64c1b08cda170c0b9e11e643394d2de Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Fri, 4 Sep 2026 21:18:13 +0900 Subject: [PATCH 2/9] refactor(gateway): bootstrap trusted proxy policy --- ...ted-proxy-client-ip-attribution-sources.md | 31 +++ docs/runbooks/operations.md | 19 ++ src/credentials.rs | 19 +- src/lib.rs | 69 +++-- src/runtime_config.rs | 257 ++++++++++++++++++ 5 files changed, 358 insertions(+), 37 deletions(-) create mode 100644 docs/papers/trusted-proxy-client-ip-attribution-sources.md create mode 100644 src/runtime_config.rs 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..0f0da8a --- /dev/null +++ b/docs/papers/trusted-proxy-client-ip-attribution-sources.md @@ -0,0 +1,31 @@ +# Trusted proxy client IP attribution sources + +This note records the operational sources that ground Wardnet's trusted-proxy +client IP attribution behavior. The current implementation anchors trust in the +direct peer, then evaluates forwarded metadata only when that peer belongs to a +configured trusted range. + +## Source summary + +The sources below converge on the same boundary. RFC 7239 defines forwarding +metadata as proxy-supplied information, not client truth. NGINX documents the +trusted-proxy rule explicitly and resolves recursive chains to the last +non-trusted hop. Envoy documents the same right-to-left trust model for +`X-Forwarded-For` and trusted CIDR lists. + +## 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 + +## Redistribution note + +These sources are publicly linkable. This repository currently stores the URLs +and summaries instead of vendoring local PDFs because the authoritative source +formats for these documents are HTML pages rather than project-hosted PDF +artifacts. diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 87a4a68..61183c6 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -70,6 +70,25 @@ 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 e95023e..9ed3493 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 { @@ -392,7 +394,8 @@ fn normalize_ip(ip: IpAddr) -> IpAddr { } } -fn parse_trusted_proxies(raw: Option<&str>) -> Result, String> { +/// 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()); }; @@ -2544,10 +2547,25 @@ async fn gateway(State(state): State, request: Request) -> Response { } } +/// 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>, @@ -2566,6 +2584,7 @@ pub fn effective_client_ip( } } +/// Resolve client IP headers from the request's direct peer and trusted ranges. fn client_ip_from_request( headers: &HeaderMap, peer_ip: Option, @@ -2579,6 +2598,11 @@ fn client_ip_from_request( ) } +/// 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], @@ -2601,6 +2625,7 @@ fn trusted_forwarded_chain_client_ip_checked( .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()) @@ -3429,46 +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())?, - trusted_proxies: parse_trusted_proxies( - std::env::var("TRUSTED_PROXY_CIDRS").ok().as_deref(), - ) - .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?, - }; - 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}"); diff --git a/src/runtime_config.rs b/src/runtime_config.rs new file mode 100644 index 0000000..77e5d4c --- /dev/null +++ b/src/runtime_config.rs @@ -0,0 +1,257 @@ +//! 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()) + } + + 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)] +fn direct_runtime_env_read_offenders(root: &Path) -> Vec { + 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; + + 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] + 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] + 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] + 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] + 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] + 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] + 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:?}" + ); + } +} From ae26fe5e6d5aeb0f0d47f633616bd326de19724f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:08:10 +0900 Subject: [PATCH 3/9] test(gateway): reproduce mapped IPv6 trusted CIDR prefix --- tests/trusted_forwarded_fail_closed.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs index 3b79613..b341ec1 100644 --- a/tests/trusted_forwarded_fail_closed.rs +++ b/tests/trusted_forwarded_fail_closed.rs @@ -53,3 +53,26 @@ fn valid_chain_selects_rightmost_untrusted_hop() { assert_eq!(resolved, Some(ip("203.0.113.9"))); } + +#[test] +fn mapped_ipv6_trusted_cidr_matches_equivalent_ipv4_range() { + let mapped_range = vec![IpNet::parse("::ffff:192.0.2.0/120") + .expect("IPv4-mapped IPv6 /120 must normalize to IPv4 /24")]; + let forwarded = "198.51.100.77, 192.0.2.10"; + + let mapped_result = effective_client_ip( + Some(ip("::ffff:192.0.2.44")), + Some(forwarded), + None, + &mapped_range, + ); + let ipv4_result = effective_client_ip( + Some(ip("192.0.2.44")), + Some(forwarded), + None, + &trusted_proxy_range(), + ); + + assert_eq!(mapped_result, Some(ip("198.51.100.77"))); + assert_eq!(mapped_result, ipv4_result); +} From c6c4819abc7c7bd75247cced463aa2a1017df647 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:09:09 +0900 Subject: [PATCH 4/9] test(gateway): keep mapped CIDR configuration fail closed --- tests/trusted_forwarded_fail_closed.rs | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs index b341ec1..0129c48 100644 --- a/tests/trusted_forwarded_fail_closed.rs +++ b/tests/trusted_forwarded_fail_closed.rs @@ -55,24 +55,10 @@ fn valid_chain_selects_rightmost_untrusted_hop() { } #[test] -fn mapped_ipv6_trusted_cidr_matches_equivalent_ipv4_range() { - let mapped_range = vec![IpNet::parse("::ffff:192.0.2.0/120") - .expect("IPv4-mapped IPv6 /120 must normalize to IPv4 /24")]; - let forwarded = "198.51.100.77, 192.0.2.10"; +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"); - let mapped_result = effective_client_ip( - Some(ip("::ffff:192.0.2.44")), - Some(forwarded), - None, - &mapped_range, - ); - let ipv4_result = effective_client_ip( - Some(ip("192.0.2.44")), - Some(forwarded), - None, - &trusted_proxy_range(), - ); - - assert_eq!(mapped_result, Some(ip("198.51.100.77"))); - assert_eq!(mapped_result, ipv4_result); + assert!(error.contains("prefix 120 is too large")); + assert!(IpNet::parse("192.0.2.0/24").is_ok()); } From 3415b748bdf8c6ccd112f215b28cdc116895b861 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 4 Sep 2026 22:10:57 +0900 Subject: [PATCH 5/9] docs(security): add peer-reviewed proxy trust evidence --- ...ted-proxy-client-ip-attribution-sources.md | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/docs/papers/trusted-proxy-client-ip-attribution-sources.md b/docs/papers/trusted-proxy-client-ip-attribution-sources.md index 0f0da8a..b7b325f 100644 --- a/docs/papers/trusted-proxy-client-ip-attribution-sources.md +++ b/docs/papers/trusted-proxy-client-ip-attribution-sources.md @@ -1,17 +1,29 @@ # Trusted proxy client IP attribution sources -This note records the operational sources that ground Wardnet's trusted-proxy -client IP attribution behavior. The current implementation anchors trust in the -direct peer, then evaluates forwarded metadata only when that peer belongs to a +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. -## Source summary +## Evidence synthesis -The sources below converge on the same boundary. RFC 7239 defines forwarding -metadata as proxy-supplied information, not client truth. NGINX documents the -trusted-proxy rule explicitly and resolves recursive chains to the last -non-trusted hop. Envoy documents the same right-to-left trust model for -`X-Forwarded-For` and trusted CIDR lists. +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 @@ -22,10 +34,17 @@ non-trusted hop. Envoy documents the same right-to-left trust model for 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 -These sources are publicly linkable. This repository currently stores the URLs -and summaries instead of vendoring local PDFs because the authoritative source -formats for these documents are HTML pages rather than project-hosted PDF -artifacts. +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. From 77717acee60da56a7cc135b604f52d5e1cd1fd63 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sat, 5 Sep 2026 02:07:09 +0900 Subject: [PATCH 6/9] fix(gateway): canonicalize mapped trusted-proxy CIDRs --- src/lib.rs | 60 ++++++++++++++++++++------ tests/trusted_forwarded_fail_closed.rs | 10 ++--- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 9ed3493..16dbb2a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -336,21 +336,12 @@ impl IpNet { } 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 original_addr = parse_ip(addr)?; 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) + canonicalize_trusted_proxy_net(original_addr, prefix_len, raw)? } None => { let addr = parse_ip_with_mapped_ipv4(raw)?; @@ -377,13 +368,48 @@ impl std::str::FromStr for IpNet { } } -fn parse_ip_with_mapped_ipv4(raw: &str) -> Result { +/// Parse one trusted-proxy address without normalizing IPv4-mapped IPv6 input. +fn parse_ip(raw: &str) -> Result { raw.trim() .parse::() - .map(normalize_ip) .map_err(|error| format!("invalid trusted proxy address {raw:?}: {error}")) } +fn parse_ip_with_mapped_ipv4(raw: &str) -> Result { + parse_ip(raw).map(normalize_ip) +} + +/// Canonicalize CIDR input so IPv4-mapped IPv6 networks become equivalent IPv4 +/// networks. Prefixes narrower than `/96` span non-mapped IPv6 space and are +/// rejected. +fn canonicalize_trusted_proxy_net( + original_addr: IpAddr, + prefix_len: u8, + raw: &str, +) -> Result<(IpAddr, u8), String> { + match original_addr { + IpAddr::V4(addr) => { + if prefix_len > 32 { + return Err(format!( + "trusted proxy prefix {prefix_len} is too large for {raw:?}" + )); + } + Ok((IpAddr::V4(addr), prefix_len)) + } + IpAddr::V6(addr) => { + if let Some(mapped) = addr.to_ipv4_mapped() { + if prefix_len < 96 { + return Err(format!( + "trusted proxy prefix {prefix_len} is too small for IPv4-mapped CIDR {raw:?}" + )); + } + return Ok((IpAddr::V4(mapped), prefix_len - 96)); + } + Ok((IpAddr::V6(addr), prefix_len)) + } + } +} + fn normalize_ip(ip: IpAddr) -> IpAddr { match ip { IpAddr::V4(ipv4) => IpAddr::V4(ipv4), @@ -3575,6 +3601,14 @@ mod tests { assert!(parse_trusted_proxies(Some("bad-ip")).is_err()); } + #[test] + fn parse_trusted_proxies_accepts_ipv4_mapped_ipv6_cidrs() { + let parsed = parse_trusted_proxies(Some("::ffff:192.0.2.0/120")).unwrap(); + assert_eq!(parsed.len(), 1); + assert!(parsed[0].contains("192.0.2.77".parse().unwrap())); + assert!(!parsed[0].contains("198.51.100.7".parse().unwrap())); + } + #[test] fn parses_and_limits_phishing_database_feeds() { let domains = parse_phishing_domains( diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs index 0129c48..627b69b 100644 --- a/tests/trusted_forwarded_fail_closed.rs +++ b/tests/trusted_forwarded_fail_closed.rs @@ -55,10 +55,10 @@ fn valid_chain_selects_rightmost_untrusted_hop() { } #[test] -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"); +fn ipv4_mapped_cidr_configuration_is_canonicalized() { + let mapped = + IpNet::parse("::ffff:192.0.2.0/120").expect("mapped IPv6 CIDR should canonicalize"); + let canonical = IpNet::parse("192.0.2.0/24").expect("canonical IPv4 CIDR should parse"); - assert!(error.contains("prefix 120 is too large")); - assert!(IpNet::parse("192.0.2.0/24").is_ok()); + assert_eq!(mapped, canonical); } From 29373abea1a2407370c0d9f5c5a3788a651adb2f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:10:17 +0900 Subject: [PATCH 7/9] test(gateway): reject out-of-range trusted-proxy prefixes --- tests/trusted_forwarded_fail_closed.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs index 627b69b..673d082 100644 --- a/tests/trusted_forwarded_fail_closed.rs +++ b/tests/trusted_forwarded_fail_closed.rs @@ -62,3 +62,15 @@ fn ipv4_mapped_cidr_configuration_is_canonicalized() { assert_eq!(mapped, canonical); } + +#[test] +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}" + ); + } +} From 4b96f4e3b5da8d3bb727ad45c4d2954770ec420c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 04:14:22 +0900 Subject: [PATCH 8/9] fix(gateway): restore fail-closed trusted-proxy CIDR grammar --- src/lib.rs | 60 ++++++-------------------- tests/trusted_forwarded_fail_closed.rs | 10 ++--- 2 files changed, 18 insertions(+), 52 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 16dbb2a..9ed3493 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -336,12 +336,21 @@ impl IpNet { } let (addr, prefix_len) = match raw.split_once('/') { Some((addr, prefix)) => { - let original_addr = parse_ip(addr)?; + 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}"))?; - canonicalize_trusted_proxy_net(original_addr, prefix_len, raw)? + 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)?; @@ -368,48 +377,13 @@ impl std::str::FromStr for IpNet { } } -/// Parse one trusted-proxy address without normalizing IPv4-mapped IPv6 input. -fn parse_ip(raw: &str) -> Result { +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 parse_ip_with_mapped_ipv4(raw: &str) -> Result { - parse_ip(raw).map(normalize_ip) -} - -/// Canonicalize CIDR input so IPv4-mapped IPv6 networks become equivalent IPv4 -/// networks. Prefixes narrower than `/96` span non-mapped IPv6 space and are -/// rejected. -fn canonicalize_trusted_proxy_net( - original_addr: IpAddr, - prefix_len: u8, - raw: &str, -) -> Result<(IpAddr, u8), String> { - match original_addr { - IpAddr::V4(addr) => { - if prefix_len > 32 { - return Err(format!( - "trusted proxy prefix {prefix_len} is too large for {raw:?}" - )); - } - Ok((IpAddr::V4(addr), prefix_len)) - } - IpAddr::V6(addr) => { - if let Some(mapped) = addr.to_ipv4_mapped() { - if prefix_len < 96 { - return Err(format!( - "trusted proxy prefix {prefix_len} is too small for IPv4-mapped CIDR {raw:?}" - )); - } - return Ok((IpAddr::V4(mapped), prefix_len - 96)); - } - Ok((IpAddr::V6(addr), prefix_len)) - } - } -} - fn normalize_ip(ip: IpAddr) -> IpAddr { match ip { IpAddr::V4(ipv4) => IpAddr::V4(ipv4), @@ -3601,14 +3575,6 @@ mod tests { assert!(parse_trusted_proxies(Some("bad-ip")).is_err()); } - #[test] - fn parse_trusted_proxies_accepts_ipv4_mapped_ipv6_cidrs() { - let parsed = parse_trusted_proxies(Some("::ffff:192.0.2.0/120")).unwrap(); - assert_eq!(parsed.len(), 1); - assert!(parsed[0].contains("192.0.2.77".parse().unwrap())); - assert!(!parsed[0].contains("198.51.100.7".parse().unwrap())); - } - #[test] fn parses_and_limits_phishing_database_feeds() { let domains = parse_phishing_domains( diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs index 673d082..41cb3ed 100644 --- a/tests/trusted_forwarded_fail_closed.rs +++ b/tests/trusted_forwarded_fail_closed.rs @@ -55,12 +55,12 @@ fn valid_chain_selects_rightmost_untrusted_hop() { } #[test] -fn ipv4_mapped_cidr_configuration_is_canonicalized() { - let mapped = - IpNet::parse("::ffff:192.0.2.0/120").expect("mapped IPv6 CIDR should canonicalize"); - let canonical = IpNet::parse("192.0.2.0/24").expect("canonical IPv4 CIDR should parse"); +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_eq!(mapped, canonical); + assert!(error.contains("prefix 120 is too large")); + assert!(IpNet::parse("192.0.2.0/24").is_ok()); } #[test] From 99581e056645e98e866157443a6732a4bbd729c4 Mon Sep 17 00:00:00 2001 From: OpenAI Codex Date: Sat, 5 Sep 2026 06:56:21 +0900 Subject: [PATCH 9/9] docs: raise trusted-proxy doc coverage --- src/runtime_config.rs | 10 ++++++++++ tests/trusted_forwarded_fail_closed.rs | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/src/runtime_config.rs b/src/runtime_config.rs index 77e5d4c..227e624 100644 --- a/src/runtime_config.rs +++ b/src/runtime_config.rs @@ -45,6 +45,7 @@ impl RuntimeConfiguration { 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> { @@ -99,7 +100,9 @@ impl RuntimeConfiguration { } #[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(); @@ -133,6 +136,7 @@ mod tests { 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> { @@ -144,6 +148,7 @@ mod tests { } #[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); @@ -163,6 +168,7 @@ mod tests { } #[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"), @@ -187,6 +193,7 @@ mod tests { } #[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!( @@ -208,6 +215,7 @@ mod tests { } #[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()); @@ -215,6 +223,7 @@ mod tests { } #[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(), @@ -246,6 +255,7 @@ mod tests { } #[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); diff --git a/tests/trusted_forwarded_fail_closed.rs b/tests/trusted_forwarded_fail_closed.rs index 41cb3ed..2638399 100644 --- a/tests/trusted_forwarded_fail_closed.rs +++ b/tests/trusted_forwarded_fail_closed.rs @@ -8,15 +8,18 @@ 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( @@ -30,6 +33,7 @@ fn malformed_middle_hop_falls_back_to_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( @@ -43,6 +47,7 @@ fn empty_middle_hop_falls_back_to_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")), @@ -55,6 +60,7 @@ fn valid_chain_selects_rightmost_untrusted_hop() { } #[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"); @@ -64,6 +70,7 @@ fn noncanonical_ipv4_mapped_cidr_configuration_fails_closed() { } #[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)