2026-04-26 02:27:45 +02:00
|
|
|
//! Trusted-proxy CIDR list and client IP resolution.
|
|
|
|
|
//!
|
|
|
|
|
//! Set `OXICLOUD_TRUST_PROXY_CIDR` to a comma-separated list of CIDR blocks
|
|
|
|
|
//! whose source IPs are trusted to set `X-Forwarded-For` / `X-Real-Ip`:
|
|
|
|
|
//!
|
|
|
|
|
//! ```text
|
|
|
|
|
//! OXICLOUD_TRUST_PROXY_CIDR=127.0.0.1/32,10.0.0.0/8,172.16.0.0/12,::1/128
|
|
|
|
|
//! ```
|
|
|
|
|
//!
|
|
|
|
|
//! When the TCP peer address falls inside one of those CIDRs the leftmost
|
|
|
|
|
//! entry of `X-Forwarded-For` (or `X-Real-Ip`) is used as the effective
|
|
|
|
|
//! client IP. When the peer is **not** trusted, or no proxy headers are
|
|
|
|
|
//! present, the raw TCP peer address is returned.
|
|
|
|
|
//!
|
|
|
|
|
//! IPv4-mapped IPv6 addresses (`::ffff:x.x.x.x`) are automatically
|
|
|
|
|
//! normalised to their IPv4 equivalent before CIDR lookup.
|
|
|
|
|
|
|
|
|
|
use axum::extract::ConnectInfo;
|
|
|
|
|
use axum::http::Request;
|
|
|
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
|
|
|
|
use std::sync::OnceLock;
|
|
|
|
|
|
|
|
|
|
// ─── CIDR list ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
static TRUSTED_CIDRS: OnceLock<Vec<(IpAddr, u8)>> = OnceLock::new();
|
|
|
|
|
|
|
|
|
|
fn trusted_cidrs() -> &'static [(IpAddr, u8)] {
|
|
|
|
|
TRUSTED_CIDRS.get_or_init(|| {
|
|
|
|
|
let mut cidrs: Vec<(IpAddr, u8)> = std::env::var("OXICLOUD_TRUST_PROXY_CIDR")
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.split(',')
|
|
|
|
|
.filter_map(|s| parse_cidr(s.trim()))
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
// Backward-compat: OXICLOUD_TRUST_PROXY_HEADERS=true trusts all source IPs.
|
|
|
|
|
let legacy = std::env::var("OXICLOUD_TRUST_PROXY_HEADERS")
|
|
|
|
|
.map(|v| v == "true" || v == "1")
|
|
|
|
|
.unwrap_or(false);
|
|
|
|
|
if legacy {
|
|
|
|
|
tracing::warn!(
|
|
|
|
|
"OXICLOUD_TRUST_PROXY_HEADERS is deprecated — \
|
|
|
|
|
use OXICLOUD_TRUST_PROXY_CIDR to restrict trusted proxy source IPs. \
|
|
|
|
|
Falling back to trust all IPs (0.0.0.0/0 and ::/0)."
|
|
|
|
|
);
|
|
|
|
|
cidrs.push(("0.0.0.0".parse().unwrap(), 0));
|
|
|
|
|
cidrs.push(("::".parse().unwrap(), 0));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cidrs
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_cidr(s: &str) -> Option<(IpAddr, u8)> {
|
|
|
|
|
let (addr_s, prefix_s) = s.split_once('/')?;
|
|
|
|
|
let addr: IpAddr = addr_s.parse().ok()?;
|
|
|
|
|
let prefix: u8 = prefix_s.parse().ok()?;
|
|
|
|
|
let max = if addr.is_ipv4() { 32 } else { 128 };
|
|
|
|
|
if prefix > max {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
Some((addr, prefix))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── CIDR matching ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
fn ipv4_in_cidr(base: Ipv4Addr, prefix: u8, ip: Ipv4Addr) -> bool {
|
|
|
|
|
if prefix == 0 {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
let shift = 32 - u32::from(prefix);
|
|
|
|
|
let mask = !0u32 << shift;
|
|
|
|
|
(u32::from(base) & mask) == (u32::from(ip) & mask)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ipv6_in_cidr(base: Ipv6Addr, prefix: u8, ip: Ipv6Addr) -> bool {
|
|
|
|
|
if prefix == 0 {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
let shift = 128 - u32::from(prefix);
|
|
|
|
|
let mask = !0u128 << shift;
|
|
|
|
|
(u128::from(base) & mask) == (u128::from(ip) & mask)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Normalise an IP to IPv4 if it is an IPv4-mapped IPv6 address.
|
|
|
|
|
fn normalise(ip: IpAddr) -> IpAddr {
|
|
|
|
|
if let IpAddr::V6(v6) = ip
|
|
|
|
|
&& let Some(v4) = v6.to_ipv4_mapped()
|
|
|
|
|
{
|
|
|
|
|
return IpAddr::V4(v4);
|
|
|
|
|
}
|
|
|
|
|
ip
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn cidr_contains(base: IpAddr, prefix: u8, target: IpAddr) -> bool {
|
|
|
|
|
let base = normalise(base);
|
|
|
|
|
let target = normalise(target);
|
|
|
|
|
match (base, target) {
|
|
|
|
|
(IpAddr::V4(b), IpAddr::V4(t)) => ipv4_in_cidr(b, prefix, t),
|
|
|
|
|
(IpAddr::V6(b), IpAddr::V6(t)) => ipv6_in_cidr(b, prefix, t),
|
|
|
|
|
_ => false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Logs the loaded CIDR list at INFO level. Call once at startup, after the
|
|
|
|
|
/// tracing subscriber is initialised, to make the effective configuration
|
|
|
|
|
/// visible in the logs.
|
|
|
|
|
pub fn log_config() {
|
|
|
|
|
let cidrs = trusted_cidrs();
|
|
|
|
|
if cidrs.is_empty() {
|
|
|
|
|
tracing::info!(
|
|
|
|
|
"trusted proxy CIDRs: none — X-Forwarded-For/X-Real-Ip headers will be ignored"
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
let list: Vec<String> = cidrs
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(addr, prefix)| format!("{addr}/{prefix}"))
|
|
|
|
|
.collect();
|
|
|
|
|
tracing::info!("trusted proxy CIDRs: {}", list.join(", "));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns `true` when `peer` falls inside one of the configured CIDR ranges.
|
|
|
|
|
pub fn is_trusted_proxy(peer: IpAddr) -> bool {
|
|
|
|
|
let cidrs = trusted_cidrs();
|
|
|
|
|
if cidrs.is_empty() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
cidrs
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|&(base, prefix)| cidr_contains(base, prefix, peer))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── IP extraction ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
/// Resolve the effective client IP for a request.
|
|
|
|
|
///
|
|
|
|
|
/// * `include_port` — when `true` the returned string for direct (non-proxied)
|
|
|
|
|
/// connections includes the port, e.g. `"127.0.0.1:12345"`. Pass `false`
|
|
|
|
|
/// for contexts that only need the bare IP (e.g. rate-limiting keys).
|
|
|
|
|
pub fn client_ip<B>(req: &Request<B>, include_port: bool) -> String {
|
|
|
|
|
let peer: Option<SocketAddr> = req
|
|
|
|
|
.extensions()
|
|
|
|
|
.get::<ConnectInfo<SocketAddr>>()
|
|
|
|
|
.map(|ci| ci.0);
|
|
|
|
|
|
2026-04-27 12:22:59 -07:00
|
|
|
client_ip_from_parts(req.headers(), peer, include_port)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Same as [`client_ip`], but operates on already-extracted parts (headers
|
|
|
|
|
/// plus an optional TCP peer). Handlers that don't take a full `Request<B>`,
|
|
|
|
|
/// e.g. those that consume the body via `Json<…>`, can still derive a stable
|
|
|
|
|
/// client identifier with this entry point.
|
|
|
|
|
pub fn client_ip_from_parts(
|
|
|
|
|
headers: &axum::http::HeaderMap,
|
|
|
|
|
peer: Option<SocketAddr>,
|
|
|
|
|
include_port: bool,
|
|
|
|
|
) -> String {
|
2026-04-26 02:27:45 +02:00
|
|
|
if let Some(peer_addr) = peer {
|
|
|
|
|
if is_trusted_proxy(peer_addr.ip()) {
|
|
|
|
|
// Try X-Forwarded-For first (leftmost = original client)
|
2026-04-27 12:22:59 -07:00
|
|
|
if let Some(xff) = headers
|
2026-04-26 02:27:45 +02:00
|
|
|
.get("x-forwarded-for")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
&& let Some(ip) = xff
|
|
|
|
|
.split(',')
|
|
|
|
|
.next()
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
{
|
|
|
|
|
return ip.to_string();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Then X-Real-Ip
|
2026-04-27 12:22:59 -07:00
|
|
|
if let Some(xri) = headers
|
2026-04-26 02:27:45 +02:00
|
|
|
.get("x-real-ip")
|
|
|
|
|
.and_then(|v| v.to_str().ok())
|
|
|
|
|
.map(str::trim)
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
{
|
|
|
|
|
return xri.to_string();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return if include_port {
|
|
|
|
|
peer_addr.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
peer_addr.ip().to_string()
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
"unknown".to_string()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Tests ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ipv4_cidr_host() {
|
|
|
|
|
let base: Ipv4Addr = "192.168.1.1".parse().unwrap();
|
|
|
|
|
assert!(ipv4_in_cidr(base, 32, "192.168.1.1".parse().unwrap()));
|
|
|
|
|
assert!(!ipv4_in_cidr(base, 32, "192.168.1.2".parse().unwrap()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ipv4_cidr_subnet() {
|
|
|
|
|
let base: Ipv4Addr = "10.0.0.0".parse().unwrap();
|
|
|
|
|
assert!(ipv4_in_cidr(base, 8, "10.255.255.255".parse().unwrap()));
|
|
|
|
|
assert!(!ipv4_in_cidr(base, 8, "11.0.0.1".parse().unwrap()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ipv4_cidr_any() {
|
|
|
|
|
let base: Ipv4Addr = "0.0.0.0".parse().unwrap();
|
|
|
|
|
assert!(ipv4_in_cidr(base, 0, "1.2.3.4".parse().unwrap()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ipv6_cidr_loopback() {
|
|
|
|
|
let base: Ipv6Addr = "::1".parse().unwrap();
|
|
|
|
|
assert!(ipv6_in_cidr(base, 128, "::1".parse().unwrap()));
|
|
|
|
|
assert!(!ipv6_in_cidr(base, 128, "::2".parse().unwrap()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn ipv4_mapped_matches_v4_cidr() {
|
|
|
|
|
// ::ffff:127.0.0.1 should match 127.0.0.1/8
|
|
|
|
|
let base = IpAddr::V4("127.0.0.0".parse().unwrap());
|
|
|
|
|
let mapped = IpAddr::V6("::ffff:127.0.0.1".parse().unwrap());
|
|
|
|
|
assert!(cidr_contains(base, 8, mapped));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_cidr_valid() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_cidr("10.0.0.0/8"),
|
|
|
|
|
Some((IpAddr::V4("10.0.0.0".parse().unwrap()), 8))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_cidr("::1/128"),
|
|
|
|
|
Some((IpAddr::V6("::1".parse().unwrap()), 128))
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_cidr_invalid_prefix() {
|
|
|
|
|
assert!(parse_cidr("10.0.0.0/33").is_none());
|
|
|
|
|
assert!(parse_cidr("::1/129").is_none());
|
|
|
|
|
assert!(parse_cidr("notanip/8").is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|