From aa548076fba4c122de50f8821e880e222fedf5fb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:06:57 +0900 Subject: [PATCH 1/5] test(ci): require explicit hosted runner image --- tests/workflow_runner_contract.rs | 39 +++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/workflow_runner_contract.rs diff --git a/tests/workflow_runner_contract.rs b/tests/workflow_runner_contract.rs new file mode 100644 index 00000000..f63eee0a --- /dev/null +++ b/tests/workflow_runner_contract.rs @@ -0,0 +1,39 @@ +//! Repository contract for deterministic GitHub-hosted runner selection. +//! +//! Wardnet's required pull-request workflows must not depend on GitHub's floating +//! `ubuntu-latest` alias. A floating image can change independently of the +//! repository and, during hosted-runner transitions, can leave exact-head jobs +//! queued before checkout. Pinning the Ubuntu image makes runner acquisition a +//! reviewed repository change while preserving GitHub-hosted execution. + +use std::fs; +use std::path::Path; + +const PINNED_UBUNTU_RUNNER: &str = "ubuntu-24.04"; +const FLOATING_UBUNTU_RUNNER: &str = "ubuntu-latest"; + +const RUNNER_BACKED_WORKFLOWS: &[&str] = &[ + ".github/workflows/ci.yml", + ".github/workflows/fuzz.yml", + ".github/workflows/scorecard-analysis.yml", +]; + +#[test] +fn runner_backed_workflows_pin_the_hosted_ubuntu_image() { + let repository = Path::new(env!("CARGO_MANIFEST_DIR")); + + for relative in RUNNER_BACKED_WORKFLOWS { + let path = repository.join(relative); + let workflow = fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + + assert!( + !workflow.contains(FLOATING_UBUNTU_RUNNER), + "{relative} must not use the floating {FLOATING_UBUNTU_RUNNER} runner alias" + ); + assert!( + workflow.contains(&format!("runs-on: {PINNED_UBUNTU_RUNNER}")), + "{relative} must pin runner-backed jobs to {PINNED_UBUNTU_RUNNER}" + ); + } +} From 2770b57abebacae00b8624ba4213cdc032d9a9c8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:11 +0900 Subject: [PATCH 2/5] fix(ci): pin hosted Ubuntu runner --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df092755..9409a898 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: rust: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable From 33ebae0208382f7e6582bd88188050d42b5ddbcf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:28 +0900 Subject: [PATCH 3/5] fix(ci): pin fuzz runner image --- .github/workflows/fuzz.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index ebcb3ce9..b2a5366d 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -23,7 +23,7 @@ concurrency: jobs: fuzz: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: From 2d41c4079f9a4465c3142a0aa2dd5895cb11f793 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 02:07:41 +0900 Subject: [PATCH 4/5] fix(ci): pin scorecard runner image --- .github/workflows/scorecard-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index dfa64206..2d147bee 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -13,7 +13,7 @@ permissions: jobs: analysis: name: Scorecard Analysis - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read security-events: write From ced58fe17e4fda2d014bd9d18d2008fde93c1dca Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 2 Sep 2026 03:11:17 +0900 Subject: [PATCH 5/5] feat(gateway): trust forwarded IPs only from trusted proxies (#131) * feat(gateway): trust forwarded IPs only from trusted proxies * fix(gateway): harden trusted proxy attribution * test(gateway): fuzz trusted forwarded client attribution * fix(gateway): accept mapped trusted proxy peers * test(gateway): align mapped proxy trust invariants * docs(gateway): clarify trusted proxy runtime contract * fix(gateway): reject invalid mapped proxy cidrs * fix(gateway): keep admin credential provenance accurate * test(gateway): run trusted-proxy fuzz target in CI * style: apply rustfmt to credential regression --------- Co-authored-by: OpenAI Codex --- .github/workflows/fuzz.yml | 1 + docs/runbooks/operations.md | 26 + fuzz/Cargo.toml | 7 + .../fuzz_trusted_forwarded_client_ip.rs | 150 ++++ src/credentials.rs | 138 +++- src/lib.rs | 725 ++++++++++++++++-- src/main.rs | 117 ++- tests/fuzz_invariants.rs | 114 ++- 8 files changed, 1172 insertions(+), 106 deletions(-) create mode 100644 fuzz/fuzz_targets/fuzz_trusted_forwarded_client_ip.rs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index b2a5366d..29870062 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -32,6 +32,7 @@ jobs: - fuzz_appdata_json - fuzz_parse_admin_tokens - fuzz_dnsbl_zone + - fuzz_trusted_forwarded_client_ip steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 9b6b7015..1c6b7738 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -53,6 +53,32 @@ cargo run Health reports `credentials_source` (`file` / `env` / `none`) and `admin_auth_configured` (boolean) without exposing secret values. +### Trusted proxy client IP attribution + +Wardnet now treats forwarded client IP headers as 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 in that allowlist, Wardnet honors the first `X-Forwarded-For` +chain element that is not itself another trusted proxy, scanning the chain from +right to left, and falls back to `X-Real-IP` only from that trusted proxy +context. Trusted ingress proxies must normalize inbound forwarding headers +before appending their own hop so attacker-supplied leading values cannot +survive unchanged. If no trusted proxy range is configured, spoofed forwarded +headers are ignored. + +Operational references: + +- Petersson, A., & Nilsson, M. (2014). *Forwarded HTTP Extension* (RFC 7239). IETF. https://www.rfc-editor.org/info/rfc7239 + This standard defines proxy-disclosed client/address chain metadata and warns that forwarded headers cannot be assumed correct without trusted intermediary policy. +- MDN contributors. (2025, July 4). *Forwarded header*. MDN Web Docs. https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Forwarded + MDN documents the comma-appended proxy chain model and the de facto relationship between `Forwarded` and `X-Forwarded-For`, which is the operational shape Wardnet validates here. + ## Health Check ```bash diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index efa8fb36..86ae041d 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -53,6 +53,13 @@ test = false doc = false bench = false +[[bin]] +name = "fuzz_trusted_forwarded_client_ip" +path = "fuzz_targets/fuzz_trusted_forwarded_client_ip.rs" +test = false +doc = false +bench = false + # Empty table => this crate is its own workspace root, isolated from the # repository's primary workspace. Do not remove. [workspace] diff --git a/fuzz/fuzz_targets/fuzz_trusted_forwarded_client_ip.rs b/fuzz/fuzz_targets/fuzz_trusted_forwarded_client_ip.rs new file mode 100644 index 00000000..5db1dd5e --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_trusted_forwarded_client_ip.rs @@ -0,0 +1,150 @@ +#![no_main] +//! Fuzz trusted client-IP attribution for forwarded proxy headers. +//! +//! `effective_client_ip` is a trust-boundary parser: it decides whether +//! attacker-controlled forwarding headers can influence rate limiting, DNSBL +//! checks, and audit/event attribution. Arbitrary chains, invalid hops, IPv4, +//! IPv6, trusted peers, and untrusted peers must never panic, and the trusted +//! peer path must match the documented right-to-left selection rule. + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use waf_ids_ai_soc::{IpNet, effective_client_ip}; + +#[derive(Arbitrary, Debug, Clone)] +enum AnyIp { + V4(u32), + V6(u128), +} + +impl AnyIp { + fn into_ip(self) -> IpAddr { + match self { + Self::V4(raw) => IpAddr::V4(Ipv4Addr::from(raw)), + Self::V6(raw) => IpAddr::V6(Ipv6Addr::from(raw)), + } + } +} + +fn normalized_ip(addr: IpAddr) -> IpAddr { + match addr { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + IpAddr::V4(ip) => IpAddr::V4(ip), + } +} + +fn is_trusted_single_host(ip: IpAddr, trusted_proxy_ip: IpAddr) -> bool { + normalized_ip(ip) == normalized_ip(trusted_proxy_ip) +} + +#[derive(Arbitrary, Debug)] +enum Hop { + Ip(AnyIp), + Invalid(String), + Empty, +} + +impl Hop { + fn into_text(self) -> String { + match self { + Self::Ip(ip) => ip.into_ip().to_string(), + Self::Invalid(raw) => raw, + Self::Empty => " ".to_string(), + } + } +} + +#[derive(Arbitrary, Debug)] +struct Input { + trusted_proxy: AnyIp, + peer_ip: Option, + trust_peer: bool, + forwarded_hops: Vec, + x_real_ip: Option, +} + +fn expected_client_ip( + peer_ip: Option, + x_forwarded_for: Option<&str>, + x_real_ip: Option<&str>, + trusted_proxy_ip: IpAddr, + trust_peer: bool, +) -> Option { + let peer_ip = match (peer_ip, trust_peer) { + (Some(_), false) => peer_ip, + (Some(peer_ip), true) => Some(peer_ip), + (None, _) => return None, + }?; + + if !trust_peer { + return Some(peer_ip); + } + + if let Some(forwarded) = x_forwarded_for { + for hop in forwarded.split(',').rev() { + let hop = hop.trim(); + if hop.is_empty() { + continue; + } + let Ok(ip) = hop.parse::() else { + continue; + }; + if is_trusted_single_host(ip, trusted_proxy_ip) { + continue; + } + return Some(ip); + } + } + + x_real_ip + .and_then(|value| value.trim().parse::().ok()) + .or(Some(peer_ip)) +} + +fuzz_target!(|input: Input| { + let trusted_proxy_ip = input.trusted_proxy.clone().into_ip(); + let peer_ip = input.peer_ip.map(AnyIp::into_ip); + let trusted_cidr = match trusted_proxy_ip { + IpAddr::V4(ip) => format!("{ip}/32"), + IpAddr::V6(ip) => format!("{ip}/128"), + }; + let trusted_proxy = IpNet::parse(&trusted_cidr).expect("single-host CIDR must parse"); + let trusted_proxies = vec![trusted_proxy.clone()]; + let peer_ip = if input.trust_peer && peer_ip.is_some() { + Some(trusted_proxy_ip) + } else { + peer_ip + }; + let trust_peer = peer_ip + .map(|peer_ip| is_trusted_single_host(peer_ip, trusted_proxy_ip)) + .unwrap_or(false); + let x_forwarded_for = if input.forwarded_hops.is_empty() { + None + } else { + Some( + input + .forwarded_hops + .into_iter() + .map(Hop::into_text) + .collect::>() + .join(","), + ) + }; + let x_real_ip = input.x_real_ip.map(Hop::into_text); + let resolved = effective_client_ip(peer_ip, x_forwarded_for.as_deref(), x_real_ip.as_deref(), &trusted_proxies); + let expected = expected_client_ip( + peer_ip, + x_forwarded_for.as_deref(), + x_real_ip.as_deref(), + trusted_proxy_ip, + trust_peer, + ); + assert_eq!( + resolved, expected, + "trusted client attribution must match the right-to-left trust model" + ); +}); diff --git a/src/credentials.rs b/src/credentials.rs index bcc07e50..d2446131 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -2,9 +2,9 @@ //! credential registry. //! //! Org guidance: runtime code must not treat raw environment variables as the -//! source of secrets. Environment (and optional credentials file) are bootstrap -//! transports that seed this registry; handlers and auth checks read through -//! [`CredentialRegistry::get_credential`]. +//! source of runtime secrets. Environment (and optional credentials file) are +//! bootstrap transports that seed this registry; handlers and auth checks read +//! through [`CredentialRegistry::get_credential`]. use serde::{Deserialize, Serialize}; use std::{collections::HashMap, io::ErrorKind, path::Path}; @@ -12,6 +12,7 @@ use std::{collections::HashMap, io::ErrorKind, path::Path}; /// Well-known credentials loaded into the registry at bootstrap. pub const CRED_ADMIN_TOKEN: &str = "admin_token"; pub const CRED_ADMIN_TOKENS: &str = "admin_tokens"; +pub const CRED_TRUSTED_PROXY_CIDRS: &str = "trusted_proxy_cidrs"; /// Where secret-bearing credentials were loaded from (never includes values). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -27,6 +28,7 @@ pub enum CredentialSource { } impl CredentialSource { + /// Stable string label for non-secret source reporting surfaces. pub fn as_str(self) -> &'static str { match self { Self::File => "file", @@ -45,18 +47,31 @@ pub struct CredentialRegistry { } impl CredentialRegistry { + /// Build an empty registry for tests or runtime paths with no bootstrap + /// credentials. pub fn empty() -> Self { Self::default() } + /// Return one bootstrap value by well-known credential key. pub fn get_credential(&self, name: &str) -> Option<&str> { self.values.get(name).map(String::as_str) } + /// Report whether the bootstrap source was env, file, or absent. pub fn source(&self) -> CredentialSource { self.source } + /// Store one bootstrap value for later runtime reads. Empty values are + /// discarded so callers can treat env/file as optional transports. + pub fn bootstrap_value(&mut self, name: &str, value: Option) { + if let Some(value) = value.filter(|value| !value.is_empty()) { + self.values.insert(name.to_string(), value); + } + } + + /// Report whether any admin credential was bootstrapped for runtime auth. pub fn has_admin_auth(&self) -> bool { self.get_credential(CRED_ADMIN_TOKEN) .is_some_and(|v| !v.is_empty()) @@ -76,6 +91,7 @@ impl CredentialRegistry { credentials_path: Option<&Path>, env_admin_token: Option, env_admin_tokens: Option, + env_trusted_proxy_cidrs: Option, ) -> Result { let mut values = HashMap::new(); // CredentialSource is documented (and reported via HealthStatus/support @@ -93,12 +109,18 @@ impl CredentialRegistry { path.display() ) })?; - for key in [CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS] { + for key in [ + CRED_ADMIN_TOKEN, + CRED_ADMIN_TOKENS, + CRED_TRUSTED_PROXY_CIDRS, + ] { if let Some(raw) = file_map.get(key) { let text = json_value_as_nonempty_string(raw); if let Some(text) = text { values.insert(key.to_string(), text); - admin_from_file = true; + if matches!(key, CRED_ADMIN_TOKEN | CRED_ADMIN_TOKENS) { + admin_from_file = true; + } } } } @@ -125,6 +147,11 @@ impl CredentialRegistry { values.insert(CRED_ADMIN_TOKENS.to_string(), tokens); admin_from_env = true; } + if !values.contains_key(CRED_TRUSTED_PROXY_CIDRS) + && let Some(cidr_list) = env_trusted_proxy_cidrs.filter(|value| !value.is_empty()) + { + values.insert(CRED_TRUSTED_PROXY_CIDRS.to_string(), cidr_list); + } let source = if admin_from_file { CredentialSource::File } else if admin_from_env { @@ -137,6 +164,7 @@ impl CredentialRegistry { } } +/// Normalize JSON scalar/bootstrap values into non-empty strings. fn json_value_as_nonempty_string(value: &serde_json::Value) -> Option { match value { serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()), @@ -163,6 +191,7 @@ mod tests { None, Some("secret".to_string()), Some("tok:alice".to_string()), + Some("192.0.2.0/24".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -171,13 +200,17 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKENS), Some("tok:alice") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_CIDRS), + Some("192.0.2.0/24") + ); assert!(registry.has_admin_auth()); } #[test] fn bootstrap_empty_when_no_secrets() { let registry = - CredentialRegistry::bootstrap_secrets(None, None, Some(String::new())).unwrap(); + CredentialRegistry::bootstrap_secrets(None, None, Some(String::new()), None).unwrap(); assert_eq!(registry.source(), CredentialSource::None); assert!(!registry.has_admin_auth()); } @@ -206,6 +239,7 @@ mod tests { Some(&path), Some("from-env".to_string()), Some("envtok:env".to_string()), + Some("198.51.100.0/24".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -214,6 +248,10 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKENS), Some("filetok:operator") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_CIDRS), + Some("198.51.100.0/24") + ); let _ = std::fs::remove_dir_all(&dir); } @@ -236,6 +274,7 @@ mod tests { Some(&path), Some("ignored".to_string()), Some("envtok:bob".to_string()), + Some("198.51.100.0/24".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::File); @@ -262,6 +301,7 @@ mod tests { Some(&path), Some("env-secret".to_string()), None, + Some("203.0.113.0/24".to_string()), ) .unwrap(); assert_eq!(registry.source(), CredentialSource::Env); @@ -269,6 +309,10 @@ mod tests { registry.get_credential(CRED_ADMIN_TOKEN), Some("env-secret") ); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_CIDRS), + Some("203.0.113.0/24") + ); } #[test] @@ -284,8 +328,88 @@ mod tests { std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("credentials.json"); std::fs::write(&path, "not-json").unwrap(); - let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None).unwrap_err(); + let err = CredentialRegistry::bootstrap_secrets(Some(&path), None, None, None).unwrap_err(); assert!(err.contains("not valid JSON")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn file_overrides_env_for_trusted_proxy_cidrs() { + let dir = std::env::temp_dir().join(format!( + "wardnet-creds-trusted-proxies-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("credentials.json"); + std::fs::write( + &path, + r#"{"trusted_proxy_cidrs":"192.0.2.0/24,2001:db8::/32"}"#, + ) + .unwrap(); + + let registry = CredentialRegistry::bootstrap_secrets( + Some(&path), + None, + None, + Some("198.51.100.0/24".to_string()), + ) + .unwrap(); + + assert_eq!(registry.source(), CredentialSource::None); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_CIDRS), + Some("192.0.2.0/24,2001:db8::/32") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn trusted_proxy_file_config_does_not_mask_env_admin_source() { + let dir = std::env::temp_dir().join(format!( + "wardnet-creds-source-proxy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("credentials.json"); + std::fs::write(&path, r#"{"trusted_proxy_cidrs":"192.0.2.0/24"}"#).unwrap(); + + let registry = CredentialRegistry::bootstrap_secrets( + Some(&path), + Some("env-admin".to_string()), + None, + None, + ) + .unwrap(); + + assert_eq!(registry.source(), CredentialSource::Env); + assert_eq!(registry.get_credential(CRED_ADMIN_TOKEN), Some("env-admin")); + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_CIDRS), + Some("192.0.2.0/24") + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn bootstrap_value_stores_non_empty_runtime_config() { + let mut registry = CredentialRegistry::empty(); + registry.bootstrap_value(CRED_TRUSTED_PROXY_CIDRS, Some("192.0.2.0/24".to_string())); + registry.bootstrap_value("blank", Some(String::new())); + + assert_eq!( + registry.get_credential(CRED_TRUSTED_PROXY_CIDRS), + Some("192.0.2.0/24") + ); + assert_eq!(registry.get_credential("blank"), None); + } } diff --git a/src/lib.rs b/src/lib.rs index ab902cae..a55bbdc7 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}, + 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}, @@ -44,7 +44,10 @@ mod opencti_import; mod stix_import; mod suricata_eve; mod taxii; -pub use credentials::{CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CredentialRegistry, CredentialSource}; +pub use credentials::{ + CRED_ADMIN_TOKEN, CRED_ADMIN_TOKENS, CRED_TRUSTED_PROXY_CIDRS, CredentialRegistry, + CredentialSource, +}; #[derive(Clone)] pub struct AppState { @@ -66,6 +69,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 +142,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 +201,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,22 +304,170 @@ pub struct AppConfig { pub state_path: Option, pub dnsbl_origin: String, pub event_limit: usize, + pub trusted_proxies: Vec, } impl AppConfig { pub const DEFAULT_DNSBL_ORIGIN: &'static str = "dnsbl.local"; pub const DEFAULT_EVENT_LIMIT: usize = 1_000; + /// Build an in-memory app config with no trusted proxy ranges. pub fn memory(admin_token: Option) -> Self { Self { admin_token, 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 be non-empty".to_string()); + } + let (addr, prefix_len) = + match raw.split_once('/') { + Some((addr, prefix)) => { + let addr = addr.trim().parse::().map_err(|error| { + format!("invalid trusted proxy address {raw:?}: {error}") + })?; + let prefix_len = prefix.trim().parse::().map_err(|error| { + format!("invalid trusted proxy prefix {raw:?}: {error}") + })?; + (addr, prefix_len) + } + None => { + let addr = raw.parse::().map_err(|error| { + format!("invalid trusted proxy address {raw:?}: {error}") + })?; + (addr, max_prefix_len(addr)) + } + }; + let max = max_prefix_len(addr); + if prefix_len > max { + return Err(format!( + "trusted proxy prefix {prefix_len} exceeds {max} for {raw:?}" + )); + } + if let IpAddr::V6(ip) = addr + && ip.to_ipv4_mapped().is_some() + && prefix_len < 96 + { + return Err(format!( + "trusted proxy prefix {prefix_len} must be at least 96 for IPv4-mapped IPv6 {raw:?}" + )); + } + Ok(Self { addr, prefix_len }) + } + + /// Report whether the candidate IP falls within this network after + /// normalizing IPv4-mapped IPv6 forms. + fn contains(&self, ip: IpAddr) -> bool { + let (network, prefix_len) = normalized_network(self.addr, self.prefix_len); + let ip = normalized_ip(ip); + match (network, ip) { + (IpAddr::V4(network), IpAddr::V4(ip)) => { + masked_v4(network, prefix_len) == masked_v4(ip, prefix_len) + } + (IpAddr::V6(network), IpAddr::V6(ip)) => { + masked_v6(network, prefix_len) == masked_v6(ip, prefix_len) + } + _ => false, + } + } +} + +impl std::str::FromStr for IpNet { + type Err = String; + + fn from_str(raw: &str) -> Result { + Self::parse(raw) + } +} + +/// Return the maximum CIDR prefix width for one IP family. +fn max_prefix_len(addr: IpAddr) -> u8 { + match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + } +} + +/// Convert IPv4-mapped IPv6 networks into canonical IPv4 form when possible. +fn normalized_network(addr: IpAddr, prefix_len: u8) -> (IpAddr, u8) { + match addr { + IpAddr::V6(ip) => match ip.to_ipv4_mapped() { + Some(mapped) if prefix_len >= 96 => (IpAddr::V4(mapped), prefix_len - 96), + _ => (IpAddr::V6(ip), prefix_len), + }, + IpAddr::V4(ip) => (IpAddr::V4(ip), prefix_len), + } +} + +/// Canonicalize IPv4-mapped IPv6 addresses before trust comparisons. +fn normalized_ip(addr: IpAddr) -> IpAddr { + match addr { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + IpAddr::V4(ip) => IpAddr::V4(ip), + } +} + +/// Apply an IPv4 prefix mask as an integer comparison aid. +fn masked_v4(ip: Ipv4Addr, prefix_len: u8) -> u32 { + let raw = u32::from(ip); + let shift = 32_u32.saturating_sub(prefix_len as u32); + if shift == 32 { + 0 + } else { + raw & (!0_u32 << shift) + } +} + +/// Apply an IPv6 prefix mask as an integer comparison aid. +fn masked_v6(ip: std::net::Ipv6Addr, prefix_len: u8) -> u128 { + let raw = u128::from(ip); + let shift = 128_u32.saturating_sub(prefix_len as u32); + if shift == 128 { + 0 + } else { + raw & (!0_u128 << shift) + } +} + +/// Parse the trusted-proxy allowlist from one comma-separated bootstrap value. +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)?); } + if proxies.is_empty() { + return Err("trusted proxy list must contain at least one IP or CIDR".to_string()); + } + Ok(proxies) } +/// Load an existing state snapshot or write a seeded one on first boot. async fn load_or_seed_state(path: &Path) -> Result { match fs::read_to_string(path).await { Ok(content) => serde_json::from_str(&content) @@ -325,6 +484,7 @@ async fn load_or_seed_state(path: &Path) -> Result { } } +/// Persist state through a temporary sibling file followed by atomic rename. async fn persist_state(path: &Path, data: &AppData) -> Result<(), String> { if let Some(parent) = path .parent() @@ -356,6 +516,7 @@ async fn persist_state(path: &Path, data: &AppData) -> Result<(), String> { Ok(()) } +/// Build a sibling temporary path for atomic snapshot replacement. fn temporary_state_path(path: &Path) -> PathBuf { let file_name = path .file_name() @@ -368,6 +529,7 @@ fn temporary_state_path(path: &Path) -> PathBuf { path.with_file_name(format!(".{file_name}.tmp-{}-{unique}", std::process::id())) } +/// Trim operator input and fall back to the default DNSBL origin when blank. fn normalized_origin(origin: &str) -> String { let trimmed = origin.trim().trim_end_matches('.'); if trimmed.is_empty() { @@ -482,6 +644,10 @@ struct ErrorBody { error: String, } +/// Build the HTTP application surface for management, DNSBL, and gateway +/// traffic. Gateway requests still require peer metadata via +/// `ConnectInfo`; use [`serve`] or +/// `Router::into_make_service_with_connect_info::()`. pub fn build_app(state: AppState) -> Router { let max_body_bytes = state.max_body_bytes; Router::new() @@ -535,6 +701,25 @@ pub fn build_app(state: AppState) -> Router { .with_state(state) } +/// Serve the Wardnet router with peer address attribution enabled for gateway +/// controls such as rate limiting, DNSBL matching, and event attribution. +pub async fn serve( + listener: tokio::net::TcpListener, + state: AppState, + shutdown: F, +) -> Result<(), std::io::Error> +where + F: std::future::Future + Send + 'static, +{ + axum::serve( + listener, + build_app(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown) + .await?; + Ok(()) +} + pub fn export_events_ndjson(events: &[SecurityEvent]) -> Result { let mut out = String::new(); for event in events { @@ -2295,6 +2480,7 @@ async fn events_ndjson(State(state): State) -> Response { events_ndjson_response(export_events_ndjson(&data.events)) } +/// Render NDJSON event export or an internal serialization failure. fn events_ndjson_response(export: Result) -> Response { match export { Ok(body) => ( @@ -2310,6 +2496,7 @@ fn events_ndjson_response(export: Result) -> Response } } +/// Export the current DNSBL zone body in RFC 5782 text form. async fn dnsbl_zone(State(state): State) -> impl IntoResponse { let data = state.inner.read().await; ( @@ -2319,13 +2506,35 @@ async fn dnsbl_zone(State(state): State) -> impl IntoResponse { ) } -async fn gateway( - State(state): State, - method: Method, - uri: Uri, - headers: HeaderMap, - body: Bytes, -) -> Response { +/// Enforce trusted client attribution, rate limits, scoring, and proxying for +/// one gateway request. +async fn gateway(State(state): State, request: Request) -> Response { + let connect_info = request + .extensions() + .get::>() + .cloned(); + let peer_ip = match connect_info.as_ref().map(|peer| peer.0.ip()) { + Some(peer_ip) => peer_ip, + None => { + return error( + StatusCode::INTERNAL_SERVER_ERROR, + "gateway requires peer address metadata; serve with `serve(...)` or `into_make_service_with_connect_info::()`", + ); + } + }; + let (parts, body) = request.into_parts(); + let method = parts.method; + let uri = parts.uri; + let headers = parts.headers; + let body = match axum::body::to_bytes(body, state.max_body_bytes).await { + Ok(body) => body, + Err(read_error) => { + return error( + StatusCode::PAYLOAD_TOO_LARGE, + format!("failed to read request body: {read_error}"), + ); + } + }; let gateway_path = uri .path() .strip_prefix("/gateway") @@ -2343,7 +2552,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, Some(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 +2649,81 @@ 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()) +/// Resolve the effective client IP from direct peer and optional forwarding +/// metadata. +/// +/// Forwarded headers participate only when the direct peer is within +/// `trusted_proxies`. In that trusted context, the `X-Forwarded-For` chain is +/// walked from right to left so appended trusted-proxy hops are skipped and the +/// first untrusted valid address becomes the client identity. If no such hop is +/// present, a trusted `X-Real-IP` fallback is used, then the direct peer. +pub fn effective_client_ip( + peer_ip: Option, + x_forwarded_for: Option<&str>, + x_real_ip: Option<&str>, + trusted_proxies: &[IpNet], +) -> Option { + match peer_ip { + Some(peer_ip) if trusted_proxies.iter().any(|proxy| proxy.contains(peer_ip)) => { + trusted_forwarded_chain_client_ip(x_forwarded_for, trusted_proxies) + .or_else(|| trusted_real_ip_value(x_real_ip)) + .or(Some(peer_ip)) + } + Some(peer_ip) => Some(peer_ip), + None => None, + } +} + +/// Resolve the effective client IP from request headers and peer metadata. +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, + ) +} + +/// Extract the first untrusted client hop from an `X-Forwarded-For` chain, +/// scanning from the right so trusted proxy hops are skipped instead of +/// attacker-chosen leading values being accepted as the client identity. +pub fn trusted_forwarded_chain_client_ip( + x_forwarded_for: Option<&str>, + trusted_proxies: &[IpNet], +) -> Option { + let forwarded = x_forwarded_for?; + let mut client_ip = None; + for candidate in forwarded.split(',').rev() { + let candidate = candidate.trim(); + if candidate.is_empty() { + continue; + } + let ip = match candidate.parse::() { + Ok(ip) => ip, + Err(_) => continue, + }; + if trusted_proxies.iter().any(|proxy| proxy.contains(ip)) { + continue; + } + client_ip = Some(ip); + break; + } + client_ip +} + +/// Trusted-proxy fallback for deployments that emit a single canonical client +/// address via `X-Real-IP`. +fn trusted_real_ip_value(x_real_ip: Option<&str>) -> Option { + x_real_ip.and_then(|value| value.trim().parse().ok()) +} + +/// Read one request header as UTF-8 text. +fn header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) } async fn proxy_request( @@ -3286,6 +3558,7 @@ pub async fn run_from_env( credentials_path.as_deref(), std::env::var("ADMIN_TOKEN").ok(), std::env::var("ADMIN_TOKENS").ok(), + std::env::var("TRUSTED_PROXY_CIDRS").ok(), )?; let config = AppConfig { admin_token: credentials @@ -3295,6 +3568,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( + credentials.get_credential(CRED_TRUSTED_PROXY_CIDRS), + ) + .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,10 +3602,7 @@ 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; - served?; + serve(listener, state, shutdown).await?; Ok(()) } @@ -3368,6 +3642,7 @@ mod tests { "RATE_LIMIT", "RATE_LIMIT_WINDOW", "MAX_BODY_BYTES", + "TRUSTED_PROXY_CIDRS", ] { unsafe { std::env::remove_var(name) }; } @@ -3476,6 +3751,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_surfaces_state_load_failure() { let _guard = ENV_GUARD.lock().await; @@ -3600,6 +3891,12 @@ mod tests { app.clone().oneshot(request).await.unwrap() } + 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 + } + fn empty_request(method: Method, uri: &str) -> Request { Request::builder() .method(method) @@ -3636,12 +3933,129 @@ 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) + .header("x-forwarded-for", ip) + .body(Body::empty()) + .unwrap(), + ip, + ) + } + + fn gateway_get_via_proxy(uri: &str, peer_ip: &str, forwarded_ip: &str) -> Request { + with_peer_ip( + Request::builder() + .method(Method::GET) + .uri(uri) + .header("x-forwarded-for", forwarded_ip) + .body(Body::empty()) + .unwrap(), + peer_ip, + ) + } + + #[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("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("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 client_ip_from_request_uses_rightmost_untrusted_forwarded_hop() { + 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_skips_invalid_spoofed_leading_forwarded_hops() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-forwarded-for", + HeaderValue::from_static("not-an-ip, 203.0.113.9"), + ); + 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 parse_trusted_proxies_accepts_single_ips_and_cidrs() { + let trusted = parse_trusted_proxies(Some("192.0.2.44,2001:db8::/32")).unwrap(); + assert_eq!(trusted.len(), 2); + assert!(trusted[0].contains("192.0.2.44".parse().unwrap())); + assert!(trusted[1].contains("2001:db8::1".parse().unwrap())); + } + + #[test] + fn parse_trusted_proxies_rejects_invalid_prefixes() { + let error = parse_trusted_proxies(Some("192.0.2.0/40")).expect_err("bad prefix"); + assert!(error.contains("exceeds 32")); + let error = + parse_trusted_proxies(Some("::ffff:192.0.2.0/24")).expect_err("mapped bad prefix"); + assert!(error.contains("at least 96")); + } + + #[test] + fn parse_trusted_proxies_rejects_blank_configurations() { + let error = parse_trusted_proxies(Some(" , ")).expect_err("blank trusted proxies"); + assert!(error.contains("at least one IP or CIDR")); } #[test] @@ -3674,6 +4088,137 @@ mod tests { assert_eq!(other.status(), StatusCode::OK); } + #[tokio::test] + async fn gateway_ignores_spoofed_forwarded_for_from_untrusted_peer() { + let app = build_app(AppState::seeded(None).with_rate_limit(1, 60)); + + let first = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "198.51.100.7", "203.0.113.9"), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let blocked = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "198.51.100.7", "198.51.100.8"), + ) + .await; + assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); + } + + #[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(parse_trusted_proxies(Some("192.0.2.0/24")).unwrap()), + ); + + let first = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "192.0.2.44", "203.0.113.9"), + ) + .await; + assert_eq!(first.status(), StatusCode::OK); + + let blocked = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "192.0.2.44", "203.0.113.9"), + ) + .await; + assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS); + + let other = app_request( + &app, + gateway_get_via_proxy("/gateway/demo", "192.0.2.44", "203.0.113.10"), + ) + .await; + assert_eq!(other.status(), StatusCode::OK); + } + + #[tokio::test] + async fn gateway_rejects_missing_connect_info_instead_of_silently_sharing_unknown_ip() { + let app = build_app(AppState::seeded(None).with_rate_limit(1, 60)); + + let response = app_request( + &app, + Request::builder() + .method(Method::GET) + .uri("/gateway/demo") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + body_text(response) + .await + .contains("gateway requires peer address metadata") + ); + } + + #[tokio::test] + async fn serve_preserves_peer_ip_for_real_tcp_gateway_requests() { + let state = AppState::seeded(None).with_rate_limit(1, 60); + state + .mutate_and_persist(|data| { + data.routes.push(RouteConfig { + id: "loopback".to_string(), + path_prefix: "/loopback".to_string(), + upstream: "mock://loopback".to_string(), + mode: EnforcementMode::Block, + enabled: true, + block_threshold: None, + }); + data.dnsbl.push(DnsblEntry { + address: "127.0.0.1".parse().unwrap(), + code: "127.0.0.2".to_string(), + reason: "loopback test".to_string(), + source: "unit".to_string(), + ttl_seconds: 300, + prefix_len: None, + }); + }) + .await + .unwrap(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let server = tokio::spawn(serve(listener, state, async move { + let _ = shutdown_rx.await; + })); + + let client = reqwest::Client::new(); + let url = format!("http://{addr}/gateway/loopback"); + + let blocked = client.get(&url).send().await.unwrap(); + assert_eq!(blocked.status(), StatusCode::FORBIDDEN); + + let limited = client.get(&url).send().await.unwrap(); + assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS); + + let events: Vec = client + .get(format!("http://{addr}/api/events")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(events.iter().any(|event| { + event.client_ip == Some("127.0.0.1".parse().unwrap()) && event.action == "blocked" + })); + assert!(events.iter().any(|event| { + event.client_ip == Some("127.0.0.1".parse().unwrap()) && event.action == "rate_limited" + })); + + let _ = shutdown_tx.send(()); + server.await.unwrap().unwrap(); + } + 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() @@ -3983,11 +4528,17 @@ mod tests { &serde_json::json!({"id": "hi", "path_prefix": "/hi", "upstream": "mock://x", "mode": "block", "enabled": true}), ); assert!(app_request(&app, hi).await.status().is_success()); - let blocked = - app_request(&app, empty_request(Method::GET, "/gateway/low?q=probe-xyz")).await; + let blocked = app_request( + &app, + gateway_get_from_ip("/gateway/low?q=probe-xyz", "203.0.113.9"), + ) + .await; assert_eq!(blocked.status(), StatusCode::FORBIDDEN); - let allowed = - app_request(&app, empty_request(Method::GET, "/gateway/hi?q=probe-xyz")).await; + let allowed = app_request( + &app, + gateway_get_from_ip("/gateway/hi?q=probe-xyz", "203.0.113.9"), + ) + .await; assert_ne!(allowed.status(), StatusCode::FORBIDDEN); let bad = json_request( Method::POST, @@ -4004,20 +4555,26 @@ mod tests { #[tokio::test] async fn oversized_request_body_is_rejected() { let app = build_app(AppState::seeded(None).with_max_body_size(16)); - let big = Request::builder() - .method(Method::POST) - .uri("/gateway/demo") - .body(Body::from("x".repeat(64))) - .unwrap(); + let big = with_peer_ip( + Request::builder() + .method(Method::POST) + .uri("/gateway/demo") + .body(Body::from("x".repeat(64))) + .unwrap(), + "203.0.113.9", + ); assert_eq!( app_request(&app, big).await.status(), StatusCode::PAYLOAD_TOO_LARGE ); - let small = Request::builder() - .method(Method::POST) - .uri("/gateway/demo") - .body(Body::from("x")) - .unwrap(); + let small = with_peer_ip( + Request::builder() + .method(Method::POST) + .uri("/gateway/demo") + .body(Body::from("x")) + .unwrap(), + "203.0.113.9", + ); assert_ne!( app_request(&app, small).await.status(), StatusCode::PAYLOAD_TOO_LARGE @@ -4327,6 +4884,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example.".to_string(), event_limit: 10, + trusted_proxies: parse_trusted_proxies(Some("192.0.2.0/24")).unwrap(), }) .await .unwrap(); @@ -4475,12 +5033,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, 192.0.2.10") + .body(Body::from("payload")) + .unwrap(), + "192.0.2.44", + ); let response = app_request(&app, gateway_request).await; assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(body_text(response).await.contains("\"action\":\"blocked\"")); @@ -4537,6 +5098,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 +5167,7 @@ mod tests { state_path: Some(path.clone()), dnsbl_origin: "dnsbl.example".to_string(), event_limit: 10, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -4716,7 +5279,7 @@ mod tests { let gateway_response = app_request( &app, - empty_request(Method::GET, "/gateway/demo?q=union%20select"), + gateway_get_from_ip("/gateway/demo?q=union%20select", "203.0.113.9"), ) .await; assert_eq!(gateway_response.status(), StatusCode::OK); @@ -4945,9 +5508,9 @@ mod tests { let blocked = app_request( &app, - empty_request( - Method::GET, + gateway_get_from_ip( "/gateway/phish?q=https%3A%2F%2Fevil.example%2Flogin", + "203.0.113.9", ), ) .await; @@ -5309,7 +5872,7 @@ mod tests { let allowed = app_request( &app, - empty_request(Method::GET, "/gateway/cve-lookup?id=CVE-2021-44228"), + gateway_get_from_ip("/gateway/cve-lookup?id=CVE-2021-44228", "203.0.113.44"), ) .await; assert_eq!(allowed.status(), StatusCode::OK); @@ -6577,19 +7140,24 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.local".to_string(), event_limit: 20, + trusted_proxies: Vec::new(), }, ); let app = build_app(state); - let no_route = app_request(&app, empty_request(Method::GET, "/gateway/none")).await; + let no_route = + app_request(&app, gateway_get_from_ip("/gateway/none", "198.51.100.8")).await; assert_eq!(no_route.status(), StatusCode::NOT_FOUND); - let mock_request = Request::builder() - .method(Method::GET) - .uri("/gateway/mock") - .header("x-real-ip", "198.51.100.8") - .body(Body::empty()) - .unwrap(); + let mock_request = with_peer_ip( + Request::builder() + .method(Method::GET) + .uri("/gateway/mock") + .header("x-real-ip", "198.51.100.8") + .body(Body::empty()) + .unwrap(), + "198.51.100.8", + ); let mock_response = app_request(&app, mock_request).await; assert_eq!(mock_response.status(), StatusCode::OK); assert!( @@ -6600,17 +7168,21 @@ mod tests { let proxy_response = app_request( &app, - empty_request(Method::GET, "/gateway/proxy/v1/items?ok=1"), + gateway_get_from_ip("/gateway/proxy/v1/items?ok=1", "198.51.100.8"), ) .await; assert_eq!(proxy_response.status(), StatusCode::ACCEPTED); assert_eq!(body_text(proxy_response).await, "proxied"); - let down_response = app_request(&app, empty_request(Method::GET, "/gateway/down")).await; + let down_response = + app_request(&app, gateway_get_from_ip("/gateway/down", "198.51.100.8")).await; assert_eq!(down_response.status(), StatusCode::BAD_GATEWAY); - let truncated_response = - app_request(&app, empty_request(Method::GET, "/gateway/truncated")).await; + let truncated_response = app_request( + &app, + gateway_get_from_ip("/gateway/truncated", "198.51.100.8"), + ) + .await; assert_eq!(truncated_response.status(), StatusCode::BAD_GATEWAY); raw_task.join().unwrap(); @@ -6659,6 +7231,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 +7254,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 +7841,7 @@ mod tests { state_path: None, dnsbl_origin: " . ".to_string(), event_limit: 0, + trusted_proxies: Vec::new(), }) .await .unwrap(); @@ -7300,7 +7875,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 +7889,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 +7916,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 +7969,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 +7990,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 +8033,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); @@ -7556,7 +8136,8 @@ mod tests { .await; assert!(feeds.is_empty()); - let gateway_response = app_request(&app, empty_request(Method::GET, "/gateway/mock")).await; + let gateway_response = + app_request(&app, gateway_get_from_ip("/gateway/mock", "203.0.113.9")).await; assert_eq!(gateway_response.status(), StatusCode::OK); let events: Vec = json_body(app_request(&app, empty_request(Method::GET, "/api/events")).await).await; @@ -7573,6 +8154,7 @@ mod tests { state_path: None, dnsbl_origin: "dnsbl.example".to_string(), event_limit: 2, + trusted_proxies: Vec::new(), }, ); @@ -7623,6 +8205,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/main.rs b/src/main.rs index 0baa29e3..fa65bc4b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,37 +1,110 @@ +use std::{future::Future, pin::Pin}; +#[cfg(any(test, not(unix)))] +use std::{future::poll_fn, task::Poll}; + // The gateway entrypoint is intentionally a thin shim: all configuration // parsing, binding, and serving live in `waf_ids_ai_soc::run_from_env` so they // are unit-testable, while this file is covered end-to-end by `tests/binary.rs`. #[cfg(not(test))] #[tokio::main] async fn main() -> Result<(), Box> { - // Registered eagerly, before `run_from_env` binds its listener and prints - // the readiness line, so a SIGTERM delivered immediately on startup (as - // container runtimes and the e2e test harness do) cannot race the OS-level - // handler installation and fall through to the default "kill" disposition. - let shutdown = install_shutdown_signal(); - waf_ids_ai_soc::run_from_env(Box::pin(shutdown)).await + waf_ids_ai_soc::run_from_env(shutdown_signal().await?).await } #[cfg(all(not(test), unix))] -fn install_shutdown_signal() -> impl std::future::Future + Send + 'static { - // `tokio::signal::unix::signal` registers the handler synchronously on - // call; only the subsequent `.recv()` wait is deferred to the returned - // future, so callers must invoke this *before* announcing readiness. - let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .expect("install SIGTERM handler"); - async move { +async fn shutdown_signal() +-> Result + Send>>, Box> { + // Install SIGTERM handling before readiness can be reported, so a fast + // supervisor or test harness cannot kill the process before graceful + // shutdown is armed. + let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + Ok(Box::pin(async move { term.recv().await; + })) +} + +/// Poll a shutdown future once up front so listeners that install on first +/// poll, such as `tokio::signal::ctrl_c()`, are armed before startup runs. +#[cfg(any(test, not(unix)))] +async fn arm_shutdown_future(future: F) -> Result + Send>>, E> +where + F: Future> + Send + 'static, +{ + let mut future = Box::pin(future); + let ready = poll_fn(|cx| match future.as_mut().poll(cx) { + Poll::Ready(Ok(())) => Poll::Ready(Ok(true)), + Poll::Ready(Err(error)) => Poll::Ready(Err(error)), + Poll::Pending => Poll::Ready(Ok(false)), + }) + .await?; + if ready { + return Ok(Box::pin(async {})); } + Ok(Box::pin(async move { + let _ = future.await; + })) +} + +#[cfg(all(not(test), not(unix)))] +async fn shutdown_signal() +-> Result + Send>>, Box> { + Ok(arm_shutdown_future(tokio::signal::ctrl_c()).await?) } -#[cfg(all(not(test), windows))] -fn install_shutdown_signal() -> impl std::future::Future + Send + 'static { - // Mirrors the Unix path: `tokio::signal::windows::ctrl_c` registers the - // handler synchronously, so only `.recv()` is deferred to the future. - // Scoped to `windows` specifically (not `not(unix)`) since that API only - // exists on Windows -- a broader non-Unix target would fail to compile. - let mut ctrl_c = tokio::signal::windows::ctrl_c().expect("install Ctrl-C handler"); - async move { - ctrl_c.recv().await; +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + }; + + struct PendingThenReady { + polls: Arc, + } + + impl Future for PendingThenReady { + type Output = io::Result<()>; + + fn poll(self: Pin<&mut Self>, _cx: &mut std::task::Context<'_>) -> Poll { + let polls = self.polls.fetch_add(1, Ordering::SeqCst); + if polls == 0 { + Poll::Pending + } else { + Poll::Ready(Ok(())) + } + } + } + + #[tokio::test] + async fn arm_shutdown_future_registers_listener_before_await() { + let polls = Arc::new(AtomicUsize::new(0)); + let shutdown = arm_shutdown_future(PendingThenReady { + polls: polls.clone(), + }) + .await + .unwrap(); + + assert_eq!(polls.load(Ordering::SeqCst), 1); + shutdown.await; + assert_eq!(polls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn arm_shutdown_future_propagates_registration_error() { + let err = match arm_shutdown_future(async { + Err::<(), io::Error>(io::Error::other("listener failed")) + }) + .await + { + Ok(_) => panic!("listener registration should fail"), + Err(err) => err, + }; + + assert_eq!(err.kind(), io::ErrorKind::Other); + assert_eq!(err.to_string(), "listener failed"); } } diff --git a/tests/fuzz_invariants.rs b/tests/fuzz_invariants.rs index 746fc3cb..281c9351 100644 --- a/tests/fuzz_invariants.rs +++ b/tests/fuzz_invariants.rs @@ -1,12 +1,25 @@ -//! Property-based invariant test for the `ADMIN_TOKENS` config parser. +//! Property-based invariant tests mirrored from the cargo-fuzz targets. //! -//! Mirrors the `fuzz_parse_admin_tokens` cargo-fuzz target (see `../fuzz`) but -//! runs on stable in the normal `cargo test` suite. Parsing arbitrary operator -//! config must never panic and must never emit an empty token key or empty -//! actor value. +//! These run on stable in the normal `cargo test` suite so core invariants stay +//! covered in primary CI. use proptest::prelude::*; -use waf_ids_ai_soc::parse_admin_tokens; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use waf_ids_ai_soc::{IpNet, effective_client_ip, parse_admin_tokens}; + +fn normalized_ip(addr: IpAddr) -> IpAddr { + match addr { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + IpAddr::V4(ip) => IpAddr::V4(ip), + } +} + +fn is_trusted_single_host(ip: IpAddr, trusted_proxy_ip: IpAddr) -> bool { + normalized_ip(ip) == normalized_ip(trusted_proxy_ip) +} proptest! { #[test] @@ -17,4 +30,93 @@ proptest! { prop_assert!(!principal.actor.is_empty(), "actor value must never be empty"); } } + + #[test] + fn trusted_forwarded_client_ip_matches_right_to_left_trust_model( + trusted_proxy in prop_oneof![any::().prop_map(Ipv4Addr::from).prop_map(IpAddr::V4), any::().prop_map(Ipv6Addr::from).prop_map(IpAddr::V6)], + peer_ip in prop::option::of(prop_oneof![any::().prop_map(Ipv4Addr::from).prop_map(IpAddr::V4), any::().prop_map(Ipv6Addr::from).prop_map(IpAddr::V6)]), + trust_peer in any::(), + forwarded_hops in prop::collection::vec( + prop_oneof![ + any::().prop_map(Ipv4Addr::from).prop_map(IpAddr::V4).prop_map(|ip| ip.to_string()), + any::().prop_map(Ipv6Addr::from).prop_map(IpAddr::V6).prop_map(|ip| ip.to_string()), + ".*", + Just(String::from(" ")), + ], + 0..8 + ), + x_real_ip in prop::option::of(prop_oneof![ + any::().prop_map(Ipv4Addr::from).prop_map(IpAddr::V4).prop_map(|ip| ip.to_string()), + any::().prop_map(Ipv6Addr::from).prop_map(IpAddr::V6).prop_map(|ip| ip.to_string()), + ".*", + Just(String::from(" ")), + ]), + ) { + let trusted_proxy_ip = trusted_proxy; + let trusted_proxy_raw = match trusted_proxy_ip { + IpAddr::V4(ip) => format!("{ip}/32"), + IpAddr::V6(ip) => format!("{ip}/128"), + }; + let trusted_proxy = IpNet::parse(&trusted_proxy_raw).unwrap(); + let trusted_proxies = vec![trusted_proxy.clone()]; + let peer_ip = if trust_peer && peer_ip.is_some() { + Some(trusted_proxy_ip) + } else { + peer_ip + }; + let trust_peer = peer_ip + .map(|peer_ip| is_trusted_single_host(peer_ip, trusted_proxy_ip)) + .unwrap_or(false); + let x_forwarded_for = if forwarded_hops.is_empty() { + None + } else { + Some(forwarded_hops.join(",")) + }; + let resolved = effective_client_ip( + peer_ip, + x_forwarded_for.as_deref(), + x_real_ip.as_deref(), + &trusted_proxies, + ); + let expected = peer_ip.and_then(|peer_ip| { + if !trust_peer { + return Some(peer_ip); + } + if let Some(forwarded) = x_forwarded_for.as_deref() { + for hop in forwarded.split(',').rev() { + let hop = hop.trim(); + if hop.is_empty() { + continue; + } + let Ok(ip) = hop.parse::() else { + continue; + }; + if is_trusted_single_host(ip, trusted_proxy_ip) { + continue; + } + return Some(ip); + } + } + x_real_ip + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .or(Some(peer_ip)) + }); + + prop_assert_eq!(resolved, expected); + } +} + +#[test] +fn trusted_forwarded_client_ip_accepts_ipv4_mapped_trusted_peer_reference_model() { + let trusted_proxies = vec![IpNet::parse("192.0.2.44/32").unwrap()]; + let peer_ip = Some(IpAddr::V6(Ipv4Addr::new(192, 0, 2, 44).to_ipv6_mapped())); + let resolved = effective_client_ip( + peer_ip, + Some("198.51.100.7, 192.0.2.44"), + None, + &trusted_proxies, + ); + + assert_eq!(resolved, Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 7)))); }