Skip to main content

nestrs_core/
client_ip.rs

1//! Client IP resolution shared by the rate limiter and the throttler.
2//!
3//! The shape of this module is intentionally minimal so it can live in
4//! `nestrs-core` without pulling axum's response types. Platform-specific
5//! helpers (`nestrs::client_ip::ClientIp` extractor, `nestrs::client_ip::ClientIpMissing`
6//! rejection) stay in `nestrs/src/client_ip.rs`, which re-exports the
7//! shared pieces from here.
8//!
9//! Resolution order:
10//!
11//! 1. Forwarded headers (`x-forwarded-for`, then `x-real-ip`) — **only** when a trusted-proxy
12//!    hop count has been configured.
13//! 2. Connection metadata from Axum `ConnectInfo<SocketAddr>` when available (the host-side
14//!    peer address, set by `NestApplication::listen*`).
15//!
16//! Forwarded headers are client-controlled. Trusting them without knowing your proxy topology
17//! lets callers spoof their IP (bypassing IP-based rate limits, poisoning other clients'
18//! windows). When `hops` is configured, each of the `hops` trusted proxies appends exactly one
19//! entry (its peer) to `x-forwarded-for`, so the client IP as seen by your outermost proxy is
20//! the entry `hops` positions from the end of the list; entries further left are
21//! client-controlled and are never selected.
22
23use axum::extract::connect_info::{ConnectInfo, MockConnectInfo};
24use axum::http::request::Parts;
25use axum::http::Extensions;
26use axum::http::{HeaderMap, HeaderName};
27use std::net::{IpAddr, SocketAddr};
28
29/// `x-forwarded-for` header name (static so we never re-allocate it).
30pub static X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
31/// `x-real-ip` header name (static so we never re-allocate it).
32pub static X_REAL_IP: HeaderName = HeaderName::from_static("x-real-ip");
33
34/// Extension carrying the configured trusted-proxy hop count (installed per request by
35/// `NestApplication::use_trusted_proxy_headers`).
36#[derive(Clone, Copy, Debug)]
37pub struct TrustedProxyHops(pub u16);
38
39fn parse_forwarded_ip(raw: &str) -> Option<IpAddr> {
40    // Some proxies include a port (e.g. `1.2.3.4:1234`). Try SocketAddr first.
41    if let Ok(sa) = raw.parse::<SocketAddr>() {
42        return Some(sa.ip());
43    }
44    raw.parse::<IpAddr>().ok()
45}
46
47/// Resolves the client IP given the optional trusted-proxy hop count.
48///
49/// - `None` / `Some(0)` — connection metadata only; forwarded headers are ignored.
50/// - `Some(n)` with `n >= 1` — take the `x-forwarded-for` entry `n` positions from the *end*
51///   of the chain (the value appended by the outermost trusted proxy); fall back to
52///   `x-real-ip` (set by the outermost proxy) only when the XFF chain is shorter than the
53///   hop count.
54pub fn best_effort_client_ip(
55    headers: &HeaderMap,
56    extensions: &Extensions,
57    trusted_hops: Option<u16>,
58) -> Option<IpAddr> {
59    // `MockConnectInfo` is stored as its own extension type until Axum's `ConnectInfo` extractor
60    // maps it (see axum `ConnectInfo::from_request_parts`). It represents the *actual* peer in
61    // test setups (no proxy in front), so it keeps precedence over forwarded headers.
62    if let Some(MockConnectInfo(addr)) = extensions.get::<MockConnectInfo<SocketAddr>>() {
63        return Some(addr.ip());
64    }
65
66    let hops = trusted_hops.unwrap_or(0);
67
68    // When a trusted-proxy topology is declared, connection metadata is the *proxy's* address,
69    // not the client's — forwarded headers must take precedence.
70    if hops == 0 {
71        if let Some(ConnectInfo(addr)) = extensions.get::<ConnectInfo<SocketAddr>>() {
72            return Some(addr.ip());
73        }
74        return None;
75    }
76
77    // Duplicate XFF headers are legal; proxies may emit several. Join them so hop indexing
78    // covers the full chain rather than a truncated list.
79    let xff_values: Vec<&str> = headers
80        .get_all(&X_FORWARDED_FOR)
81        .iter()
82        .filter_map(|v| v.to_str().ok())
83        .collect();
84    if !xff_values.is_empty() {
85        let v = xff_values.join(",");
86        let entries: Vec<&str> = v
87            .split(',')
88            .map(str::trim)
89            .filter(|s| !s.is_empty())
90            .collect();
91        // Each of the `hops` trusted proxies appended exactly one entry (its peer), so the
92        // client IP as seen by the outermost trusted proxy sits at `len - hops`. Entries to
93        // its left are client-controlled and must never be selected.
94        if let Some(idx) = entries.len().checked_sub(hops as usize) {
95            if let Some(ip) = parse_forwarded_ip(entries[idx]) {
96                return Some(ip);
97            }
98        }
99    }
100
101    headers
102        .get(&X_REAL_IP)
103        .and_then(|v| v.to_str().ok())
104        .and_then(parse_forwarded_ip)
105}
106
107/// Client IP for a rate-limit / throttle key, with the shared `"unknown"`
108/// fallback.
109///
110/// Resolution fails when no forwarded header parses and no peer address is
111/// available (e.g. a malformed `x-forwarded-for` under a trusted topology, or
112/// a transport without socket metadata). Every such request lands in ONE
113/// shared `unknown` bucket, coupling the rate limits of unrelated clients —
114/// so callers typically log when this fallback fires (see
115/// `nestrs::client_ip::rate_limit_key_ip` for the wrapper that does so).
116pub fn rate_limit_key_ip_or_unknown(
117    headers: &HeaderMap,
118    extensions: &Extensions,
119    trusted_hops: Option<u16>,
120) -> String {
121    best_effort_client_ip(headers, extensions, trusted_hops)
122        .map(|ip| ip.to_string())
123        .unwrap_or_else(|| "unknown".to_string())
124}
125
126/// Result of [`rate_limit_key_ip_or_unknown`]: the key string plus whether
127/// the `"unknown"` fallback was used. Callers that want to warn on
128/// unkeyable traffic (rate limiter, throttler) consume this and emit the
129/// diagnostic themselves — `nestrs-core` stays free of the `tracing`
130/// dependency.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct RateLimitKey {
133    pub key: String,
134    pub fell_back: bool,
135}
136
137impl RateLimitKey {
138    pub fn resolve(
139        headers: &HeaderMap,
140        extensions: &Extensions,
141        trusted_hops: Option<u16>,
142    ) -> Self {
143        match best_effort_client_ip(headers, extensions, trusted_hops) {
144            Some(ip) => Self {
145                key: ip.to_string(),
146                fell_back: false,
147            },
148            None => Self {
149                key: "unknown".to_string(),
150                fell_back: true,
151            },
152        }
153    }
154}
155
156/// Convenience: read the per-request trusted-proxy hop count from `Parts.extensions`,
157/// falling back to the supplied default when the extension is absent.
158pub fn trusted_hops_from_parts(parts: &Parts, fallback: Option<u16>) -> Option<u16> {
159    parts
160        .extensions
161        .get::<TrustedProxyHops>()
162        .map(|h| h.0)
163        .or(fallback)
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{
169        best_effort_client_ip, rate_limit_key_ip_or_unknown, trusted_hops_from_parts,
170        TrustedProxyHops, X_FORWARDED_FOR, X_REAL_IP,
171    };
172    use axum::extract::connect_info::{ConnectInfo, MockConnectInfo};
173    use axum::http::{Extensions, HeaderMap, HeaderValue};
174    use std::net::{IpAddr, SocketAddr};
175
176    fn xff() -> HeaderMap {
177        let mut headers = HeaderMap::new();
178        headers.insert(
179            &X_FORWARDED_FOR,
180            HeaderValue::from_static("203.0.113.10, 198.51.100.10"),
181        );
182        headers.insert(&X_REAL_IP, HeaderValue::from_static("198.51.100.20"));
183        headers
184    }
185
186    #[test]
187    fn configured_hops_override_connect_info_behind_proxy() {
188        // Behind a reverse proxy, ConnectInfo is the *proxy's* address; with a declared
189        // trusted-proxy topology the forwarded chain must win.
190        let headers = xff();
191        let mut extensions = Extensions::new();
192        extensions.insert(ConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4321))));
193        extensions.insert(TrustedProxyHops(2));
194
195        assert_eq!(
196            best_effort_client_ip(&headers, &extensions, Some(2)),
197            Some(IpAddr::from([203, 0, 113, 10]))
198        );
199    }
200
201    #[test]
202    fn forwarded_headers_are_ignored_without_trusted_proxies() {
203        // Secure default: spoofable forwarded headers are not consulted unless configured.
204        let mut headers = HeaderMap::new();
205        headers.insert(&X_FORWARDED_FOR, HeaderValue::from_static("203.0.113.10"));
206        headers.insert(&X_REAL_IP, HeaderValue::from_static("198.51.100.20"));
207
208        assert_eq!(
209            best_effort_client_ip(&headers, &Extensions::new(), None),
210            None
211        );
212        assert_eq!(
213            best_effort_client_ip(&headers, &Extensions::new(), Some(0)),
214            None
215        );
216    }
217
218    #[test]
219    fn one_trusted_proxy_uses_rightmost_xff_entry() {
220        // Client -> our LB (appends "198.51.100.10") -> app.
221        let headers = xff();
222        assert_eq!(
223            best_effort_client_ip(&headers, &Extensions::new(), Some(1)),
224            Some(IpAddr::from([198, 51, 100, 10]))
225        );
226    }
227
228    #[test]
229    fn two_trusted_proxies_use_second_from_right() {
230        let headers = xff();
231        assert_eq!(
232            best_effort_client_ip(&headers, &Extensions::new(), Some(2)),
233            Some(IpAddr::from([203, 0, 113, 10]))
234        );
235    }
236
237    #[test]
238    fn real_ip_fallback_when_xff_shorter_than_hop_count() {
239        let mut headers = HeaderMap::new();
240        headers.insert(&X_REAL_IP, HeaderValue::from_static("198.51.100.20"));
241        assert_eq!(
242            best_effort_client_ip(&headers, &Extensions::new(), Some(1)),
243            Some(IpAddr::from([198, 51, 100, 20]))
244        );
245    }
246
247    #[test]
248    fn spoofed_left_entries_cannot_override_trusted_resolution() {
249        // Attacker prepends a fake IP; with one trusted hop it must be ignored.
250        let mut headers = HeaderMap::new();
251        headers.insert(
252            &X_FORWARDED_FOR,
253            HeaderValue::from_static("6.6.6.6, 203.0.113.10"),
254        );
255        assert_eq!(
256            best_effort_client_ip(&headers, &Extensions::new(), Some(1)),
257            Some(IpAddr::from([203, 0, 113, 10]))
258        );
259    }
260
261    #[test]
262    fn honest_single_hop_traffic_resolves_the_appended_entry() {
263        // Client -> our LB (appends the client IP) -> app: the only XFF entry is the client's
264        // and must be selected (regression: `len - 1 - hops` underflowed here and fell through
265        // to `x-real-ip`, or panicked on `len == hops` arithmetic).
266        let mut headers = HeaderMap::new();
267        headers.insert(&X_FORWARDED_FOR, HeaderValue::from_static("198.51.100.7"));
268        assert_eq!(
269            best_effort_client_ip(&headers, &Extensions::new(), Some(1)),
270            Some(IpAddr::from([198, 51, 100, 7]))
271        );
272    }
273
274    #[test]
275    fn duplicate_xff_headers_are_joined_before_hop_indexing() {
276        // Two separate XFF header lines (legal per RFC 7239 predecessors): hop indexing must
277        // see the full chain, not just the first line.
278        let mut headers = HeaderMap::new();
279        headers.append(&X_FORWARDED_FOR, HeaderValue::from_static("6.6.6.6"));
280        headers.append(&X_FORWARDED_FOR, HeaderValue::from_static("198.51.100.10"));
281        assert_eq!(
282            best_effort_client_ip(&headers, &Extensions::new(), Some(1)),
283            Some(IpAddr::from([198, 51, 100, 10]))
284        );
285    }
286
287    #[test]
288    fn mock_connect_info_is_visible_to_best_effort() {
289        let mut extensions = Extensions::new();
290        extensions.insert(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4321))));
291
292        assert_eq!(
293            best_effort_client_ip(&HeaderMap::new(), &extensions, None),
294            Some(IpAddr::from([127, 0, 0, 1]))
295        );
296    }
297
298    // --- rate-limit key fallback -----------------------------------------
299
300    #[test]
301    fn rate_limit_key_ip_formats_a_resolved_ip() {
302        let mut extensions = Extensions::new();
303        extensions.insert(MockConnectInfo(SocketAddr::from(([203, 0, 113, 9], 443))));
304
305        assert_eq!(
306            rate_limit_key_ip_or_unknown(&HeaderMap::new(), &extensions, None),
307            "203.0.113.9"
308        );
309    }
310
311    #[test]
312    fn rate_limit_key_ip_falls_back_to_unknown_when_unresolvable() {
313        assert_eq!(
314            rate_limit_key_ip_or_unknown(&HeaderMap::new(), &Extensions::new(), None),
315            "unknown"
316        );
317        let mut headers = HeaderMap::new();
318        headers.insert(&X_FORWARDED_FOR, HeaderValue::from_static("not-an-ip"));
319        assert_eq!(
320            rate_limit_key_ip_or_unknown(&headers, &Extensions::new(), Some(1)),
321            "unknown"
322        );
323    }
324
325    #[test]
326    fn trusted_hops_from_parts_prefers_extension_then_fallback() {
327        let mut parts = axum::http::Request::new(()).into_parts().0;
328        assert_eq!(trusted_hops_from_parts(&parts, None), None);
329        assert_eq!(trusted_hops_from_parts(&parts, Some(0)), Some(0));
330
331        parts.extensions.insert(TrustedProxyHops(3));
332        assert_eq!(trusted_hops_from_parts(&parts, None), Some(3));
333        assert_eq!(trusted_hops_from_parts(&parts, Some(0)), Some(3));
334    }
335}