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) <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,12 @@ pub struct AppState {
|
|||||||
/// Per-IP rate limiters + brute-force lockout (Task 4). Shared (Arc-backed
|
/// Per-IP rate limiters + brute-force lockout (Task 4). Shared (Arc-backed
|
||||||
/// internally) so cloning AppState shares the same counters.
|
/// internally) so cloning AppState shares the same counters.
|
||||||
pub rate_limits: middleware::RateLimitState,
|
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<utils::ip_extract::TrustedProxies>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware to inject JWT config and token blacklist into request extensions
|
/// 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");
|
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
|
// Initialize Prometheus metrics
|
||||||
let mut registry = Registry::default();
|
let mut registry = Registry::default();
|
||||||
let metrics = Arc::new(metrics::Metrics::new(&mut registry));
|
let metrics = Arc::new(metrics::Metrics::new(&mut registry));
|
||||||
@@ -267,6 +287,7 @@ async fn main() -> Result<()> {
|
|||||||
registry,
|
registry,
|
||||||
start_time,
|
start_time,
|
||||||
rate_limits: middleware::RateLimitState::new(),
|
rate_limits: middleware::RateLimitState::new(),
|
||||||
|
trusted_proxies,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build router
|
// Build router
|
||||||
@@ -508,9 +529,14 @@ struct ValidateParams {
|
|||||||
async fn validate_code(
|
async fn validate_code(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
) -> Json<CodeValidation> {
|
) -> Json<CodeValidation> {
|
||||||
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
|
// PREVIEW ONLY: validate_code inspects the in-memory code state and does NOT
|
||||||
// consume the code (single-use consumption happens at agent BIND, in
|
// consume the code (single-use consumption happens at agent BIND, in
|
||||||
|
|||||||
@@ -19,12 +19,15 @@
|
|||||||
//! the brute-force defense for the support-code space (the code-validate
|
//! the brute-force defense for the support-code space (the code-validate
|
||||||
//! route reports per-attempt success/failure into it).
|
//! route reports per-attempt success/failure into it).
|
||||||
//!
|
//!
|
||||||
//! Client IP is taken from axum's [`ConnectInfo<SocketAddr>`] (the same source
|
//! Client IP is the REAL client IP from the shared trusted-proxy-aware extractor
|
||||||
//! the relay uses for `client_ip`). `X-Forwarded-For` is intentionally NOT
|
//! ([`crate::utils::ip_extract::client_ip`]) — the same source the relay and the
|
||||||
//! trusted here: the server terminates behind a known reverse proxy (NPM), and
|
//! audit/event log use, so all three never drift. The extractor honors
|
||||||
//! honoring a client-settable header would let an attacker trivially rotate the
|
//! `X-Forwarded-For` / `X-Real-IP` ONLY when the TCP peer is a configured trusted
|
||||||
//! limiter key. If/when per-proxy XFF handling is needed it must be gated on a
|
//! proxy (default: loopback, since NPM runs on the same host); a header from an
|
||||||
//! trusted-proxy allowlist — tracked as a follow-up, not done blindly here.
|
//! 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
|
//! Memory is bounded by pruning expired entries opportunistically on each call
|
||||||
//! and capping the map size; an unbounded attacker rotating source IPs cannot
|
//! 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,
|
request: axum::extract::Request,
|
||||||
next: axum::middleware::Next,
|
next: axum::middleware::Next,
|
||||||
) -> Response {
|
) -> 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) {
|
if !state.rate_limits.login.check(ip) {
|
||||||
tracing::warn!("Rate limit exceeded on /api/auth/login from {}", ip);
|
tracing::warn!("Rate limit exceeded on /api/auth/login from {}", ip);
|
||||||
return too_many_requests(
|
return too_many_requests(
|
||||||
@@ -357,7 +360,7 @@ pub async fn change_password_rate_limit(
|
|||||||
request: axum::extract::Request,
|
request: axum::extract::Request,
|
||||||
next: axum::middleware::Next,
|
next: axum::middleware::Next,
|
||||||
) -> Response {
|
) -> 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) {
|
if !state.rate_limits.change_password.check(ip) {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"Rate limit exceeded on /api/auth/change-password from {}",
|
"Rate limit exceeded on /api/auth/change-password from {}",
|
||||||
@@ -389,7 +392,7 @@ pub async fn code_validate_rate_limit(
|
|||||||
request: axum::extract::Request,
|
request: axum::extract::Request,
|
||||||
next: axum::middleware::Next,
|
next: axum::middleware::Next,
|
||||||
) -> Response {
|
) -> 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.
|
// 1. Brute-force lockout takes precedence.
|
||||||
if state.rate_limits.code_validate_lockout.is_locked(ip) {
|
if state.rate_limits.code_validate_lockout.is_locked(ip) {
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ pub async fn agent_ws_handler(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Query(params): Query<AgentParams>,
|
Query(params): Query<AgentParams>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
// The CLIENT-SUPPLIED agent_id. For a per-agent-key (managed) agent this is
|
// 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());
|
.unwrap_or_else(|| agent_id.clone());
|
||||||
let support_code = params.support_code.clone();
|
let support_code = params.support_code.clone();
|
||||||
let api_key = params.api_key.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
|
// SECURITY: Agent must provide either a support code OR an API key
|
||||||
// Support code = ad-hoc support session (technician generated code)
|
// Support code = ad-hoc support session (technician generated code)
|
||||||
@@ -408,9 +414,13 @@ pub async fn viewer_ws_handler(
|
|||||||
ws: WebSocketUpgrade,
|
ws: WebSocketUpgrade,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: axum::http::HeaderMap,
|
||||||
Query(params): Query<ViewerParams>,
|
Query(params): Query<ViewerParams>,
|
||||||
) -> Result<impl IntoResponse, StatusCode> {
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
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
|
// 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.
|
// can never match a viewer token's `session_id` claim, so reject up front.
|
||||||
|
|||||||
@@ -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<SocketAddr>`] 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
|
/// Parsed once at startup from the `CONNECT_TRUSTED_PROXIES` env var and stored
|
||||||
/// ```rust
|
/// in `AppState`. Membership is exact-IP; a peer not in this set is treated as a
|
||||||
/// pub async fn handler(ConnectInfo(addr): ConnectInfo<SocketAddr>) {
|
/// direct (untrusted) client and its forwarding headers are ignored.
|
||||||
/// let ip = extract_ip(&addr);
|
#[derive(Debug, Clone)]
|
||||||
/// // Use ip for logging
|
pub struct TrustedProxies {
|
||||||
/// }
|
proxies: HashSet<IpAddr>,
|
||||||
/// ```
|
|
||||||
#[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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract IP address as string
|
impl TrustedProxies {
|
||||||
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
/// Build a trusted-proxy set from an explicit list of IPs.
|
||||||
pub fn extract_ip_string(addr: &SocketAddr) -> String {
|
pub fn new(proxies: HashSet<IpAddr>) -> Self {
|
||||||
addr.ip().to_string()
|
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::<IpAddr>() {
|
||||||
|
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<String> = 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<SocketAddr>`]),
|
||||||
|
/// `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<IpAddr> {
|
||||||
|
let value = headers.get(name)?;
|
||||||
|
let text = value.to_str().ok()?.trim();
|
||||||
|
text.parse::<IpAddr>().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<IpAddr> {
|
||||||
|
// Collect all XFF tokens in wire order (left = closest to original client,
|
||||||
|
// right = closest to us).
|
||||||
|
let mut tokens: Vec<IpAddr> = 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::<IpAddr>() {
|
||||||
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user