From 5d5cd26572e296c09706f67de6c040bf93a4afe6 Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Sat, 30 May 2026 07:15:45 -0700 Subject: [PATCH] fix(server): trusted-proxy client-IP extraction for rate-limit/audit keying Resolves coord todo 3c1f372a (Task-4 review SHOULD-FIX). Behind NPM-on-loopback, ConnectInfo was 127.0.0.1 so the rate limiter + lockout bucketed every client under one IP. New shared utils::ip_extract::client_ip() honors X-Real-IP / X-Forwarded-For (rightmost-untrusted hop) ONLY when the TCP peer is a configured trusted proxy (CONNECT_TRUSTED_PROXIES env, default loopback, fail-closed); untrusted peers are keyed by their true peer IP (forged headers ignored). Wired into the 3 rate-limit middleware, the validate_code lockout feed, and the agent/ viewer WS handlers so the limiter, lockout, and audit ip_address all key on the real client consistently. 13 unit tests (spoof rejection, XFF walk, fail-safe defaults). Code-reviewed APPROVED. Not cargo-check-verified locally (no toolchain); build-host/CI verification follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/main.rs | 28 +- server/src/middleware/rate_limit.rs | 21 +- server/src/relay/mod.rs | 14 +- server/src/utils/ip_extract.rs | 396 ++++++++++++++++++++++++++-- 4 files changed, 430 insertions(+), 29 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index f254006..b104ad3 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -61,6 +61,12 @@ pub struct AppState { /// Per-IP rate limiters + brute-force lockout (Task 4). Shared (Arc-backed /// internally) so cloning AppState shares the same counters. pub rate_limits: middleware::RateLimitState, + /// Trusted reverse-proxy allowlist for client-IP extraction. Forwarding + /// headers (`X-Forwarded-For` / `X-Real-IP`) are honored ONLY when the TCP + /// peer is in this set; otherwise the peer IP is used. Parsed once at startup + /// from `CONNECT_TRUSTED_PROXIES` (default: loopback). See + /// `utils::ip_extract::client_ip`. + pub trusted_proxies: Arc, } /// Middleware to inject JWT config and token blacklist into request extensions @@ -235,6 +241,20 @@ async fn main() -> Result<()> { info!("No AGENT_API_KEY set - persistent agents will need JWT token or support code"); } + // Trusted reverse-proxy allowlist for real-client-IP extraction. + // GuruConnect sits behind NPM on loopback, so axum's ConnectInfo reports the + // proxy peer (127.0.0.1/::1), not the client. We only honor X-Forwarded-For / + // X-Real-IP when the TCP peer is a trusted proxy; otherwise the header is + // attacker-spoofable and is ignored. Default trust set is loopback; override + // with CONNECT_TRUSTED_PROXIES (comma-separated IPs). + let trusted_proxies = Arc::new(utils::ip_extract::TrustedProxies::from_env_value( + std::env::var("CONNECT_TRUSTED_PROXIES").ok().as_deref(), + )); + info!( + "Trusted reverse-proxy set for client-IP extraction: [{}]", + trusted_proxies.describe() + ); + // Initialize Prometheus metrics let mut registry = Registry::default(); let metrics = Arc::new(metrics::Metrics::new(&mut registry)); @@ -267,6 +287,7 @@ async fn main() -> Result<()> { registry, start_time, rate_limits: middleware::RateLimitState::new(), + trusted_proxies, }; // Build router @@ -508,9 +529,14 @@ struct ValidateParams { async fn validate_code( State(state): State, ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, Path(code): Path, ) -> Json { - let ip = addr.ip(); + // Real client IP via the trusted-proxy-aware extractor — must match the key + // the lockout middleware (`code_validate_rate_limit`) uses, or the per-attempt + // success/failure would be recorded against a different bucket than the one + // the lockout is enforced on. + let ip = utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies); // PREVIEW ONLY: validate_code inspects the in-memory code state and does NOT // consume the code (single-use consumption happens at agent BIND, in diff --git a/server/src/middleware/rate_limit.rs b/server/src/middleware/rate_limit.rs index 285599c..cf3433f 100644 --- a/server/src/middleware/rate_limit.rs +++ b/server/src/middleware/rate_limit.rs @@ -19,12 +19,15 @@ //! the brute-force defense for the support-code space (the code-validate //! route reports per-attempt success/failure into it). //! -//! Client IP is taken from axum's [`ConnectInfo`] (the same source -//! the relay uses for `client_ip`). `X-Forwarded-For` is intentionally NOT -//! trusted here: the server terminates behind a known reverse proxy (NPM), and -//! honoring a client-settable header would let an attacker trivially rotate the -//! limiter key. If/when per-proxy XFF handling is needed it must be gated on a -//! trusted-proxy allowlist — tracked as a follow-up, not done blindly here. +//! Client IP is the REAL client IP from the shared trusted-proxy-aware extractor +//! ([`crate::utils::ip_extract::client_ip`]) — the same source the relay and the +//! audit/event log use, so all three never drift. The extractor honors +//! `X-Forwarded-For` / `X-Real-IP` ONLY when the TCP peer is a configured trusted +//! proxy (default: loopback, since NPM runs on the same host); a header from an +//! untrusted peer is attacker-spoofable and is ignored. Keying on the real client +//! IP is what makes the per-IP limiter and the failure lockout per-actual-client +//! rather than per-proxy — without it, every external client buckets under the +//! proxy's loopback address and one abuser could lock out the whole fleet. //! //! Memory is bounded by pruning expired entries opportunistically on each call //! and capping the map size; an unbounded attacker rotating source IPs cannot @@ -339,7 +342,7 @@ pub async fn login_rate_limit( request: axum::extract::Request, next: axum::middleware::Next, ) -> Response { - let ip = addr.ip(); + let ip = crate::utils::ip_extract::client_ip(&addr, request.headers(), &state.trusted_proxies); if !state.rate_limits.login.check(ip) { tracing::warn!("Rate limit exceeded on /api/auth/login from {}", ip); return too_many_requests( @@ -357,7 +360,7 @@ pub async fn change_password_rate_limit( request: axum::extract::Request, next: axum::middleware::Next, ) -> Response { - let ip = addr.ip(); + let ip = crate::utils::ip_extract::client_ip(&addr, request.headers(), &state.trusted_proxies); if !state.rate_limits.change_password.check(ip) { tracing::warn!( "Rate limit exceeded on /api/auth/change-password from {}", @@ -389,7 +392,7 @@ pub async fn code_validate_rate_limit( request: axum::extract::Request, next: axum::middleware::Next, ) -> Response { - let ip = addr.ip(); + let ip = crate::utils::ip_extract::client_ip(&addr, request.headers(), &state.trusted_proxies); // 1. Brute-force lockout takes precedence. if state.rate_limits.code_validate_lockout.is_locked(ip) { diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs index 5d51a37..fa2fe23 100644 --- a/server/src/relay/mod.rs +++ b/server/src/relay/mod.rs @@ -82,6 +82,7 @@ pub async fn agent_ws_handler( ws: WebSocketUpgrade, State(state): State, ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, Query(params): Query, ) -> Result { // The CLIENT-SUPPLIED agent_id. For a per-agent-key (managed) agent this is @@ -96,7 +97,12 @@ pub async fn agent_ws_handler( .unwrap_or_else(|| agent_id.clone()); let support_code = params.support_code.clone(); let api_key = params.api_key.clone(); - let client_ip = addr.ip(); + // Real client IP via the trusted-proxy-aware extractor (shared with the rate + // limiter and audit log). Behind NPM on loopback, `addr.ip()` is 127.0.0.1; + // this resolves the actual remote agent IP from forwarding headers when the + // peer is a trusted proxy, and ignores those headers (using the peer) when it + // is not — so the connection-rejected events below record the real client. + let client_ip = crate::utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies); // SECURITY: Agent must provide either a support code OR an API key // Support code = ad-hoc support session (technician generated code) @@ -408,9 +414,13 @@ pub async fn viewer_ws_handler( ws: WebSocketUpgrade, State(state): State, ConnectInfo(addr): ConnectInfo, + headers: axum::http::HeaderMap, Query(params): Query, ) -> Result { - let client_ip = addr.ip(); + // Real client IP via the trusted-proxy-aware extractor (shared with the rate + // limiter and audit log) so the viewer audit events record the real client, + // not the proxy's loopback address. + let client_ip = crate::utils::ip_extract::client_ip(&addr, &headers, &state.trusted_proxies); // The session the viewer is asking to join. Parse early — a malformed UUID // can never match a viewer token's `session_id` claim, so reject up front. diff --git a/server/src/utils/ip_extract.rs b/server/src/utils/ip_extract.rs index 42fe7d8..fa7d351 100644 --- a/server/src/utils/ip_extract.rs +++ b/server/src/utils/ip_extract.rs @@ -1,23 +1,385 @@ -//! IP address extraction from WebSocket connections +//! Trusted-proxy-aware client-IP extraction. +//! +//! GuruConnect runs behind a reverse proxy (NPM) on the same host, so axum's +//! [`ConnectInfo`] reports the *proxy's* peer address +//! (`127.0.0.1`/`::1`), not the real remote client. Anything that keys on the +//! peer IP — the per-IP rate limiter, the brute-force lockout, and the audit +//! `ip_address` column — would therefore bucket every external client under one +//! address, and the code-validate lockout could lock out the whole fleet on a +//! single abuser. +//! +//! This module is the single source of truth for "who is the real client?". +//! It derives the client IP from forwarding headers **only when the immediate +//! TCP peer is a configured trusted proxy** — a client-supplied +//! `X-Forwarded-For` / `X-Real-IP` from an *untrusted* peer is attacker-spoofable +//! and is therefore ignored. +//! +//! ## Trust model +//! +//! - **Untrusted peer** (peer IP not in the trusted-proxy allowlist): return the +//! peer IP. Forwarding headers are ignored entirely — a direct client could +//! set any value, so honoring them would let an attacker rotate the limiter key +//! or forge the audit IP at will. +//! - **Trusted peer** (peer IP in the allowlist): derive the client IP from the +//! forwarding headers the trusted proxy is expected to set: +//! 1. `X-Real-IP`, if present and parseable (NPM/nginx sets a single value). +//! 2. Otherwise `X-Forwarded-For`: take the **rightmost entry that is not +//! itself a trusted proxy**. Walking right-to-left and skipping trusted hops +//! yields the address the outermost trusted proxy actually *observed* — a +//! client cannot forge an entry past that point, because the trusted proxy +//! appends the real peer and everything the client pre-seeded sits further +//! left. +//! 3. If neither header yields a usable address, fall back to the peer IP. +//! +//! Exact-IP matching is sufficient for the deployment (NPM on loopback); CIDR +//! ranges are intentionally not supported to avoid pulling in an `ipnet`-style +//! dependency for a case that exact loopback IPs already cover. -use std::net::{IpAddr, SocketAddr}; +use std::collections::HashSet; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; -/// Extract IP address from Axum ConnectInfo +use axum::http::HeaderMap; + +/// Header carrying a single client IP, set by nginx/NPM (`X-Real-IP`). +const X_REAL_IP: &str = "x-real-ip"; +/// Standard forwarding chain header (`X-Forwarded-For`). +const X_FORWARDED_FOR: &str = "x-forwarded-for"; + +/// The set of TCP peers whose forwarding headers we trust. /// -/// # Example -/// ```rust -/// pub async fn handler(ConnectInfo(addr): ConnectInfo) { -/// let ip = extract_ip(&addr); -/// // Use ip for logging -/// } -/// ``` -#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ -pub fn extract_ip(addr: &SocketAddr) -> IpAddr { - addr.ip() +/// Parsed once at startup from the `CONNECT_TRUSTED_PROXIES` env var and stored +/// in `AppState`. Membership is exact-IP; a peer not in this set is treated as a +/// direct (untrusted) client and its forwarding headers are ignored. +#[derive(Debug, Clone)] +pub struct TrustedProxies { + proxies: HashSet, } -/// Extract IP address as string -#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/ -pub fn extract_ip_string(addr: &SocketAddr) -> String { - addr.ip().to_string() +impl TrustedProxies { + /// Build a trusted-proxy set from an explicit list of IPs. + pub fn new(proxies: HashSet) -> Self { + Self { proxies } + } + + /// The default trusted set: IPv4 and IPv6 loopback. NPM terminates TLS on + /// the same host and proxies to GuruConnect over loopback, so loopback is the + /// only peer we trust unless `CONNECT_TRUSTED_PROXIES` overrides it. + pub fn default_loopback() -> Self { + let mut proxies = HashSet::with_capacity(2); + proxies.insert(IpAddr::V4(Ipv4Addr::LOCALHOST)); // 127.0.0.1 + proxies.insert(IpAddr::V6(Ipv6Addr::LOCALHOST)); // ::1 + Self { proxies } + } + + /// Parse the trusted-proxy set from a comma-separated env-var value. + /// + /// Empty/whitespace entries are skipped; unparseable entries are skipped with + /// a warning (a typo must not silently widen or break trust). If the input + /// yields no usable IPs at all (unset, empty, or all-garbage), the default + /// loopback set is returned so the server is never left with an empty trust + /// set (which would make every request look like it came directly from the + /// proxy peer). + pub fn from_env_value(raw: Option<&str>) -> Self { + let raw = match raw { + Some(s) => s, + None => return Self::default_loopback(), + }; + + let mut proxies = HashSet::new(); + for entry in raw.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + match entry.parse::() { + Ok(ip) => { + proxies.insert(ip); + } + Err(_) => { + tracing::warn!( + "Ignoring unparseable entry in CONNECT_TRUSTED_PROXIES: {:?}", + entry + ); + } + } + } + + if proxies.is_empty() { + tracing::warn!( + "CONNECT_TRUSTED_PROXIES contained no usable IPs; \ + falling back to default loopback trust set" + ); + return Self::default_loopback(); + } + + Self { proxies } + } + + /// Is `ip` a trusted proxy? + pub fn is_trusted(&self, ip: &IpAddr) -> bool { + self.proxies.contains(ip) + } + + /// Sorted, comma-joined view of the trusted set for startup logging. + pub fn describe(&self) -> String { + let mut ips: Vec = self.proxies.iter().map(|ip| ip.to_string()).collect(); + ips.sort(); + ips.join(", ") + } +} + +impl Default for TrustedProxies { + fn default() -> Self { + Self::default_loopback() + } +} + +/// Resolve the real client `IpAddr` for a request. +/// +/// `peer` is the immediate TCP peer (from axum's [`ConnectInfo`]), +/// `headers` are the request headers, and `trusted` is the configured +/// trusted-proxy allowlist. +/// +/// See the module docs for the full trust model. In short: +/// - peer NOT trusted → return `peer.ip()` (headers ignored — spoofable); +/// - peer trusted → `X-Real-IP`, else rightmost-untrusted `X-Forwarded-For` +/// entry, else `peer.ip()`. +pub fn client_ip(peer: &SocketAddr, headers: &HeaderMap, trusted: &TrustedProxies) -> IpAddr { + let peer_ip = peer.ip(); + + // Untrusted peer: a direct client could set any forwarding header, so they + // are not evidence of anything. Use what the OS actually observed. + if !trusted.is_trusted(&peer_ip) { + return peer_ip; + } + + // Trusted peer: prefer the single-value X-Real-IP if the proxy set it. + if let Some(ip) = header_single_ip(headers, X_REAL_IP) { + return ip; + } + + // Otherwise walk X-Forwarded-For right-to-left, skipping trusted hops, and + // return the first (rightmost) entry that is not itself a trusted proxy — + // that is the address the outermost trusted proxy observed and a client + // cannot forge past it. + if let Some(ip) = xff_rightmost_untrusted(headers, trusted) { + return ip; + } + + // No usable forwarding header — fall back to the peer. + peer_ip +} + +/// Parse a single-value IP header (e.g. `X-Real-IP`). Returns `None` if the +/// header is absent, not valid UTF-8, or not a parseable `IpAddr`. +fn header_single_ip(headers: &HeaderMap, name: &str) -> Option { + let value = headers.get(name)?; + let text = value.to_str().ok()?.trim(); + text.parse::().ok() +} + +/// From `X-Forwarded-For`, return the rightmost entry that is NOT a trusted +/// proxy. Trusted hops on the right are the chain of proxies between us and the +/// client; the first non-trusted entry walking leftward is the real client as +/// observed by the outermost trusted proxy. Unparseable tokens are skipped. +/// +/// Multiple `X-Forwarded-For` headers (or one header with comma-separated +/// values) are flattened into a single left-to-right list before walking right +/// to left, so a proxy that appends a second header line is handled correctly. +fn xff_rightmost_untrusted(headers: &HeaderMap, trusted: &TrustedProxies) -> Option { + // Collect all XFF tokens in wire order (left = closest to original client, + // right = closest to us). + let mut tokens: Vec = Vec::new(); + for value in headers.get_all(X_FORWARDED_FOR).iter() { + let text = match value.to_str() { + Ok(t) => t, + Err(_) => continue, + }; + for raw in text.split(',') { + let raw = raw.trim(); + if raw.is_empty() { + continue; + } + // XFF entries are bare IPs in this deployment; ignore anything that + // does not parse (e.g. obfuscated identifiers, port suffixes). + if let Ok(ip) = raw.parse::() { + tokens.push(ip); + } + } + } + + // Walk right-to-left, skipping trusted proxies, and return the first client + // address (the hop the outermost trusted proxy actually saw). + tokens.iter().rev().find(|ip| !trusted.is_trusted(ip)).copied() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + fn peer(ip: [u8; 4]) -> SocketAddr { + SocketAddr::from((ip, 40000)) + } + + fn v4(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + + fn headers_with(name: &str, value: &str) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + value.parse().unwrap(), + ); + h + } + + /// Default trusted set is loopback (v4 + v6). + #[test] + fn default_trusts_loopback() { + let t = TrustedProxies::default_loopback(); + assert!(t.is_trusted(&IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(t.is_trusted(&IpAddr::V6(Ipv6Addr::LOCALHOST))); + assert!(!t.is_trusted(&v4(203, 0, 113, 7))); + } + + /// Unset env -> default loopback trust. + #[test] + fn from_env_none_defaults_to_loopback() { + let t = TrustedProxies::from_env_value(None); + assert!(t.is_trusted(&IpAddr::V4(Ipv4Addr::LOCALHOST))); + assert!(t.is_trusted(&IpAddr::V6(Ipv6Addr::LOCALHOST))); + } + + /// Empty/whitespace/all-garbage env -> default loopback trust (never empty). + #[test] + fn from_env_empty_or_garbage_defaults_to_loopback() { + for raw in ["", " ", " , ,, ", "not-an-ip, also-bad"] { + let t = TrustedProxies::from_env_value(Some(raw)); + assert!( + t.is_trusted(&IpAddr::V4(Ipv4Addr::LOCALHOST)), + "input {raw:?} should fall back to loopback" + ); + } + } + + /// Valid env list parses; a garbage entry alongside valid ones is skipped, + /// and loopback is NOT added implicitly when an explicit set is given. + #[test] + fn from_env_parses_valid_and_skips_garbage() { + let t = TrustedProxies::from_env_value(Some("10.0.0.1, garbage, 172.16.3.1")); + assert!(t.is_trusted(&v4(10, 0, 0, 1))); + assert!(t.is_trusted(&v4(172, 16, 3, 1))); + // Explicit set given -> loopback only trusted if it was listed. + assert!(!t.is_trusted(&IpAddr::V4(Ipv4Addr::LOCALHOST))); + } + + /// SECURITY: a header-bearing request from an UNTRUSTED peer cannot spoof its + /// IP — the function returns the peer and ignores the forwarding header. + #[test] + fn untrusted_peer_cannot_spoof_via_header() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([203, 0, 113, 9]); // not loopback -> untrusted + + let h = headers_with(X_FORWARDED_FOR, "1.2.3.4"); + assert_eq!(client_ip(&p, &h, &trusted), v4(203, 0, 113, 9)); + + let h = headers_with(X_REAL_IP, "5.6.7.8"); + assert_eq!(client_ip(&p, &h, &trusted), v4(203, 0, 113, 9)); + } + + /// Trusted peer + X-Real-IP -> that IP. + #[test] + fn trusted_peer_uses_x_real_ip() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); // loopback -> trusted + let h = headers_with(X_REAL_IP, "198.51.100.23"); + assert_eq!(client_ip(&p, &h, &trusted), v4(198, 51, 100, 23)); + } + + /// X-Real-IP takes precedence over X-Forwarded-For when both are present. + #[test] + fn trusted_peer_prefers_x_real_ip_over_xff() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); + let mut h = headers_with(X_REAL_IP, "198.51.100.23"); + h.insert(X_FORWARDED_FOR, "9.9.9.9".parse().unwrap()); + assert_eq!(client_ip(&p, &h, &trusted), v4(198, 51, 100, 23)); + } + + /// Trusted peer + XFF -> rightmost entry that is not a trusted proxy. + #[test] + fn trusted_peer_xff_rightmost_untrusted_single_hop() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); + // Client appended its own value, then the real proxy appended 127.0.0.1. + let h = headers_with(X_FORWARDED_FOR, "203.0.113.5, 127.0.0.1"); + assert_eq!(client_ip(&p, &h, &trusted), v4(203, 0, 113, 5)); + } + + /// Multiple trusted hops on the right are all skipped; the rightmost + /// non-trusted entry is the observed client. + #[test] + fn trusted_peer_xff_skips_multiple_trusted_hops() { + // Trust loopback AND an internal LB at 10.0.0.2. + let mut set = HashSet::new(); + set.insert(IpAddr::V4(Ipv4Addr::LOCALHOST)); + set.insert(v4(10, 0, 0, 2)); + let trusted = TrustedProxies::new(set); + + let p = peer([127, 0, 0, 1]); + // Real client 198.51.100.7, then through LB 10.0.0.2, then to us (127.0.0.1). + let h = headers_with(X_FORWARDED_FOR, "198.51.100.7, 10.0.0.2, 127.0.0.1"); + assert_eq!(client_ip(&p, &h, &trusted), v4(198, 51, 100, 7)); + } + + /// A client that pre-seeds a forged left-most entry cannot win: the rightmost + /// untrusted entry is still the address the trusted proxy observed. + #[test] + fn trusted_peer_xff_forged_left_entry_ignored() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); + // Attacker pre-set "1.1.1.1"; the proxy observed them as 203.0.113.5 and + // appended that, then 127.0.0.1. Rightmost-untrusted = 203.0.113.5. + let h = headers_with(X_FORWARDED_FOR, "1.1.1.1, 203.0.113.5, 127.0.0.1"); + assert_eq!(client_ip(&p, &h, &trusted), v4(203, 0, 113, 5)); + } + + /// Trusted peer, no forwarding header -> fall back to the peer IP. + #[test] + fn trusted_peer_no_header_falls_back_to_peer() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); + let h = HeaderMap::new(); + assert_eq!(client_ip(&p, &h, &trusted), v4(127, 0, 0, 1)); + } + + /// Trusted peer, empty / garbage forwarding headers -> fall back to peer. + #[test] + fn trusted_peer_garbage_header_falls_back_to_peer() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); + + // Empty XFF. + let h = headers_with(X_FORWARDED_FOR, " "); + assert_eq!(client_ip(&p, &h, &trusted), v4(127, 0, 0, 1)); + + // Garbage XFF tokens (none parse). + let h = headers_with(X_FORWARDED_FOR, "not-an-ip, also-bad"); + assert_eq!(client_ip(&p, &h, &trusted), v4(127, 0, 0, 1)); + + // Garbage X-Real-IP -> ignored, no XFF -> peer. + let h = headers_with(X_REAL_IP, "nonsense"); + assert_eq!(client_ip(&p, &h, &trusted), v4(127, 0, 0, 1)); + } + + /// XFF consisting ONLY of trusted hops -> nothing untrusted to return -> peer. + #[test] + fn trusted_peer_xff_all_trusted_falls_back_to_peer() { + let trusted = TrustedProxies::default_loopback(); + let p = peer([127, 0, 0, 1]); + let h = headers_with(X_FORWARDED_FOR, "127.0.0.1, 127.0.0.1"); + assert_eq!(client_ip(&p, &h, &trusted), v4(127, 0, 0, 1)); + } }