Skip to main content

sl_map_web/
client_ip.rs

1//! Axum extractor that resolves the real client IP address, honouring
2//! `X-Forwarded-For` or `Forwarded` only when the direct peer is in the
3//! configured `trusted_proxies` list.
4//!
5//! Every proxy in that list is assumed to **strip and replace** any
6//! client-supplied `X-Forwarded-For` / `Forwarded` header rather than
7//! append to it. See [`crate::config::Config::trusted_proxies`] for the
8//! full security contract — without that assumption the extractor
9//! cannot tell a spoofed loopback / private / link-local entry from a
10//! real client.
11
12use std::net::{IpAddr, SocketAddr};
13
14use axum::extract::{ConnectInfo, FromRequestParts};
15use axum::http::request::Parts;
16
17use crate::config::ForwardedHeader;
18use crate::state::AppState;
19
20/// Resolved client IP address, ready for logging or storage in the
21/// `sessions` row.
22#[derive(Debug, Clone, Copy)]
23pub struct ClientIp(pub IpAddr);
24
25impl std::fmt::Display for ClientIp {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        write!(f, "{}", self.0)
28    }
29}
30
31impl FromRequestParts<AppState> for ClientIp {
32    type Rejection = std::convert::Infallible;
33
34    async fn from_request_parts(
35        parts: &mut Parts,
36        state: &AppState,
37    ) -> Result<Self, Self::Rejection> {
38        // Extracting `ConnectInfo<SocketAddr>` requires that the server was
39        // started with `.into_make_service_with_connect_info::<SocketAddr>()`.
40        // If that's missing the extractor returns an error — fall back to an
41        // unspecified address so request handling continues; client IP is
42        // never used for auth decisions, only for audit logging.
43        let peer = ConnectInfo::<SocketAddr>::from_request_parts(parts, state)
44            .await
45            .map_or(
46                IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
47                |ConnectInfo(addr)| addr.ip(),
48            );
49
50        let cfg = &state.config;
51        if matches!(cfg.forwarded_header, ForwardedHeader::None)
52            || !is_trusted_proxy(&cfg.trusted_proxies, peer)
53        {
54            return Ok(Self(peer));
55        }
56
57        let resolved = match cfg.forwarded_header {
58            ForwardedHeader::XForwardedFor => extract_x_forwarded_for(parts, &cfg.trusted_proxies),
59            ForwardedHeader::Forwarded => extract_forwarded(parts, &cfg.trusted_proxies),
60            ForwardedHeader::None => None,
61        };
62
63        Ok(Self(resolved.unwrap_or(peer)))
64    }
65}
66
67/// True if `ip` matches any of the configured trusted-proxy CIDR ranges.
68fn is_trusted_proxy(networks: &[ipnet::IpNet], ip: IpAddr) -> bool {
69    networks.iter().any(|net| net.contains(&ip))
70}
71
72/// Walk the comma-separated `X-Forwarded-For` chain from the right, skipping
73/// hops that are themselves in the trusted-proxies list, and return the
74/// first non-trusted entry. That's the original client; everything to its
75/// right is a proxy that's already in our trust list.
76fn extract_x_forwarded_for(parts: &Parts, trusted: &[ipnet::IpNet]) -> Option<IpAddr> {
77    let header = parts.headers.get_all("x-forwarded-for");
78    // Join all header instances together; the RFC allows multiple headers
79    // each with comma-separated entries.
80    let combined: Vec<String> = header
81        .iter()
82        .filter_map(|v| v.to_str().ok())
83        .flat_map(|s| s.split(','))
84        .map(|s| s.trim().to_owned())
85        .filter(|s| !s.is_empty())
86        .collect();
87    let mut iter = combined.into_iter().rev();
88    while let Some(entry) = iter.next() {
89        match entry.parse::<IpAddr>() {
90            Ok(ip) => {
91                if is_trusted_proxy(trusted, ip) {
92                    continue;
93                }
94                return Some(ip);
95            }
96            Err(err) => {
97                // The entry comes from an attacker-controlled header.
98                // Escape control chars before logging so embedded
99                // newlines / ANSI escapes cannot inject fake log lines
100                // or hijack a tailing terminal (same family as L5).
101                // Demoted to `debug!` so a flood of malformed headers
102                // cannot amplify into a warn-log DoS.
103                let safe: String = entry.chars().flat_map(char::escape_debug).collect();
104                tracing::debug!(
105                    "ignoring un-parseable X-Forwarded-For entry {safe:?}: {err}; remaining chain ignored"
106                );
107                drop(iter);
108                return None;
109            }
110        }
111    }
112    None
113}
114
115/// Walk the RFC 7239 `Forwarded` header from the right, picking the first
116/// `for=` parameter whose value isn't itself a trusted proxy.
117fn extract_forwarded(parts: &Parts, trusted: &[ipnet::IpNet]) -> Option<IpAddr> {
118    let entries: Vec<String> = parts
119        .headers
120        .get_all("forwarded")
121        .iter()
122        .filter_map(|v| v.to_str().ok())
123        .flat_map(|s| s.split(','))
124        .map(|s| s.trim().to_owned())
125        .filter(|s| !s.is_empty())
126        .collect();
127    let mut iter = entries.into_iter().rev();
128    while let Some(entry) = iter.next() {
129        let Some(for_value) = forwarded_for_param(&entry) else {
130            continue;
131        };
132        match parse_forwarded_for_node(&for_value) {
133            Some(ip) => {
134                if is_trusted_proxy(trusted, ip) {
135                    continue;
136                }
137                return Some(ip);
138            }
139            None => {
140                // Attacker-controlled; same rationale as the
141                // X-Forwarded-For branch above.
142                let safe: String = for_value.chars().flat_map(char::escape_debug).collect();
143                tracing::debug!(
144                    "ignoring un-parseable Forwarded for= node {safe:?}; remaining chain ignored"
145                );
146                drop(iter);
147                return None;
148            }
149        }
150    }
151    None
152}
153
154/// Find the `for=` parameter inside a single `Forwarded` entry. Parameters
155/// are semicolon-separated, names are case-insensitive, values may be
156/// quoted strings.
157fn forwarded_for_param(entry: &str) -> Option<String> {
158    for raw in entry.split(';') {
159        let raw = raw.trim();
160        let Some((name, value)) = raw.split_once('=') else {
161            continue;
162        };
163        if name.trim().eq_ignore_ascii_case("for") {
164            let v = value.trim();
165            let stripped = v.strip_prefix('"').and_then(|s| s.strip_suffix('"'));
166            return Some(stripped.unwrap_or(v).to_owned());
167        }
168    }
169    None
170}
171
172/// Parse an RFC 7239 node identifier (IP, optionally bracketed IPv6,
173/// optionally with `:port`) back into an [`IpAddr`].
174fn parse_forwarded_for_node(node: &str) -> Option<IpAddr> {
175    let trimmed = node.trim();
176    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
177        return None;
178    }
179    // Bracketed IPv6: "[::1]" or "[::1]:1234".
180    if let Some(rest) = trimmed.strip_prefix('[') {
181        if let Some((inside, _after)) = rest.split_once(']') {
182            return inside.parse().ok();
183        }
184        return None;
185    }
186    // Try the whole thing as an IP first (covers bare IPv6 without brackets
187    // and bare IPv4 without port).
188    if let Ok(ip) = trimmed.parse::<IpAddr>() {
189        return Some(ip);
190    }
191    // IPv4 with port: "1.2.3.4:1234"
192    if let Some((host, _port)) = trimmed.rsplit_once(':')
193        && let Ok(ip) = host.parse::<IpAddr>()
194    {
195        return Some(ip);
196    }
197    None
198}