Skip to main content

http_extract/
client_ip.rs

1//! Best-effort extraction of raw client IP assertions.
2//!
3//! [`extract_client_ip`] uses [`CLIENT_IP_HEADERS`], while
4//! [`extract_client_ip_with_headers`] lets callers choose the fields and their
5//! order explicitly using [`ClientIpHeader`] values.
6//! Both functions only inspect HTTP fields. They cannot authenticate the
7//! sender, so their results remain raw and untrusted. Applications must
8//! establish the relevant proxy or CDN trust boundary before using a result
9//! for authorization, abuse prevention, or rate limiting.
10//!
11//! RFC 7239 standardizes `Forwarded`; `X-Forwarded-For` and the single-value
12//! provider/proxy fields are de facto conventions rather than IETF standards.
13//! No standard defines precedence between these different field names.
14//!
15//! [RFC 7239]: https://www.rfc-editor.org/rfc/rfc7239.html
16
17use std::{
18    net::{IpAddr, SocketAddr},
19    str::FromStr,
20};
21
22use http::{HeaderMap, HeaderName};
23
24use crate::Error;
25
26macro_rules! client_ip_headers {
27    (
28        $(
29            $(#[$docs:meta])*
30            ($variant:ident, $name:literal, $extractor:path);
31        )+
32    ) => {
33        /// A supported client IP Header and its parsing rule.
34        ///
35        /// Choosing a variant does not authenticate the sender or make the
36        /// extracted value trustworthy.
37        #[derive(Clone, Copy, Debug, Eq, PartialEq)]
38        pub enum ClientIpHeader {
39            $(
40                $(#[$docs])*
41                $variant,
42            )+
43        }
44
45        impl FromStr for ClientIpHeader {
46            type Err = Error;
47
48            fn from_str(source: &str) -> Result<Self, Self::Err> {
49                let name = HeaderName::from_bytes(source.as_bytes())
50                    .map_err(|_| Error::unsupported_header_name(source))?;
51
52                match name.as_str() {
53                    $(
54                        $name => Ok(Self::$variant),
55                    )+
56                    _ => Err(Error::unsupported_header_name(source)),
57                }
58            }
59        }
60
61        impl ClientIpHeader {
62            fn extract(self, headers: &HeaderMap) -> Result<Option<IpAddr>, Error> {
63                match self {
64                    $(
65                        Self::$variant => ($extractor)(headers),
66                    )+
67                }
68            }
69        }
70    };
71}
72
73client_ip_headers! {
74    /// `CF-Connecting-IP`.
75    (
76        CfConnectingIp,
77        "cf-connecting-ip",
78        crate::client_ip_headers::extract_header_cf_connecting_ip
79    );
80    /// `X-Real-IP`.
81    (XRealIp, "x-real-ip", crate::client_ip_headers::extract_header_x_real_ip);
82    /// RFC 7239 `Forwarded`.
83    (Forwarded, "forwarded", crate::forwarded::extract_rightmost_forwarded);
84    /// `X-Forwarded-For`.
85    (
86        XForwardedFor,
87        "x-forwarded-for",
88        crate::x_forwarded::extract_rightmost_x_forwarded_for
89    );
90    /// `CloudFront-Viewer-Address`.
91    (
92        CloudFrontViewerAddress,
93        "cloudfront-viewer-address",
94        crate::client_ip_headers::extract_header_cloudfront_viewer_address
95    );
96    /// `Fly-Client-IP`.
97    (
98        FlyClientIp,
99        "fly-client-ip",
100        crate::client_ip_headers::extract_header_fly_client_ip
101    );
102    /// `True-Client-IP`.
103    (
104        TrueClientIp,
105        "true-client-ip",
106        crate::client_ip_headers::extract_header_true_client_ip
107    );
108    /// `X-Envoy-External-Address`.
109    (
110        XEnvoyExternalAddress,
111        "x-envoy-external-address",
112        crate::client_ip_headers::extract_header_x_envoy_external_address
113    );
114}
115
116impl TryFrom<&str> for ClientIpHeader {
117    type Error = Error;
118
119    fn try_from(source: &str) -> Result<Self, Self::Error> {
120        source.parse()
121    }
122}
123
124/// The default Header lookup order used by [`extract_client_ip`].
125///
126/// The standardized `Forwarded` field is checked first, followed by the de
127/// facto `X-Forwarded-For`, `X-Real-IP`, and `CF-Connecting-IP` fields. The
128/// first present source wins. This standard-first order is a library
129/// convention, not an RFC-defined precedence or trust policy.
130pub const CLIENT_IP_HEADERS: &[ClientIpHeader] = &[
131    ClientIpHeader::Forwarded,
132    ClientIpHeader::XForwardedFor,
133    ClientIpHeader::XRealIp,
134    ClientIpHeader::CfConnectingIp,
135];
136
137/// Return the IP address of a transport peer supplied out-of-band by an adapter.
138///
139/// This is a direct conversion from [`SocketAddr`] to [`IpAddr`]. It does not
140/// read HTTP fields or apply a proxy policy. The caller is responsible for
141/// supplying the actual connection peer rather than a header-derived address.
142pub const fn extract_peer_ip(peer: SocketAddr) -> IpAddr {
143    extract_peer_address(peer).ip()
144}
145
146/// Return a transport peer address supplied out-of-band by an adapter.
147///
148/// An HTTP request does not inherently contain this network fact, so no
149/// request-based convenience function is provided. The value is returned
150/// unchanged; this function does not read headers or apply a proxy policy.
151pub const fn extract_peer_address(peer: SocketAddr) -> SocketAddr {
152    peer
153}
154
155/// Extract the Axum transport peer stored in a request extension.
156///
157/// This reads `axum::extract::ConnectInfo<SocketAddr>` inserted by
158/// `Router::into_make_service_with_connect_info` or explicitly by a test. It
159/// returns `None` when that extension is absent. The returned address is the
160/// socket peer; this function neither parses nor trusts forwarding Headers.
161#[cfg(feature = "axum")]
162pub fn extract_axum_peer_address<B>(request: &http::Request<B>) -> Option<SocketAddr> {
163    request
164        .extensions()
165        .get::<axum::extract::ConnectInfo<SocketAddr>>()
166        .map(|info| info.0)
167}
168
169/// Extract the Axum socket peer IP stored in a request extension.
170///
171/// This returns the [`IpAddr`] from the
172/// `axum::extract::ConnectInfo<SocketAddr>` request extension, or `None` when
173/// that extension is absent. It does not parse `Forwarded`,
174/// `X-Forwarded-For`, or vendor Headers, so it is not a Header-derived or
175/// effective client IP.
176#[cfg(feature = "axum")]
177pub fn extract_axum_peer_ip<B>(request: &http::Request<B>) -> Option<IpAddr> {
178    extract_axum_peer_address(request).map(|peer| peer.ip())
179}
180
181/// Extract a raw client IP assertion using the default field order.
182///
183/// This delegates to [`extract_client_ip_with_headers`] with
184/// [`CLIENT_IP_HEADERS`]. A missing value in every source returns
185/// `None`. A malformed, duplicate, or non-text value in the first present
186/// source returns an error without consulting lower-priority sources.
187///
188/// For `Forwarded` and `X-Forwarded-For`, this returns the rightmost address,
189/// which is the assertion nearest the server. The result is still untrusted;
190/// this function has no transport-peer or trusted-proxy configuration.
191pub fn extract_client_ip(headers: &HeaderMap) -> Result<Option<IpAddr>, Error> {
192    extract_client_ip_with_headers(headers, CLIENT_IP_HEADERS)
193}
194
195/// Extract a raw client IP assertion using caller-defined fields and order.
196///
197/// Sources are checked from left to right and the first present value wins. An
198/// empty order, or no value in any configured source, returns `None`. If a
199/// source is present but malformed, duplicate, or non-text, its error is
200/// returned immediately instead of falling through to another source.
201///
202/// `Forwarded` and `X-Forwarded-For` contribute their rightmost address. All
203/// results remain raw and untrusted regardless of the chosen order.
204pub fn extract_client_ip_with_headers(
205    headers: &HeaderMap,
206    sources: &[ClientIpHeader],
207) -> Result<Option<IpAddr>, Error> {
208    for source in sources {
209        if let Some(ip) = source.extract(headers)? {
210            return Ok(Some(ip));
211        }
212    }
213
214    Ok(None)
215}
216
217#[cfg(test)]
218mod tests {
219    use http::HeaderMap;
220
221    use crate::{forwarded::FORWARDED, x_forwarded::X_FORWARDED_FOR};
222
223    use super::*;
224    #[test]
225    fn peer_functions_preserve_the_transport_address() {
226        let peer: SocketAddr = "203.0.113.8:443".parse().unwrap();
227        assert_eq!(extract_peer_address(peer), peer);
228        assert_eq!(
229            extract_peer_ip(peer),
230            "203.0.113.8".parse::<IpAddr>().unwrap()
231        );
232    }
233
234    #[cfg(feature = "axum")]
235    #[test]
236    fn axum_peer_address_reads_connect_info_extension() {
237        let peer: SocketAddr = "203.0.113.8:443".parse().unwrap();
238        let mut request = http::Request::new(());
239        request
240            .extensions_mut()
241            .insert(axum::extract::ConnectInfo(peer));
242
243        assert_eq!(extract_axum_peer_address(&request), Some(peer));
244    }
245
246    #[cfg(feature = "axum")]
247    #[test]
248    fn axum_peer_address_returns_none_without_connect_info() {
249        let request = http::Request::new(());
250
251        assert_eq!(extract_axum_peer_address(&request), None);
252    }
253
254    #[cfg(feature = "axum")]
255    #[test]
256    fn axum_peer_ip_reads_connect_info_extension() {
257        let peer: SocketAddr = "203.0.113.8:443".parse().unwrap();
258        let mut request = http::Request::new(());
259        request
260            .extensions_mut()
261            .insert(axum::extract::ConnectInfo(peer));
262
263        assert_eq!(extract_axum_peer_ip(&request), Some(peer.ip()));
264    }
265
266    #[cfg(feature = "axum")]
267    #[test]
268    fn axum_peer_ip_returns_none_without_connect_info() {
269        let request = http::Request::new(());
270
271        assert_eq!(extract_axum_peer_ip(&request), None);
272    }
273
274    #[test]
275    fn default_order_is_stable_and_first_present_header_wins() {
276        assert_eq!(
277            CLIENT_IP_HEADERS,
278            &[
279                ClientIpHeader::Forwarded,
280                ClientIpHeader::XForwardedFor,
281                ClientIpHeader::XRealIp,
282                ClientIpHeader::CfConnectingIp,
283            ]
284        );
285
286        let mut headers = HeaderMap::new();
287        headers.insert("cf-connecting-ip", "192.0.2.1".parse().unwrap());
288        headers.insert("x-real-ip", "192.0.2.2".parse().unwrap());
289        headers.insert(&FORWARDED, "for=192.0.2.3".parse().unwrap());
290        headers.insert(&X_FORWARDED_FOR, "192.0.2.4".parse().unwrap());
291
292        assert_eq!(
293            extract_client_ip(&headers).unwrap(),
294            Some("192.0.2.3".parse().unwrap())
295        );
296    }
297
298    #[test]
299    fn default_order_falls_through_only_when_a_header_is_absent() {
300        let mut headers = HeaderMap::new();
301        headers.insert(&FORWARDED, "for=192.0.2.3".parse().unwrap());
302        headers.insert(&X_FORWARDED_FOR, "192.0.2.4".parse().unwrap());
303
304        assert_eq!(
305            extract_client_ip(&headers).unwrap(),
306            Some("192.0.2.3".parse().unwrap())
307        );
308
309        headers.insert("x-real-ip", "not-an-ip".parse().unwrap());
310        assert_eq!(
311            extract_client_ip(&headers).unwrap(),
312            Some("192.0.2.3".parse().unwrap())
313        );
314
315        headers.insert(&FORWARDED, "for=unknown".parse().unwrap());
316        assert!(matches!(
317            extract_client_ip(&headers),
318            Err(Error::InvalidHeader { .. })
319        ));
320    }
321
322    #[test]
323    fn chain_headers_return_the_rightmost_address() {
324        let mut headers = HeaderMap::new();
325        headers.insert(
326            &FORWARDED,
327            "for=192.0.2.1, for=198.51.100.2".parse().unwrap(),
328        );
329        assert_eq!(
330            extract_client_ip_with_headers(&headers, &[ClientIpHeader::Forwarded]).unwrap(),
331            Some("198.51.100.2".parse().unwrap())
332        );
333
334        headers.remove(&FORWARDED);
335        headers.insert(&X_FORWARDED_FOR, "192.0.2.1, 198.51.100.3".parse().unwrap());
336        assert_eq!(
337            extract_client_ip_with_headers(&headers, &[ClientIpHeader::XForwardedFor]).unwrap(),
338            Some("198.51.100.3".parse().unwrap())
339        );
340    }
341
342    #[test]
343    fn custom_order_changes_precedence() {
344        let mut headers = HeaderMap::new();
345        headers.insert("cf-connecting-ip", "192.0.2.1".parse().unwrap());
346        headers.insert("x-real-ip", "192.0.2.2".parse().unwrap());
347        let custom_headers = [ClientIpHeader::XRealIp, ClientIpHeader::CfConnectingIp];
348
349        assert_eq!(
350            extract_client_ip_with_headers(&headers, &custom_headers).unwrap(),
351            Some("192.0.2.2".parse().unwrap())
352        );
353        assert_eq!(extract_client_ip_with_headers(&headers, &[]).unwrap(), None);
354    }
355
356    #[test]
357    fn custom_order_supports_every_documented_single_value_header() {
358        for (source, header) in [
359            (ClientIpHeader::CfConnectingIp, "cf-connecting-ip"),
360            (ClientIpHeader::XRealIp, "x-real-ip"),
361            (ClientIpHeader::FlyClientIp, "fly-client-ip"),
362            (ClientIpHeader::TrueClientIp, "true-client-ip"),
363            (
364                ClientIpHeader::XEnvoyExternalAddress,
365                "x-envoy-external-address",
366            ),
367        ] {
368            let mut headers = HeaderMap::new();
369            headers.insert(header, "192.0.2.10".parse().unwrap());
370
371            assert_eq!(
372                extract_client_ip_with_headers(&headers, &[source]).unwrap(),
373                Some("192.0.2.10".parse().unwrap()),
374                "source {header}",
375            );
376        }
377
378        let mut headers = HeaderMap::new();
379        headers.insert(
380            "cloudfront-viewer-address",
381            "192.0.2.10:443".parse().unwrap(),
382        );
383        assert_eq!(
384            extract_client_ip_with_headers(&headers, &[ClientIpHeader::CloudFrontViewerAddress],)
385                .unwrap(),
386            Some("192.0.2.10".parse().unwrap())
387        );
388    }
389
390    #[test]
391    fn parses_supported_header_names() {
392        for (name, expected) in [
393            ("cf-connecting-ip", ClientIpHeader::CfConnectingIp),
394            ("X-Real-IP", ClientIpHeader::XRealIp),
395            ("forwarded", ClientIpHeader::Forwarded),
396            ("x-forwarded-for", ClientIpHeader::XForwardedFor),
397            (
398                "cloudfront-viewer-address",
399                ClientIpHeader::CloudFrontViewerAddress,
400            ),
401            ("fly-client-ip", ClientIpHeader::FlyClientIp),
402            ("true-client-ip", ClientIpHeader::TrueClientIp),
403            (
404                "x-envoy-external-address",
405                ClientIpHeader::XEnvoyExternalAddress,
406            ),
407        ] {
408            assert_eq!(ClientIpHeader::try_from(name).unwrap(), expected);
409            assert_eq!(name.parse::<ClientIpHeader>().unwrap(), expected);
410        }
411
412        for header in ["forwarded-for", "not a header"] {
413            let error = header.parse::<ClientIpHeader>().unwrap_err();
414            assert!(matches!(error, Error::UnsupportedHeaderName { .. }));
415            assert!(error.to_string().contains(header));
416        }
417    }
418
419    #[test]
420    fn malformed_selected_source_does_not_fall_through() {
421        let mut headers = HeaderMap::new();
422        headers.insert(&FORWARDED, "for=unknown".parse().unwrap());
423        headers.insert(&X_FORWARDED_FOR, "192.0.2.4".parse().unwrap());
424
425        assert!(matches!(
426            extract_client_ip_with_headers(
427                &headers,
428                &[ClientIpHeader::Forwarded, ClientIpHeader::XForwardedFor,],
429            ),
430            Err(Error::InvalidHeader { .. })
431        ));
432    }
433}