Skip to main content

netray_common/
ip_extract.rs

1use std::net::{IpAddr, SocketAddr};
2use std::str::FromStr;
3
4use axum::http::HeaderMap;
5use ip_network::IpNetwork;
6
7/// Extracts the real client IP from proxy headers.
8///
9/// When deployed behind a reverse proxy (Cloudflare, nginx, Caddy), the direct
10/// peer IP is the proxy, not the actual client. This extractor checks proxy headers
11/// in priority order (CF-Connecting-IP, X-Real-IP, X-Forwarded-For) but only when
12/// the peer IP is in the configured trusted proxy list.
13///
14/// Trusted proxies can be specified as individual IPs (auto-promoted to /32 or /128)
15/// or CIDR ranges (e.g. `10.0.0.0/8`, `fd00::/8`).
16///
17/// **Safe default**: When `trusted_proxies` is empty, all proxy headers are ignored
18/// and the peer address is returned directly. This prevents IP spoofing when no
19/// proxy is configured.
20#[derive(Debug)]
21pub struct IpExtractor {
22    trusted_proxies: Vec<IpNetwork>,
23}
24
25impl IpExtractor {
26    /// Create a new extractor from a list of trusted proxy strings.
27    ///
28    /// Accepts individual IPs (`10.0.0.1`) and CIDR ranges (`10.0.0.0/8`).
29    /// Bare IPs are auto-promoted to /32 (IPv4) or /128 (IPv6).
30    /// Invalid entries are skipped with a `tracing::warn!`.
31    pub fn new(trusted_proxy_strs: &[String]) -> Self {
32        let mut proxies = Vec::with_capacity(trusted_proxy_strs.len());
33
34        for s in trusted_proxy_strs {
35            // Try CIDR first, then bare IP (auto-promote to /32 or /128)
36            if let Ok(net) = s.parse::<IpNetwork>() {
37                proxies.push(net);
38            } else if let Ok(ip) = IpAddr::from_str(s) {
39                proxies.push(IpNetwork::from(ip));
40            } else {
41                tracing::warn!(entry = %s, "trusted_proxies entry is not a valid IP or CIDR range -- skipped");
42            }
43        }
44
45        Self {
46            trusted_proxies: proxies,
47        }
48    }
49
50    /// Returns true if no trusted proxies are configured.
51    #[must_use]
52    pub fn is_empty(&self) -> bool {
53        self.trusted_proxies.is_empty()
54    }
55
56    /// Extract the real client IP from headers and peer address.
57    ///
58    /// Priority:
59    /// 1. If no trusted proxies configured, return peer IP (safe default).
60    /// 2. If peer IP is not trusted, return peer IP (untrusted source).
61    /// 3. Try `CF-Connecting-IP` header (Cloudflare).
62    /// 4. Try `X-Real-IP` header (nginx).
63    /// 5. Try rightmost non-trusted IP in `X-Forwarded-For`.
64    /// 6. Fall back to peer IP.
65    pub fn extract(&self, headers: &HeaderMap, peer_addr: SocketAddr) -> IpAddr {
66        if self.trusted_proxies.is_empty() {
67            return peer_addr.ip();
68        }
69
70        if !self.is_trusted(peer_addr.ip()) {
71            return peer_addr.ip();
72        }
73
74        self.extract_cf_connecting_ip(headers)
75            .or_else(|| self.extract_x_real_ip(headers))
76            .or_else(|| self.extract_x_forwarded_for(headers))
77            .unwrap_or_else(|| peer_addr.ip())
78    }
79
80    fn is_trusted(&self, ip: IpAddr) -> bool {
81        self.trusted_proxies.iter().any(|net| net.contains(ip))
82    }
83
84    fn extract_cf_connecting_ip(&self, headers: &HeaderMap) -> Option<IpAddr> {
85        headers
86            .get("cf-connecting-ip")
87            .and_then(|v| v.to_str().ok())
88            .and_then(|s| IpAddr::from_str(s.trim()).ok())
89    }
90
91    fn extract_x_real_ip(&self, headers: &HeaderMap) -> Option<IpAddr> {
92        headers
93            .get("x-real-ip")
94            .and_then(|v| v.to_str().ok())
95            .and_then(|s| IpAddr::from_str(s.trim()).ok())
96    }
97
98    /// Walk `X-Forwarded-For` right-to-left, returning the rightmost IP that is
99    /// not in the trusted proxy set.
100    fn extract_x_forwarded_for(&self, headers: &HeaderMap) -> Option<IpAddr> {
101        let value = headers.get("x-forwarded-for")?.to_str().ok()?;
102        value
103            .rsplit(',')
104            .filter_map(|s| IpAddr::from_str(s.trim()).ok())
105            .find(|ip| !self.is_trusted(*ip))
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use axum::http::HeaderValue;
113
114    fn peer(addr: &str) -> SocketAddr {
115        addr.parse().unwrap()
116    }
117
118    fn extractor(proxies: &[&str]) -> IpExtractor {
119        IpExtractor::new(&proxies.iter().map(|s| s.to_string()).collect::<Vec<_>>())
120    }
121
122    #[test]
123    fn no_proxies_returns_peer_ip() {
124        let ext = extractor(&[]);
125        let headers = HeaderMap::new();
126        assert_eq!(
127            ext.extract(&headers, peer("1.2.3.4:12345")),
128            "1.2.3.4".parse::<IpAddr>().unwrap()
129        );
130    }
131
132    #[test]
133    fn no_proxies_ignores_all_headers() {
134        let ext = extractor(&[]);
135        let mut headers = HeaderMap::new();
136        headers.insert("cf-connecting-ip", HeaderValue::from_static("5.6.7.8"));
137        headers.insert("x-real-ip", HeaderValue::from_static("9.10.11.12"));
138        headers.insert("x-forwarded-for", HeaderValue::from_static("13.14.15.16"));
139
140        assert_eq!(
141            ext.extract(&headers, peer("1.2.3.4:12345")),
142            "1.2.3.4".parse::<IpAddr>().unwrap()
143        );
144    }
145
146    #[test]
147    fn untrusted_peer_returns_peer_ip() {
148        let ext = extractor(&["10.0.0.1"]);
149        let mut headers = HeaderMap::new();
150        headers.insert("cf-connecting-ip", HeaderValue::from_static("5.6.7.8"));
151
152        assert_eq!(
153            ext.extract(&headers, peer("1.2.3.4:12345")),
154            "1.2.3.4".parse::<IpAddr>().unwrap()
155        );
156    }
157
158    #[test]
159    fn trusted_peer_uses_cf_connecting_ip() {
160        let ext = extractor(&["10.0.0.1"]);
161        let mut headers = HeaderMap::new();
162        headers.insert("cf-connecting-ip", HeaderValue::from_static("203.0.114.50"));
163
164        assert_eq!(
165            ext.extract(&headers, peer("10.0.0.1:443")),
166            "203.0.114.50".parse::<IpAddr>().unwrap()
167        );
168    }
169
170    #[test]
171    fn cf_connecting_ip_with_whitespace() {
172        let ext = extractor(&["10.0.0.1"]);
173        let mut headers = HeaderMap::new();
174        headers.insert(
175            "cf-connecting-ip",
176            HeaderValue::from_static(" 203.0.114.50 "),
177        );
178
179        assert_eq!(
180            ext.extract(&headers, peer("10.0.0.1:443")),
181            "203.0.114.50".parse::<IpAddr>().unwrap()
182        );
183    }
184
185    #[test]
186    fn cf_connecting_ip_invalid_falls_through() {
187        let ext = extractor(&["10.0.0.1"]);
188        let mut headers = HeaderMap::new();
189        headers.insert("cf-connecting-ip", HeaderValue::from_static("not-an-ip"));
190        headers.insert("x-real-ip", HeaderValue::from_static("5.6.7.8"));
191
192        assert_eq!(
193            ext.extract(&headers, peer("10.0.0.1:443")),
194            "5.6.7.8".parse::<IpAddr>().unwrap()
195        );
196    }
197
198    #[test]
199    fn trusted_peer_uses_x_real_ip() {
200        let ext = extractor(&["10.0.0.1"]);
201        let mut headers = HeaderMap::new();
202        headers.insert("x-real-ip", HeaderValue::from_static("5.6.7.8"));
203
204        assert_eq!(
205            ext.extract(&headers, peer("10.0.0.1:443")),
206            "5.6.7.8".parse::<IpAddr>().unwrap()
207        );
208    }
209
210    #[test]
211    fn cf_connecting_ip_takes_priority_over_x_real_ip() {
212        let ext = extractor(&["10.0.0.1"]);
213        let mut headers = HeaderMap::new();
214        headers.insert("cf-connecting-ip", HeaderValue::from_static("1.1.1.1"));
215        headers.insert("x-real-ip", HeaderValue::from_static("2.2.2.2"));
216
217        assert_eq!(
218            ext.extract(&headers, peer("10.0.0.1:443")),
219            "1.1.1.1".parse::<IpAddr>().unwrap()
220        );
221    }
222
223    #[test]
224    fn x_forwarded_for_single_ip() {
225        let ext = extractor(&["10.0.0.1"]);
226        let mut headers = HeaderMap::new();
227        headers.insert("x-forwarded-for", HeaderValue::from_static("203.0.114.50"));
228
229        assert_eq!(
230            ext.extract(&headers, peer("10.0.0.1:443")),
231            "203.0.114.50".parse::<IpAddr>().unwrap()
232        );
233    }
234
235    #[test]
236    fn x_forwarded_for_rightmost_untrusted() {
237        let ext = extractor(&["10.0.0.1", "10.0.0.2"]);
238        let mut headers = HeaderMap::new();
239        headers.insert(
240            "x-forwarded-for",
241            HeaderValue::from_static("99.99.99.99, 5.6.7.8, 10.0.0.2"),
242        );
243
244        assert_eq!(
245            ext.extract(&headers, peer("10.0.0.1:443")),
246            "5.6.7.8".parse::<IpAddr>().unwrap()
247        );
248    }
249
250    #[test]
251    fn x_forwarded_for_all_trusted_returns_peer() {
252        let ext = extractor(&["10.0.0.1", "10.0.0.2", "10.0.0.3"]);
253        let mut headers = HeaderMap::new();
254        headers.insert(
255            "x-forwarded-for",
256            HeaderValue::from_static("10.0.0.3, 10.0.0.2"),
257        );
258
259        assert_eq!(
260            ext.extract(&headers, peer("10.0.0.1:443")),
261            "10.0.0.1".parse::<IpAddr>().unwrap()
262        );
263    }
264
265    #[test]
266    fn x_forwarded_for_with_whitespace() {
267        let ext = extractor(&["10.0.0.1"]);
268        let mut headers = HeaderMap::new();
269        headers.insert(
270            "x-forwarded-for",
271            HeaderValue::from_static("  5.6.7.8 , 10.0.0.1 "),
272        );
273
274        assert_eq!(
275            ext.extract(&headers, peer("10.0.0.1:443")),
276            "5.6.7.8".parse::<IpAddr>().unwrap()
277        );
278    }
279
280    #[test]
281    fn x_forwarded_for_with_invalid_entries() {
282        let ext = extractor(&["10.0.0.1"]);
283        let mut headers = HeaderMap::new();
284        headers.insert(
285            "x-forwarded-for",
286            HeaderValue::from_static("5.6.7.8, garbage, not-ip"),
287        );
288
289        assert_eq!(
290            ext.extract(&headers, peer("10.0.0.1:443")),
291            "5.6.7.8".parse::<IpAddr>().unwrap()
292        );
293    }
294
295    #[test]
296    fn no_headers_returns_peer() {
297        let ext = extractor(&["10.0.0.1"]);
298        let headers = HeaderMap::new();
299
300        assert_eq!(
301            ext.extract(&headers, peer("10.0.0.1:443")),
302            "10.0.0.1".parse::<IpAddr>().unwrap()
303        );
304    }
305
306    #[test]
307    fn ipv6_peer_and_header() {
308        let ext = extractor(&["::1"]);
309        let mut headers = HeaderMap::new();
310        headers.insert(
311            "x-real-ip",
312            HeaderValue::from_static("2001:4860:4860::8888"),
313        );
314
315        assert_eq!(
316            ext.extract(&headers, peer("[::1]:443")),
317            "2001:4860:4860::8888".parse::<IpAddr>().unwrap()
318        );
319    }
320
321    #[test]
322    fn ipv6_in_x_forwarded_for() {
323        let ext = extractor(&["::1"]);
324        let mut headers = HeaderMap::new();
325        headers.insert(
326            "x-forwarded-for",
327            HeaderValue::from_static("2606:4700::1, ::1"),
328        );
329
330        assert_eq!(
331            ext.extract(&headers, peer("[::1]:443")),
332            "2606:4700::1".parse::<IpAddr>().unwrap()
333        );
334    }
335
336    #[test]
337    fn invalid_proxy_strings_are_skipped() {
338        let ext = IpExtractor::new(&[
339            "10.0.0.1".to_string(),
340            "not-an-ip".to_string(),
341            "".to_string(),
342            "10.0.0.2".to_string(),
343        ]);
344        assert_eq!(ext.trusted_proxies.len(), 2);
345    }
346
347    #[test]
348    fn cidr_trusted_proxy_matches_subnet() {
349        let ext = extractor(&["10.0.0.0/8"]);
350        let mut headers = HeaderMap::new();
351        headers.insert("x-real-ip", HeaderValue::from_static("1.2.3.4"));
352
353        assert_eq!(
354            ext.extract(&headers, peer("10.0.0.5:443")),
355            "1.2.3.4".parse::<IpAddr>().unwrap()
356        );
357    }
358
359    #[test]
360    fn cidr_xff_skips_trusted_ranges() {
361        let ext = extractor(&["10.0.0.0/8", "172.16.0.0/12"]);
362        let mut headers = HeaderMap::new();
363        headers.insert(
364            "x-forwarded-for",
365            HeaderValue::from_static("8.8.8.8, 10.0.0.1, 172.16.0.1"),
366        );
367
368        assert_eq!(
369            ext.extract(&headers, peer("172.16.0.1:443")),
370            "8.8.8.8".parse::<IpAddr>().unwrap()
371        );
372    }
373
374    #[test]
375    fn cidr_mixed_exact_and_range() {
376        let ext = extractor(&["10.0.0.0/8", "192.168.1.1"]);
377        let mut headers = HeaderMap::new();
378        headers.insert("x-real-ip", HeaderValue::from_static("5.6.7.8"));
379
380        // Exact match
381        assert_eq!(
382            ext.extract(&headers, peer("192.168.1.1:443")),
383            "5.6.7.8".parse::<IpAddr>().unwrap()
384        );
385        // CIDR match
386        assert_eq!(
387            ext.extract(&headers, peer("10.99.99.99:443")),
388            "5.6.7.8".parse::<IpAddr>().unwrap()
389        );
390    }
391
392    #[test]
393    fn is_empty_true_when_no_proxies() {
394        let ext = extractor(&[]);
395        assert!(ext.is_empty());
396    }
397
398    #[test]
399    fn is_empty_false_when_proxies_configured() {
400        let ext = extractor(&["10.0.0.1"]);
401        assert!(!ext.is_empty());
402    }
403
404    #[test]
405    fn untrusted_peer_ignores_xff() {
406        let ext = extractor(&["10.0.0.1"]);
407        let mut headers = HeaderMap::new();
408        headers.insert(
409            "x-forwarded-for",
410            HeaderValue::from_static("5.6.7.8, 9.10.11.12"),
411        );
412        headers.insert("cf-connecting-ip", HeaderValue::from_static("5.6.7.8"));
413
414        assert_eq!(
415            ext.extract(&headers, peer("1.2.3.4:12345")),
416            "1.2.3.4".parse::<IpAddr>().unwrap()
417        );
418    }
419
420    #[test]
421    fn bare_ip_auto_promotes_to_host_network() {
422        // A bare IP like "10.0.0.1" should match only that exact IP, not the whole subnet
423        let ext = extractor(&["10.0.0.1"]);
424        let mut headers = HeaderMap::new();
425        headers.insert("x-real-ip", HeaderValue::from_static("1.2.3.4"));
426
427        // Exact match works
428        assert_eq!(
429            ext.extract(&headers, peer("10.0.0.1:443")),
430            "1.2.3.4".parse::<IpAddr>().unwrap()
431        );
432        // Different IP in same /24 does NOT match (not trusted)
433        assert_eq!(
434            ext.extract(&headers, peer("10.0.0.2:443")),
435            "10.0.0.2".parse::<IpAddr>().unwrap()
436        );
437    }
438}