1- use std:: net:: SocketAddr ;
1+ use std:: { net:: SocketAddr , num :: NonZeroU8 } ;
22
33use axum:: extract:: { ConnectInfo , Request } ;
44
55/// Extract the client IP address from a request.
66///
7- /// Checks proxy headers first, then falls back to the TCP peer address:
8- /// 1. `X-Forwarded-For` — first IP in the comma-separated list
9- /// 2. `X-Real-IP` — single IP set by the reverse proxy
10- /// 3. `ConnectInfo<SocketAddr>` — TCP peer address (direct connection)
11- pub fn extract_client_ip ( req : & Request ) -> Option < String > {
7+ /// Behavior depends on `trusted_proxy_depth`:
8+ ///
9+ /// - `Some(n)`: Takes the client IP from `X-Forwarded-For` by skipping the `n` rightmost entries
10+ /// (which were appended by trusted proxy infrastructure). Returns `None` if the header has fewer
11+ /// entries than required. Falls back to `ConnectInfo` only when the header is absent.
12+ ///
13+ /// - `None` (direct connection mode): Uses only `ConnectInfo` (TCP peer address). This is the safe
14+ /// default when not behind a reverse proxy.
15+ pub fn extract_client_ip ( req : & Request , trusted_proxy_depth : Option < NonZeroU8 > ) -> Option < String > {
16+ match trusted_proxy_depth {
17+ Some ( depth) => extract_with_proxy_depth ( req, depth) ,
18+ None => extract_direct ( req) ,
19+ }
20+ }
21+
22+ /// Extract IP using rightmost-nth selection from `X-Forwarded-For`.
23+ ///
24+ /// With `depth=1` (one trusted proxy), the proxy appended the rightmost entry,
25+ /// so the client IP is at position `len - 2` (just before the proxy entry).
26+ /// With `depth=2` (two proxies), it's at `len - 3`, etc.
27+ fn extract_with_proxy_depth ( req : & Request , depth : NonZeroU8 ) -> Option < String > {
1228 let headers = req. headers ( ) ;
1329
14- headers
15- . get ( "x-forwarded-for" )
16- . and_then ( |v| v. to_str ( ) . ok ( ) )
17- . and_then ( |s| s. split ( ',' ) . next ( ) )
18- . map ( |s| s. trim ( ) . to_string ( ) )
19- . filter ( |s| !s. is_empty ( ) )
20- . or_else ( || {
21- headers
22- . get ( "x-real-ip" )
23- . and_then ( |v| v. to_str ( ) . ok ( ) )
24- . map ( |s| s. trim ( ) . to_string ( ) )
25- . filter ( |s| !s. is_empty ( ) )
26- } )
27- . or_else ( || {
28- req. extensions ( )
29- . get :: < ConnectInfo < SocketAddr > > ( )
30- . map ( |ConnectInfo ( addr) | addr. ip ( ) . to_string ( ) )
31- } )
30+ if let Some ( xff) = headers. get ( "x-forwarded-for" ) . and_then ( |v| v. to_str ( ) . ok ( ) ) {
31+ let ips: Vec < & str > = xff. split ( ',' ) . map ( |s| s. trim ( ) ) . filter ( |s| !s. is_empty ( ) ) . collect ( ) ;
32+
33+ // The real client IP is at position len - depth - 1 (just before proxy entries).
34+ let index = ips. len ( ) . checked_sub ( depth. get ( ) as usize + 1 ) ?;
35+ let ip = ips. get ( index) ?;
36+ return Some ( ( * ip) . to_string ( ) ) ;
37+ }
38+
39+ // Fall back to ConnectInfo if X-Forwarded-For is absent
40+ req. extensions ( ) . get :: < ConnectInfo < SocketAddr > > ( ) . map ( |ConnectInfo ( addr) | addr. ip ( ) . to_string ( ) )
41+ }
42+
43+ /// Extract IP in direct connection mode (no trusted proxies).
44+ ///
45+ /// Uses only `ConnectInfo` (TCP peer address). Proxy headers are ignored
46+ /// because without trusted proxy configuration they are freely spoofable.
47+ fn extract_direct ( req : & Request ) -> Option < String > {
48+ req. extensions ( ) . get :: < ConnectInfo < SocketAddr > > ( ) . map ( |ConnectInfo ( addr) | addr. ip ( ) . to_string ( ) )
3249}
3350
3451#[ cfg( test) ]
@@ -54,78 +71,94 @@ mod tests {
5471 req
5572 }
5673
74+ // ── Direct mode (trusted_proxy_depth = None) ──────────────────
75+
5776 #[ test]
58- fn returns_x_forwarded_for_first_ip ( ) {
59- let req = request_with_headers ( & [ (
60- "x-forwarded-for" ,
61- "203.0.113.50, 70.41.3.18, 150.172.238.178" ,
62- ) ] ) ;
63- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
77+ fn direct_mode_prefers_connect_info ( ) {
78+ let addr = SocketAddr :: new ( IpAddr :: V4 ( Ipv4Addr :: new ( 10 , 0 , 0 , 1 ) ) , 12345 ) ;
79+ let mut req = request_with_headers ( & [ ( "x-forwarded-for" , "203.0.113.50" ) ] ) ;
80+ req. extensions_mut ( ) . insert ( ConnectInfo ( addr) ) ;
81+ assert_eq ! ( extract_client_ip( & req, None ) . as_deref( ) , Some ( "10.0.0.1" ) ) ;
6482 }
6583
6684 #[ test]
67- fn returns_x_forwarded_for_single_ip ( ) {
68- let req = request_with_headers ( & [ ( "x-forwarded-for" , "203.0.113.50" ) ] ) ;
69- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
85+ fn direct_mode_ignores_proxy_headers ( ) {
86+ let req = request_with_headers ( & [
87+ ( "x-forwarded-for" , "203.0.113.50" ) ,
88+ ( "x-real-ip" , "198.51.100.42" ) ,
89+ ] ) ;
90+ // Without ConnectInfo, direct mode returns None — proxy headers are untrusted
91+ assert_eq ! ( extract_client_ip( & req, None ) , None ) ;
7092 }
7193
7294 #[ test]
73- fn returns_x_real_ip_when_no_forwarded_for ( ) {
74- let req = request_with_headers ( & [ ( "x-real-ip" , "198.51.100.42" ) ] ) ;
75- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "198.51.100.42" ) ) ;
95+ fn direct_mode_returns_none_without_any_source ( ) {
96+ let req = request_with_headers ( & [ ] ) ;
97+ assert_eq ! ( extract_client_ip( & req, None ) , None ) ;
98+ }
99+
100+ // ── Proxy mode (trusted_proxy_depth = Some(n)) ────────────────
101+
102+ fn depth ( n : u8 ) -> Option < NonZeroU8 > {
103+ Some ( NonZeroU8 :: new ( n) . unwrap ( ) )
76104 }
77105
78106 #[ test]
79- fn prefers_x_forwarded_for_over_x_real_ip ( ) {
80- let req = request_with_headers ( & [
81- ( "x-forwarded-for" , "203.0.113.50" ) ,
82- ( "x-real-ip" , "198.51.100.42" ) ,
83- ] ) ;
84- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
107+ fn proxy_depth_1_takes_client_ip ( ) {
108+ let req =
109+ request_with_headers ( & [ ( "x-forwarded-for" , "spoofed.ip, 203.0.113.50, 10.0.0.1" ) ] ) ;
110+ // depth=1: one trusted proxy appended 10.0.0.1, client is 203.0.113.50
111+ assert_eq ! ( extract_client_ip( & req, depth( 1 ) ) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
85112 }
86113
87114 #[ test]
88- fn falls_back_to_connect_info ( ) {
89- let addr = SocketAddr :: new ( IpAddr :: V4 ( Ipv4Addr :: new ( 127 , 0 , 0 , 1 ) ) , 12345 ) ;
90- let req = request_with_connect_info ( addr) ;
91- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "127.0.0.1" ) ) ;
115+ fn proxy_depth_2_takes_second_from_right ( ) {
116+ let req = request_with_headers ( & [ (
117+ "x-forwarded-for" ,
118+ "spoofed.ip, 203.0.113.50, 10.0.0.1, 10.0.0.2" ,
119+ ) ] ) ;
120+ // depth=2: two proxies appended 10.0.0.1 and 10.0.0.2
121+ assert_eq ! ( extract_client_ip( & req, depth( 2 ) ) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
92122 }
93123
94124 #[ test]
95- fn returns_none_without_any_source ( ) {
96- let req = request_with_headers ( & [ ] ) ;
97- assert_eq ! ( extract_client_ip( & req) , None ) ;
125+ fn proxy_depth_with_single_ip_falls_back ( ) {
126+ let req = request_with_headers ( & [ ( "x-forwarded-for" , "203.0.113.50" ) ] ) ;
127+ // depth=1 with single IP: the proxy appended this IP, so there's no client
128+ // IP before it. Falls back to None (no ConnectInfo set).
129+ assert_eq ! ( extract_client_ip( & req, depth( 1 ) ) , None ) ;
98130 }
99131
100132 #[ test]
101- fn skips_empty_forwarded_for ( ) {
102- let req = request_with_headers ( & [ ( "x-forwarded-for" , "" ) ] ) ;
103- assert_eq ! ( extract_client_ip( & req) , None ) ;
133+ fn proxy_depth_with_two_ips ( ) {
134+ let req = request_with_headers ( & [ ( "x-forwarded-for" , "203.0.113.50, 10.0.0.1" ) ] ) ;
135+ // depth=1: proxy added 10.0.0.1, client is 203.0.113.50
136+ assert_eq ! ( extract_client_ip( & req, depth( 1 ) ) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
104137 }
105138
106139 #[ test]
107- fn skips_empty_real_ip ( ) {
108- let req = request_with_headers ( & [ ( "x-real-ip" , " " ) ] ) ;
109- assert_eq ! ( extract_client_ip( & req) , None ) ;
140+ fn proxy_depth_exceeds_ip_count_returns_none ( ) {
141+ let req = request_with_headers ( & [ ( "x-forwarded-for" , "203.0.113.50" ) ] ) ;
142+ // depth=3 but only 1 IP — can't extract
143+ assert_eq ! ( extract_client_ip( & req, depth( 3 ) ) , None ) ;
110144 }
111145
112146 #[ test]
113- fn trims_whitespace_from_forwarded_for ( ) {
114- let req = request_with_headers ( & [ ( "x-forwarded-for" , " 203.0.113.50 , 70.41.3.18" ) ] ) ;
115- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
147+ fn proxy_mode_falls_back_to_connect_info_without_xff ( ) {
148+ let addr = SocketAddr :: new ( IpAddr :: V4 ( Ipv4Addr :: new ( 127 , 0 , 0 , 1 ) ) , 12345 ) ;
149+ let req = request_with_connect_info ( addr) ;
150+ assert_eq ! ( extract_client_ip( & req, depth( 1 ) ) . as_deref( ) , Some ( "127.0.0.1" ) ) ;
116151 }
117152
118153 #[ test]
119- fn trims_whitespace_from_real_ip ( ) {
120- let req = request_with_headers ( & [ ( "x-real-ip " , " 198.51.100.42 " ) ] ) ;
121- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "198.51.100.42 " ) ) ;
154+ fn proxy_mode_trims_whitespace ( ) {
155+ let req = request_with_headers ( & [ ( "x-forwarded-for " , " 203.0.113.50 , 10.0.0.1 " ) ] ) ;
156+ assert_eq ! ( extract_client_ip( & req, depth ( 1 ) ) . as_deref( ) , Some ( "203.0.113.50 " ) ) ;
122157 }
123158
124159 #[ test]
125- fn prefers_headers_over_connect_info ( ) {
126- let addr = SocketAddr :: new ( IpAddr :: V4 ( Ipv4Addr :: new ( 10 , 0 , 0 , 1 ) ) , 12345 ) ;
127- let mut req = request_with_headers ( & [ ( "x-forwarded-for" , "203.0.113.50" ) ] ) ;
128- req. extensions_mut ( ) . insert ( ConnectInfo ( addr) ) ;
129- assert_eq ! ( extract_client_ip( & req) . as_deref( ) , Some ( "203.0.113.50" ) ) ;
160+ fn proxy_mode_skips_empty_xff ( ) {
161+ let req = request_with_headers ( & [ ( "x-forwarded-for" , "" ) ] ) ;
162+ assert_eq ! ( extract_client_ip( & req, depth( 1 ) ) , None ) ;
130163 }
131164}
0 commit comments