micromegas_auth/client_ip.rs
1//! Resolves the client IP address from request headers/extensions.
2//!
3//! Moved down from `rust/public/src/servers/http_utils.rs::get_client_ip` so every
4//! `AuthProvider` (not just audit logging) can consult the same resolution: `micromegas-auth`
5//! cannot depend on the `public` crate (it's the other way around), so this logic has to live
6//! here for `IpAllowlist::allows` to use it. `get_client_ip` becomes a thin formatting wrapper
7//! over [`resolve_client_ip`].
8
9use std::net::IpAddr;
10
11/// Resolves the client IP address from HTTP headers and extensions.
12///
13/// This function checks sources in order of priority:
14/// 1. X-Forwarded-For (rightmost entry of the last header field line -- the address the nearest
15/// trusted proxy observed)
16/// 2. X-Real-IP (used by some proxies like nginx)
17/// 3. Socket address from extensions (direct connection)
18///
19/// The *rightmost* `X-Forwarded-For` entry is used, not the leftmost, because the AWS ALB every
20/// service is deployed behind *appends* the address it observed rather than overwriting the
21/// header (`routing.http.xff_header_processing.mode = append`, the ALB default). Every entry to
22/// the left of the last one is caller-supplied and therefore spoofable; the last entry is the
23/// ALB's own observation and cannot be forged by the caller *for requests that actually traversed
24/// the ALB* -- this holds even if the caller sends its own `X-Forwarded-For` as a *separate*
25/// header field line, since `HeaderMap::get` returns only the first such line and would surface a
26/// fully caller-chosen value; `get_all(...).iter().next_back()` is required to reach the ALB's
27/// line. This is correct for exactly one trusted proxy hop -- putting a second trusted proxy in front of the ALB
28/// would mean skipping one more entry from the right. For a request that reaches this service
29/// *without* going through the ALB (local dev, an in-cluster peer, or any other direct connection),
30/// there is no trusted proxy appending anything, so the rightmost entry is just as caller-chosen
31/// as every other entry and this guarantee does not apply -- the socket-address fallback (branch
32/// 3) is the only non-forgeable source in that case.
33///
34/// Every candidate is validated as a parseable `IpAddr` before being returned; a header value
35/// that fails to parse (e.g. malformed input or an attempted log-injection payload) is treated as
36/// absent and the function falls through to the next source in priority order instead of
37/// returning the raw text.
38///
39/// The resolved address is always run through [`IpAddr::to_canonical`] before being returned, so
40/// an IPv4-mapped IPv6 form (`::ffff:a.b.c.d`) arriving from a dual-stack listener unmaps to
41/// `a.b.c.d` here -- the single normalization point, independent of listener configuration --
42/// before an [`crate::ip_allowlist::IpAllowlist`] ever sees the address, so an allowlist entry of
43/// `a.b.c.d/32` matches either arrival form. This is a deliberate behavior change from the
44/// `get_client_ip` this was moved from, which returned the un-normalized form; it also normalizes
45/// the form logged for audit purposes.
46///
47/// Returns `None` if no IP can be extracted.
48pub fn resolve_client_ip(
49 headers: &http::HeaderMap,
50 extensions: &http::Extensions,
51) -> Option<IpAddr> {
52 // Check X-Forwarded-For header first (for load balancers/proxies).
53 // `get_all(...).iter().next_back()` -- not `get(...)`, which returns only the *first* field
54 // line -- picks the last field line, since the ALB appends its own observation as (or onto)
55 // the last line; within that line, the rightmost comma-separated entry is what the ALB
56 // itself observed. Everything else (earlier lines in full, and earlier entries within the
57 // last line) is caller-supplied and spoofable. `next_back()` (not `.last()`, which would
58 // needlessly walk the whole iterator) is equivalent here since `GetAll::iter()` is a
59 // `DoubleEndedIterator`.
60 if let Some(forwarded_for) = headers.get_all("x-forwarded-for").iter().next_back()
61 && let Ok(value) = forwarded_for.to_str()
62 && let Some(candidate) = value.rsplit(',').map(str::trim).find(|s| !s.is_empty())
63 && let Ok(client_ip) = candidate.parse::<IpAddr>()
64 {
65 return Some(client_ip.to_canonical());
66 }
67
68 // Check X-Real-IP header (used by some proxies like nginx)
69 if let Some(real_ip) = headers.get("x-real-ip")
70 && let Ok(value) = real_ip.to_str()
71 && let Ok(client_ip) = value.trim().parse::<IpAddr>()
72 {
73 return Some(client_ip.to_canonical());
74 }
75
76 // Fall back to socket address from extensions
77 // Axum provides ConnectInfo<SocketAddr>, Tonic provides SocketAddr directly
78 if let Some(connect_info) = extensions.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
79 {
80 return Some(connect_info.0.ip().to_canonical());
81 }
82
83 if let Some(remote_addr) = extensions.get::<std::net::SocketAddr>() {
84 return Some(remote_addr.ip().to_canonical());
85 }
86
87 None
88}