Skip to main content

tako_rs_extractors/
ipaddr.rs

1//! Client IP address extraction from HTTP request headers.
2//!
3//! This module provides the [`IpAddr`](crate::ipaddr::IpAddr) extractor for determining the client's IP address
4//! from various HTTP headers commonly used by proxies, load balancers, and CDNs.
5//! It supports both IPv4 and IPv6 addresses and provides methods for inspecting
6//! IP address properties like whether it's private, loopback, etc.
7//!
8//! # Examples
9//!
10//! ```rust
11//! use tako::extractors::ipaddr::IpAddr;
12//! use std::net::IpAddr as StdIpAddr;
13//!
14//! async fn handle_request(ip: IpAddr) {
15//!     println!("Client IP: {}", ip);
16//!
17//!     if ip.is_private() {
18//!         println!("Request from private network");
19//!     }
20//!
21//!     if ip.is_ipv4() {
22//!         println!("IPv4 address");
23//!     } else {
24//!         println!("IPv6 address");
25//!     }
26//! }
27//! ```
28
29use std::net::IpAddr as StdIpAddr;
30use std::net::SocketAddr;
31use std::str::FromStr;
32
33use http::StatusCode;
34use http::request::Parts;
35use tako_rs_core::conn_info::ConnInfo;
36use tako_rs_core::conn_info::PeerAddr;
37use tako_rs_core::extractors::FromRequest;
38use tako_rs_core::extractors::FromRequestParts;
39use tako_rs_core::responder::Responder;
40use tako_rs_core::types::Request;
41
42/// Extractor for the client IP address.
43///
44/// **Default behavior (secure):** Returns the transport-level peer IP from
45/// `ConnInfo` (or the legacy `SocketAddr` extension). Forwarded headers
46/// (`X-Forwarded-For`, `X-Real-IP`, `Forwarded`, …) are **ignored** because
47/// any client that can reach the server directly can forge them.
48///
49/// **Trusted-proxy mode:** Insert an [`IpAddrConfig`] into router state via
50/// `tako_rs_core::state::set_state` with `trusted_proxies` listing the IPs of
51/// your real proxy/load-balancer fleet. When the direct peer matches one of
52/// those entries, forwarded headers are honored in priority order:
53/// 1. `Forwarded` (RFC 7239 — `for=`)
54/// 2. `X-Forwarded-For` (leftmost untrusted hop)
55/// 3. `X-Real-IP`
56/// 4. `X-Client-IP`
57/// 5. `CF-Connecting-IP` (Cloudflare)
58/// 6. `True-Client-IP`
59///
60/// # Examples
61///
62/// ```rust
63/// use tako::extractors::ipaddr::IpAddr;
64/// use std::net::IpAddr as StdIpAddr;
65///
66/// let ip = IpAddr::new("192.168.1.1".parse().unwrap());
67/// assert!(ip.is_ipv4());
68/// assert!(ip.is_private());
69/// ```
70#[derive(Debug, Clone, PartialEq)]
71#[doc(alias = "ip")]
72#[doc(alias = "ipaddr")]
73pub struct IpAddr(pub StdIpAddr);
74
75/// Configuration for trusted-proxy IP extraction. Insert into router state to
76/// opt into forwarded-header parsing for requests whose direct peer matches.
77#[derive(Debug, Clone, Default)]
78pub struct IpAddrConfig {
79  /// Direct-peer IPs whose forwarded-IP headers we honor. Empty (default)
80  /// means no header trust — only the direct peer IP is used.
81  pub trusted_proxies: Vec<StdIpAddr>,
82}
83
84impl IpAddrConfig {
85  /// Empty config — no forwarded-header trust.
86  pub fn new() -> Self {
87    Self::default()
88  }
89
90  /// Add a trusted proxy IP.
91  pub fn trust(mut self, ip: StdIpAddr) -> Self {
92    self.trusted_proxies.push(ip);
93    self
94  }
95
96  /// Replace the trusted-proxy list.
97  pub fn with_trusted_proxies(mut self, ips: Vec<StdIpAddr>) -> Self {
98    self.trusted_proxies = ips;
99    self
100  }
101}
102
103/// Error type for IP address extraction.
104#[derive(Debug)]
105pub enum IpAddrError {
106  /// No valid IP address found in any of the checked headers.
107  NoIpFound,
108  /// The IP address format in the header is invalid.
109  InvalidIpFormat(String),
110  /// Failed to parse the IP address from the header value.
111  HeaderParseError,
112}
113
114impl Responder for IpAddrError {
115  /// Converts the error into an HTTP response.
116  fn into_response(self) -> tako_rs_core::types::Response {
117    match self {
118      IpAddrError::NoIpFound => (
119        StatusCode::BAD_REQUEST,
120        "No valid IP address found in request headers",
121      )
122        .into_response(),
123      IpAddrError::InvalidIpFormat(ip) => (
124        StatusCode::BAD_REQUEST,
125        format!("Invalid IP address format: {ip}"),
126      )
127        .into_response(),
128      IpAddrError::HeaderParseError => (
129        StatusCode::BAD_REQUEST,
130        "Failed to parse IP address from headers",
131      )
132        .into_response(),
133    }
134  }
135}
136
137impl IpAddr {
138  /// Creates a new `IpAddr` wrapper.
139  pub fn new(addr: StdIpAddr) -> Self {
140    Self(addr)
141  }
142
143  /// Gets the inner IP address.
144  pub fn inner(&self) -> StdIpAddr {
145    self.0
146  }
147
148  /// Checks if the IP address is IPv4.
149  pub fn is_ipv4(&self) -> bool {
150    self.0.is_ipv4()
151  }
152
153  /// Checks if the IP address is IPv6.
154  pub fn is_ipv6(&self) -> bool {
155    self.0.is_ipv6()
156  }
157
158  /// Checks if the IP address is a loopback address.
159  pub fn is_loopback(&self) -> bool {
160    self.0.is_loopback()
161  }
162
163  /// Checks if the IP address is a private address.
164  ///
165  /// For IPv4, this includes addresses in the ranges:
166  /// - 10.0.0.0/8
167  /// - 172.16.0.0/12
168  /// - 192.168.0.0/16
169  /// - 127.0.0.0/8 (loopback)
170  ///
171  /// For IPv6, this includes:
172  /// - `fc00::/7` (Unique Local Addresses)
173  /// - `fe80::/10` (Link-Local Addresses)
174  /// - `::1` (loopback)
175  pub fn is_private(&self) -> bool {
176    match self.0 {
177      StdIpAddr::V4(ipv4) => ipv4.is_private(),
178      StdIpAddr::V6(ipv6) => {
179        // IPv6 private address ranges
180        let segments = ipv6.segments();
181        // fc00::/7 (Unique Local Addresses)
182        (segments[0] & 0xfe00) == 0xfc00 ||
183                // fe80::/10 (Link-Local Addresses)
184                (segments[0] & 0xffc0) == 0xfe80 ||
185                // ::1 (Loopback)
186                ipv6.is_loopback()
187      }
188    }
189  }
190
191  /// Resolves the client IP from request extensions + headers using the
192  /// configured trust policy. Secure-by-default: forwarded headers are only
193  /// honored when the direct peer is listed in `IpAddrConfig::trusted_proxies`.
194  fn extract_from(
195    extensions: &http::Extensions,
196    headers: &http::HeaderMap,
197  ) -> Result<Self, IpAddrError> {
198    let peer = peer_ip_from_extensions(extensions);
199
200    let cfg = tako_rs_core::state::get_state::<IpAddrConfig>();
201    let trust_headers = match (peer.as_ref(), cfg.as_ref()) {
202      (Some(p), Some(cfg)) => cfg.trusted_proxies.iter().any(|t| t == p),
203      _ => false,
204    };
205
206    if trust_headers
207      && let Some(cfg) = cfg.as_ref()
208      && let Some(ip) = Self::parse_forwarded_headers(headers, &cfg.trusted_proxies)
209    {
210      return Ok(Self(ip));
211    }
212
213    peer.map(Self).ok_or(IpAddrError::NoIpFound)
214  }
215
216  /// Parses the first non-trusted client IP from any of the recognized
217  /// forwarded headers, in priority order.
218  ///
219  /// For multi-hop headers (`Forwarded`, `X-Forwarded-For`) the walk goes
220  /// **right-to-left**, skipping entries that match `trusted_proxies` — the
221  /// first remaining entry is the leftmost untrusted hop (the real client).
222  /// Walking left-to-right was spoofable: an attacker could prepend a fake
223  /// `<spoofed>` to the header and a trusted proxy would append the real
224  /// `<peer>`, leaving the first parseable IP as `<spoofed>`.
225  ///
226  /// Single-IP headers (`X-Real-IP`, `CF-Connecting-IP`, …) carry one
227  /// already-resolved client IP from the proxy and are taken as-is.
228  fn parse_forwarded_headers(
229    headers: &http::HeaderMap,
230    trusted_proxies: &[StdIpAddr],
231  ) -> Option<StdIpAddr> {
232    const MULTI_HOP: &[&str] = &["forwarded", "x-forwarded-for"];
233    const SINGLE_HOP: &[&str] = &[
234      "x-real-ip",
235      "x-client-ip",
236      "cf-connecting-ip",
237      "true-client-ip",
238    ];
239    for header_name in MULTI_HOP {
240      if let Some(v) = headers.get(*header_name)
241        && let Ok(s) = v.to_str()
242        && let Some(ip) = Self::parse_ip_right_to_left(s, trusted_proxies)
243      {
244        return Some(ip);
245      }
246    }
247    for header_name in SINGLE_HOP {
248      if let Some(v) = headers.get(*header_name)
249        && let Ok(s) = v.to_str()
250        && let Some(ip) = Self::parse_ip_from_header(s)
251      {
252        return Some(ip);
253      }
254    }
255    None
256  }
257
258  /// Walk a comma-separated header from right to left and return the first
259  /// IP that is not in `trusted_proxies`. Used for multi-hop headers where
260  /// the client appends to the left and proxies append to the right.
261  fn parse_ip_right_to_left(
262    header_value: &str,
263    trusted_proxies: &[StdIpAddr],
264  ) -> Option<StdIpAddr> {
265    let parts: Vec<&str> = header_value.split(',').collect();
266    for part in parts.iter().rev() {
267      let trimmed = part.trim();
268      if trimmed.is_empty() {
269        continue;
270      }
271      // An unparseable entry in the middle of the chain is not a stop
272      // condition — it's typically a missing-port or quoted-form variant
273      // we don't recognize yet; keep walking left.
274      let Some(ip) = Self::parse_ip_from_part(trimmed) else {
275        continue;
276      };
277      if !trusted_proxies.contains(&ip) {
278        return Some(ip);
279      }
280    }
281    None
282  }
283
284  /// Parses an IP address from a header value (comma-separated list, optional
285  /// `for=` prefix, optional `:port` or `[v6]:port` suffix).
286  fn parse_ip_from_header(header_value: &str) -> Option<StdIpAddr> {
287    for part in header_value.split(',') {
288      let part = part.trim();
289      if part.is_empty() {
290        continue;
291      }
292      if let Some(ip) = Self::parse_ip_from_part(part) {
293        return Some(ip);
294      }
295    }
296    None
297  }
298
299  /// Parse one comma-separated entry into an IP, stripping `for=`, quotes,
300  /// `[v6]` brackets, and an optional `:port` suffix.
301  fn parse_ip_from_part(part: &str) -> Option<StdIpAddr> {
302    if part.is_empty() {
303      return None;
304    }
305    let ip_part = part.strip_prefix("for=").unwrap_or(part);
306    let ip_part = ip_part.trim_matches('"');
307
308    let ip_str = if ip_part.starts_with('[') {
309      if let Some(end) = ip_part.find(']') {
310        &ip_part[1..end]
311      } else {
312        ip_part
313      }
314    } else if ip_part.matches(':').count() == 1 {
315      ip_part.split(':').next().unwrap_or(ip_part)
316    } else {
317      ip_part
318    };
319
320    StdIpAddr::from_str(ip_str).ok()
321  }
322}
323
324fn peer_ip_from_extensions(ext: &http::Extensions) -> Option<StdIpAddr> {
325  if let Some(info) = ext.get::<ConnInfo>()
326    && let PeerAddr::Ip(sa) = &info.peer
327  {
328    return Some(sa.ip());
329  }
330  if let Some(sa) = ext.get::<SocketAddr>() {
331    return Some(sa.ip());
332  }
333  None
334}
335
336impl std::fmt::Display for IpAddr {
337  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338    write!(f, "{}", self.0)
339  }
340}
341
342impl From<StdIpAddr> for IpAddr {
343  fn from(addr: StdIpAddr) -> Self {
344    Self(addr)
345  }
346}
347
348impl From<IpAddr> for StdIpAddr {
349  fn from(addr: IpAddr) -> Self {
350    addr.0
351  }
352}
353
354impl<'a> FromRequest<'a> for IpAddr {
355  type Error = IpAddrError;
356
357  fn from_request(
358    req: &'a mut Request,
359  ) -> impl core::future::Future<Output = core::result::Result<Self, Self::Error>> + Send + 'a {
360    futures_util::future::ready(Self::extract_from(req.extensions(), req.headers()))
361  }
362}
363
364impl<'a> FromRequestParts<'a> for IpAddr {
365  type Error = IpAddrError;
366
367  fn from_request_parts(
368    parts: &'a mut Parts,
369  ) -> impl core::future::Future<Output = core::result::Result<Self, Self::Error>> + Send + 'a {
370    futures_util::future::ready(Self::extract_from(&parts.extensions, &parts.headers))
371  }
372}