use std::net::IpAddr;
use std::sync::Arc;
use axum::body::Body;
use axum::extract::{ConnectInfo, Request};
use axum::middleware::Next;
use axum::Router;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RealIp(pub IpAddr);
#[derive(Clone, Debug)]
pub enum HeaderStrategy {
ForwardedRfc7239,
XForwardedFor,
XRealIp,
CfConnectingIp,
Auto,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TrustedRealIp(pub IpAddr);
#[derive(Debug, Clone, Copy)]
pub(crate) struct TrustedPeer;
#[derive(Debug, Clone, Copy)]
#[cfg_attr(
not(any(
all(feature = "tenancy", feature = "sso"),
feature = "admin-sso",
feature = "mcp"
)),
allow(dead_code)
)]
pub(crate) struct TrustedHops(usize);
#[cfg(any(
all(feature = "tenancy", feature = "sso"),
feature = "admin-sso",
feature = "mcp"
))]
pub(crate) fn trusted_forwarded<'a>(
headers: &'a axum::http::HeaderMap,
extensions: &axum::http::Extensions,
name: &str,
) -> Option<&'a str> {
extensions.get::<TrustedRealIp>()?;
let hops = extensions.get::<TrustedHops>().map_or(0, |h| h.0);
let mut values = Vec::new();
for line in headers.get_all(name) {
values.extend(line.to_str().ok()?.split(',').map(str::trim));
}
let at = values.len().checked_sub(1)?.saturating_sub(hops);
Some(values[at]).filter(|s| !s.is_empty())
}
#[derive(Clone, Debug)]
pub struct RealIpLayer {
pub strategy: HeaderStrategy,
trusted_proxies: Option<Vec<crate::ip_filter::CidrRange>>,
}
impl Default for RealIpLayer {
fn default() -> Self {
Self {
strategy: HeaderStrategy::Auto,
trusted_proxies: None,
}
}
}
impl RealIpLayer {
#[must_use]
pub fn new(strategy: HeaderStrategy) -> Self {
Self {
strategy,
trusted_proxies: None,
}
}
pub fn trust_proxies<I, S>(mut self, nets: I) -> Result<Self, crate::ip_filter::IpFilterError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.trusted_proxies = Some(crate::ip_filter::parse_all(nets)?);
Ok(self)
}
fn trusts(&self, peer: IpAddr) -> bool {
self.trusted_proxies
.as_ref()
.is_some_and(|nets| nets.iter().any(|n| n.contains(peer)))
}
#[cfg(test)]
fn resolve_trusted(&self, req: &Request<Body>) -> Option<IpAddr> {
self.resolve_trusted_hops(req).map(|(ip, _)| ip)
}
fn resolve_trusted_hops(&self, req: &Request<Body>) -> Option<(IpAddr, usize)> {
let chain: Vec<Option<IpAddr>> = match self.strategy {
HeaderStrategy::XForwardedFor | HeaderStrategy::Auto => {
header_hops(req, "x-forwarded-for")?
.map(|h| h.and_then(parse_ip_with_optional_port))
.collect()
}
HeaderStrategy::ForwardedRfc7239 => header_hops(req, "forwarded")?
.map(|h| h.and_then(rfc7239_for))
.collect(),
HeaderStrategy::XRealIp | HeaderStrategy::CfConnectingIp => {
return extract(req, &self.strategy).map(|ip| (ip, 0));
}
};
let mut rightmost = None;
for (walked, hop) in chain.iter().rev().enumerate() {
let ip = (*hop)?;
if !self.trusts(ip) {
return Some((ip, walked));
}
rightmost = rightmost.or(Some(ip));
}
rightmost.map(|ip| (ip, chain.len().saturating_sub(1)))
}
}
fn header_hops<'a>(
req: &'a Request<Body>,
name: &str,
) -> Option<impl Iterator<Item = Option<&'a str>>> {
let mut lines = req.headers().get_all(name).iter().peekable();
lines.peek()?;
Some(lines.flat_map(|v| {
v.as_bytes()
.split(|b| *b == b',')
.map(|hop| std::str::from_utf8(hop).ok())
}))
}
pub trait RealIpRouterExt {
#[must_use]
fn real_ip(self, layer: RealIpLayer) -> Self;
}
impl<S: Clone + Send + Sync + 'static> RealIpRouterExt for Router<S> {
fn real_ip(self, layer: RealIpLayer) -> Self {
let cfg = Arc::new(layer);
self.layer(axum::middleware::from_fn(
move |mut req: Request<Body>, next: Next| {
let cfg = cfg.clone();
async move {
let peer = req
.extensions()
.get::<ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.ip());
let trusted_peer = peer.is_some_and(|p| cfg.trusts(p));
if trusted_peer {
req.extensions_mut().insert(TrustedPeer);
}
let trusted = trusted_peer
.then(|| cfg.resolve_trusted_hops(&req))
.flatten();
if let Some((ip, hops)) = trusted {
req.extensions_mut().insert(RealIp(ip));
req.extensions_mut().insert(TrustedRealIp(ip));
req.extensions_mut().insert(TrustedHops(hops));
} else if let Some(ip) = extract(&req, &cfg.strategy) {
req.extensions_mut().insert(RealIp(ip));
}
next.run(req).await
}
},
))
}
}
fn extract(req: &Request<Body>, strategy: &HeaderStrategy) -> Option<IpAddr> {
let h = req.headers();
match strategy {
HeaderStrategy::ForwardedRfc7239 => {
parse_forwarded_rfc7239(h.get("forwarded").and_then(|v| v.to_str().ok())?)
}
HeaderStrategy::XForwardedFor => {
parse_x_forwarded_for(h.get("x-forwarded-for").and_then(|v| v.to_str().ok())?)
}
HeaderStrategy::XRealIp => h
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(parse_ip_with_optional_port),
HeaderStrategy::CfConnectingIp => h
.get("cf-connecting-ip")
.and_then(|v| v.to_str().ok())
.and_then(parse_ip_with_optional_port),
HeaderStrategy::Auto => extract(req, &HeaderStrategy::CfConnectingIp)
.or_else(|| extract(req, &HeaderStrategy::ForwardedRfc7239))
.or_else(|| extract(req, &HeaderStrategy::XForwardedFor))
.or_else(|| extract(req, &HeaderStrategy::XRealIp))
.or_else(|| {
req.extensions()
.get::<ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.ip())
}),
}
}
fn parse_forwarded_rfc7239(s: &str) -> Option<IpAddr> {
rfc7239_chain(s).first().copied().flatten()
}
fn rfc7239_chain(s: &str) -> Vec<Option<IpAddr>> {
s.split(',').map(rfc7239_for).collect()
}
fn rfc7239_for(element: &str) -> Option<IpAddr> {
for kv in element.trim().split(';') {
let (k, v) = kv.trim().split_once('=')?;
if k.eq_ignore_ascii_case("for") {
return parse_ip_with_optional_port(v.trim().trim_matches('"'));
}
}
None
}
fn parse_x_forwarded_for(s: &str) -> Option<IpAddr> {
forwarded_for_chain(s).first().copied().flatten()
}
fn forwarded_for_chain(s: &str) -> Vec<Option<IpAddr>> {
s.split(',').map(parse_ip_with_optional_port).collect()
}
fn parse_ip_with_optional_port(s: &str) -> Option<IpAddr> {
parse_ip_raw(s).map(|ip| ip.to_canonical())
}
fn parse_ip_raw(s: &str) -> Option<IpAddr> {
let s = s.trim();
if let Some(rest) = s.strip_prefix('[') {
let close = rest.find(']')?;
return rest[..close].parse().ok();
}
if s.matches(':').count() == 1 {
if let Some((ip, _port)) = s.rsplit_once(':') {
return ip.parse().ok();
}
}
s.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
fn req_with_header(name: &'static str, value: &str) -> Request<Body> {
Request::builder()
.header(name, value)
.body(Body::empty())
.unwrap()
}
#[test]
fn x_forwarded_for_picks_leftmost() {
let r = req_with_header("x-forwarded-for", "1.2.3.4, 10.0.0.1, 172.16.0.5");
let ip = extract(&r, &HeaderStrategy::XForwardedFor).unwrap();
assert_eq!(ip.to_string(), "1.2.3.4");
}
#[test]
fn x_forwarded_for_strips_ipv4_port() {
let r = req_with_header("x-forwarded-for", "203.0.113.7:51234, 10.0.0.1");
let ip = extract(&r, &HeaderStrategy::XForwardedFor).unwrap();
assert_eq!(ip.to_string(), "203.0.113.7");
}
#[test]
fn x_forwarded_for_handles_ipv6_brackets() {
let r = req_with_header("x-forwarded-for", "[2001:db8::1]:443, 10.0.0.1");
let ip = extract(&r, &HeaderStrategy::XForwardedFor).unwrap();
assert_eq!(ip.to_string(), "2001:db8::1");
}
#[test]
fn x_forwarded_for_bare_ipv6() {
let r = req_with_header("x-forwarded-for", "2001:db8::1");
let ip = extract(&r, &HeaderStrategy::XForwardedFor).unwrap();
assert_eq!(ip.to_string(), "2001:db8::1");
}
#[test]
fn x_real_ip_strategy() {
let r = req_with_header("x-real-ip", "198.51.100.42");
let ip = extract(&r, &HeaderStrategy::XRealIp).unwrap();
assert_eq!(ip.to_string(), "198.51.100.42");
}
#[test]
fn cf_connecting_ip_strategy() {
let r = req_with_header("cf-connecting-ip", "2606:4700::1");
let ip = extract(&r, &HeaderStrategy::CfConnectingIp).unwrap();
assert_eq!(ip.to_string(), "2606:4700::1");
}
#[test]
fn rfc7239_for_token_parses() {
let r = req_with_header("forwarded", "for=192.0.2.43, for=198.51.100.17");
let ip = extract(&r, &HeaderStrategy::ForwardedRfc7239).unwrap();
assert_eq!(ip.to_string(), "192.0.2.43");
}
#[test]
fn rfc7239_with_quoted_ipv6_port() {
let r = req_with_header("forwarded", r#"for="[2001:db8:cafe::17]:4711""#);
let ip = extract(&r, &HeaderStrategy::ForwardedRfc7239).unwrap();
assert_eq!(ip.to_string(), "2001:db8:cafe::17");
}
#[test]
fn rfc7239_ignores_other_keys() {
let r = req_with_header("forwarded", "by=10.0.0.1;for=203.0.113.7;proto=https");
let ip = extract(&r, &HeaderStrategy::ForwardedRfc7239).unwrap();
assert_eq!(ip.to_string(), "203.0.113.7");
}
#[test]
fn auto_picks_cloudflare_first_when_present() {
let r = Request::builder()
.header("x-forwarded-for", "1.1.1.1")
.header("cf-connecting-ip", "9.9.9.9")
.body(Body::empty())
.unwrap();
let ip = extract(&r, &HeaderStrategy::Auto).unwrap();
assert_eq!(ip.to_string(), "9.9.9.9");
}
#[test]
fn auto_falls_through_to_xff_when_no_cf_or_forwarded() {
let r = req_with_header("x-forwarded-for", "1.1.1.1");
let ip = extract(&r, &HeaderStrategy::Auto).unwrap();
assert_eq!(ip.to_string(), "1.1.1.1");
}
#[test]
fn no_headers_returns_none_when_no_connect_info() {
let r = Request::builder().body(Body::empty()).unwrap();
assert!(extract(&r, &HeaderStrategy::Auto).is_none());
}
fn trusting(strategy: HeaderStrategy) -> RealIpLayer {
RealIpLayer::new(strategy)
.trust_proxies(["10.0.0.0/8"])
.unwrap()
}
#[test]
fn rfc7239_trusted_walks_from_the_right() {
let r = req_with_header("forwarded", "for=1.1.1.1, for=203.0.113.7, for=10.0.0.2");
let ip = trusting(HeaderStrategy::ForwardedRfc7239).resolve_trusted(&r);
assert_eq!(ip.unwrap().to_string(), "203.0.113.7");
}
#[test]
fn trusted_chain_stops_at_an_unparseable_hop() {
let r = req_with_header("x-forwarded-for", "203.0.113.7, unknown, 10.0.0.2");
assert!(trusting(HeaderStrategy::XForwardedFor)
.resolve_trusted(&r)
.is_none());
}
#[test]
fn all_trusted_chain_yields_rightmost_hop() {
let r = req_with_header("x-forwarded-for", "10.0.0.3, 10.0.0.2");
let ip = trusting(HeaderStrategy::XForwardedFor).resolve_trusted(&r);
assert_eq!(ip.unwrap().to_string(), "10.0.0.2");
}
#[test]
fn non_ascii_hop_does_not_drop_the_chain() {
let v = axum::http::HeaderValue::from_bytes(b"caf\xc3\xa9, 203.0.113.7, 10.0.0.2").unwrap();
let r = Request::builder()
.header("x-forwarded-for", v)
.body(Body::empty())
.unwrap();
let ip = trusting(HeaderStrategy::XForwardedFor).resolve_trusted(&r);
assert_eq!(ip.unwrap().to_string(), "203.0.113.7");
}
#[test]
fn non_ascii_hop_stops_the_walk() {
let v = axum::http::HeaderValue::from_bytes(b"203.0.113.7, \xff, 10.0.0.2").unwrap();
let r = Request::builder()
.header("x-forwarded-for", v)
.body(Body::empty())
.unwrap();
assert!(trusting(HeaderStrategy::XForwardedFor)
.resolve_trusted(&r)
.is_none());
}
#[test]
fn trusted_x_real_ip_strips_port_and_canonicalises() {
let t = trusting(HeaderStrategy::XRealIp);
let r = req_with_header("x-real-ip", "203.0.113.7:8080");
assert_eq!(t.resolve_trusted(&r).unwrap().to_string(), "203.0.113.7");
let r = req_with_header("x-real-ip", "::ffff:198.51.100.1");
assert_eq!(t.resolve_trusted(&r).unwrap().to_string(), "198.51.100.1");
let r = req_with_header("x-forwarded-for", "198.51.100.1");
assert!(t.resolve_trusted(&r).is_none());
}
#[test]
fn trusted_cf_connecting_ip_strips_port_and_canonicalises() {
let t = trusting(HeaderStrategy::CfConnectingIp);
let r = req_with_header("cf-connecting-ip", "[2001:db8::1]:443");
assert_eq!(t.resolve_trusted(&r).unwrap().to_string(), "2001:db8::1");
let r = req_with_header("cf-connecting-ip", "::ffff:198.51.100.1");
assert_eq!(t.resolve_trusted(&r).unwrap().to_string(), "198.51.100.1");
let r = req_with_header("x-real-ip", "198.51.100.1");
assert!(t.resolve_trusted(&r).is_none());
}
#[test]
fn auto_trusted_ignores_client_sent_cf_header() {
let r = Request::builder()
.header("cf-connecting-ip", "9.9.9.9")
.header("x-forwarded-for", "203.0.113.7")
.body(Body::empty())
.unwrap();
let ip = trusting(HeaderStrategy::Auto).resolve_trusted(&r);
assert_eq!(ip.unwrap().to_string(), "203.0.113.7");
}
#[cfg(any(
all(feature = "tenancy", feature = "sso"),
feature = "admin-sso",
feature = "mcp"
))]
#[test]
fn trusted_forwarded_takes_the_hop_the_proxy_added() {
use axum::http::HeaderValue;
let mut ext = axum::http::Extensions::new();
ext.insert(TrustedRealIp("10.0.0.2".parse().unwrap()));
let mut h = axum::http::HeaderMap::new();
h.insert("x-forwarded-proto", HeaderValue::from_static("http, https"));
assert_eq!(
trusted_forwarded(&h, &ext, "x-forwarded-proto"),
Some("https")
);
h.append("x-forwarded-proto", HeaderValue::from_static("http"));
assert_eq!(
trusted_forwarded(&h, &ext, "x-forwarded-proto"),
Some("http")
);
h.insert(
"x-forwarded-host",
HeaderValue::from_static("evil.test, app.test"),
);
assert_eq!(
trusted_forwarded(&h, &ext, "x-forwarded-host"),
Some("app.test")
);
assert_eq!(
trusted_forwarded(&h, &axum::http::Extensions::new(), "x-forwarded-host"),
None
);
}
#[cfg(any(
all(feature = "tenancy", feature = "sso"),
feature = "admin-sso",
feature = "mcp"
))]
#[tokio::test]
async fn trusted_forwarded_skips_values_of_inner_trusted_proxies() {
use axum::routing::get;
use tower::ServiceExt;
async fn handler(req: Request<Body>) -> String {
let (parts, _) = req.into_parts();
let get = |n| trusted_forwarded(&parts.headers, &parts.extensions, n).unwrap_or("-");
format!("{} {}", get("x-forwarded-proto"), get("x-forwarded-host"))
}
let app = Router::new()
.route("/", get(handler))
.real_ip(trusting(HeaderStrategy::XForwardedFor));
let send = |xff: &'static str, proto: &'static str, host: &'static str| {
let app = app.clone();
async move {
let peer: std::net::SocketAddr = "10.0.0.2:4000".parse().unwrap();
let res = app
.oneshot(
Request::builder()
.uri("/")
.extension(ConnectInfo(peer))
.header("x-forwarded-for", xff)
.header("x-forwarded-proto", proto)
.header("x-forwarded-host", host)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let b = axum::body::to_bytes(res.into_body(), 1 << 16)
.await
.unwrap();
String::from_utf8(b.to_vec()).unwrap()
}
};
assert_eq!(
send(
"203.0.113.7, 10.0.0.1",
"http, https, http",
"evil.test, app.test, app.test"
)
.await,
"https app.test"
);
assert_eq!(
send("203.0.113.7", "http, https", "evil.test, app.test").await,
"https app.test"
);
assert_eq!(
send("203.0.113.7, 10.0.0.1", "https", "app.test").await,
"https app.test"
);
}
#[test]
fn ipv4_mapped_hop_is_canonicalised() {
let r = req_with_header("x-forwarded-for", "::ffff:203.0.113.7");
let ip = extract(&r, &HeaderStrategy::XForwardedFor).unwrap();
assert_eq!(ip.to_string(), "203.0.113.7");
}
#[test]
fn malformed_header_returns_none() {
let r = req_with_header("x-real-ip", "not-an-ip");
assert!(extract(&r, &HeaderStrategy::XRealIp).is_none());
}
#[tokio::test]
async fn middleware_inserts_realip_into_extensions() {
use axum::routing::get;
use axum::Extension;
use tower::ServiceExt;
async fn handler(Extension(RealIp(ip)): Extension<RealIp>) -> String {
ip.to_string()
}
let app = Router::new()
.route("/", get(handler))
.real_ip(RealIpLayer::default());
let resp = app
.oneshot(
Request::builder()
.uri("/")
.header("x-forwarded-for", "192.0.2.1, 10.0.0.1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), 200);
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 16)
.await
.unwrap();
assert_eq!(std::str::from_utf8(&bytes).unwrap(), "192.0.2.1");
}
}