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 warning.
31    pub fn new(trusted_proxy_strs: &[String]) -> Result<Self, String> {
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        Ok(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<_>>()).unwrap()
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        .unwrap();
345        assert_eq!(ext.trusted_proxies.len(), 2);
346    }
347
348    #[test]
349    fn cidr_trusted_proxy_matches_subnet() {
350        let ext = extractor(&["10.0.0.0/8"]);
351        let mut headers = HeaderMap::new();
352        headers.insert("x-real-ip", HeaderValue::from_static("1.2.3.4"));
353
354        assert_eq!(
355            ext.extract(&headers, peer("10.0.0.5:443")),
356            "1.2.3.4".parse::<IpAddr>().unwrap()
357        );
358    }
359
360    #[test]
361    fn cidr_xff_skips_trusted_ranges() {
362        let ext = extractor(&["10.0.0.0/8", "172.16.0.0/12"]);
363        let mut headers = HeaderMap::new();
364        headers.insert(
365            "x-forwarded-for",
366            HeaderValue::from_static("8.8.8.8, 10.0.0.1, 172.16.0.1"),
367        );
368
369        assert_eq!(
370            ext.extract(&headers, peer("172.16.0.1:443")),
371            "8.8.8.8".parse::<IpAddr>().unwrap()
372        );
373    }
374
375    #[test]
376    fn cidr_mixed_exact_and_range() {
377        let ext = extractor(&["10.0.0.0/8", "192.168.1.1"]);
378        let mut headers = HeaderMap::new();
379        headers.insert("x-real-ip", HeaderValue::from_static("5.6.7.8"));
380
381        // Exact match
382        assert_eq!(
383            ext.extract(&headers, peer("192.168.1.1:443")),
384            "5.6.7.8".parse::<IpAddr>().unwrap()
385        );
386        // CIDR match
387        assert_eq!(
388            ext.extract(&headers, peer("10.99.99.99:443")),
389            "5.6.7.8".parse::<IpAddr>().unwrap()
390        );
391    }
392
393    #[test]
394    fn is_empty_true_when_no_proxies() {
395        let ext = extractor(&[]);
396        assert!(ext.is_empty());
397    }
398
399    #[test]
400    fn is_empty_false_when_proxies_configured() {
401        let ext = extractor(&["10.0.0.1"]);
402        assert!(!ext.is_empty());
403    }
404
405    #[test]
406    fn untrusted_peer_ignores_xff() {
407        let ext = extractor(&["10.0.0.1"]);
408        let mut headers = HeaderMap::new();
409        headers.insert("x-forwarded-for", HeaderValue::from_static("5.6.7.8, 9.10.11.12"));
410        headers.insert("cf-connecting-ip", HeaderValue::from_static("5.6.7.8"));
411
412        assert_eq!(
413            ext.extract(&headers, peer("1.2.3.4:12345")),
414            "1.2.3.4".parse::<IpAddr>().unwrap()
415        );
416    }
417
418    #[test]
419    fn bare_ip_auto_promotes_to_host_network() {
420        // A bare IP like "10.0.0.1" should match only that exact IP, not the whole subnet
421        let ext = extractor(&["10.0.0.1"]);
422        let mut headers = HeaderMap::new();
423        headers.insert("x-real-ip", HeaderValue::from_static("1.2.3.4"));
424
425        // Exact match works
426        assert_eq!(
427            ext.extract(&headers, peer("10.0.0.1:443")),
428            "1.2.3.4".parse::<IpAddr>().unwrap()
429        );
430        // Different IP in same /24 does NOT match (not trusted)
431        assert_eq!(
432            ext.extract(&headers, peer("10.0.0.2:443")),
433            "10.0.0.2".parse::<IpAddr>().unwrap()
434        );
435    }
436}