aster_forge_utils/
net.rs

1//! Network address helpers.
2//!
3//! The module centralizes small network parsing rules shared by Aster services: loopback host
4//! detection, trusted proxy CIDR parsing, and real-client-IP selection from `X-Forwarded-For`.
5//! Header-framework adapters stay in application crates; this module accepts plain strings and
6//! standard address types so it remains independent of Actix, Axum, or Hyper.
7
8use std::net::{IpAddr, SocketAddr};
9
10use ipnet::IpNet;
11
12/// Returns whether `host` is localhost or a loopback IP address.
13#[must_use]
14pub fn is_loopback_host(host: &str) -> bool {
15    let trimmed = host.trim();
16    let host = trimmed
17        .strip_prefix('[')
18        .and_then(|value| value.strip_suffix(']'))
19        .unwrap_or(trimmed);
20
21    host.eq_ignore_ascii_case("localhost")
22        || host
23            .parse::<std::net::IpAddr>()
24            .is_ok_and(|ip| ip.is_loopback())
25}
26
27/// Parses trusted proxy entries as CIDR networks or single IP addresses.
28///
29/// Invalid entries are skipped after emitting a warning. This mirrors the fail-open-at-startup
30/// behavior used by the application repositories: one bad optional proxy entry should not prevent
31/// the service from starting, but it also must not become trusted.
32#[must_use]
33pub fn parse_trusted_proxies(trusted_proxies: &[String]) -> Vec<IpNet> {
34    trusted_proxies
35        .iter()
36        .filter_map(|entry| {
37            entry
38                .parse::<IpNet>()
39                .or_else(|_| entry.parse::<IpAddr>().map(IpNet::from))
40                .map_err(|error| tracing::warn!("invalid trusted_proxy entry '{entry}': {error}"))
41                .ok()
42        })
43        .collect()
44}
45
46/// Returns whether `ip` is covered by the trusted proxy list.
47#[must_use]
48pub fn is_trusted_proxy(ip: IpAddr, trusted: &[IpNet]) -> bool {
49    trusted.iter().any(|net| net.contains(&ip))
50}
51
52/// Returns the first client IP from `X-Forwarded-For` only when `peer` is trusted.
53///
54/// The leftmost value is used because application reverse proxies append their own address to the
55/// right. If the peer is not trusted, the header is ignored. Malformed or empty header values fall
56/// back to the direct peer address.
57pub fn real_ip_from_forwarded_for(
58    x_forwarded_for: Option<&str>,
59    peer: IpAddr,
60    trusted: &[IpNet],
61) -> IpAddr {
62    if !trusted.is_empty() && is_trusted_proxy(peer, trusted) {
63        let ip = x_forwarded_for
64            .and_then(|value| value.split(',').next())
65            .and_then(parse_forwarded_ip);
66        if let Some(ip) = ip {
67            return ip;
68        }
69    }
70    peer
71}
72
73fn parse_forwarded_ip(value: &str) -> Option<IpAddr> {
74    let value = value.trim();
75    value
76        .parse::<IpAddr>()
77        .or_else(|_| value.parse::<SocketAddr>().map(|address| address.ip()))
78        .ok()
79}
80
81#[cfg(test)]
82mod tests {
83    use super::{
84        is_loopback_host, is_trusted_proxy, parse_trusted_proxies, real_ip_from_forwarded_for,
85    };
86    use std::net::IpAddr;
87
88    #[test]
89    fn detects_loopback_hosts() {
90        assert!(is_loopback_host("localhost"));
91        assert!(is_loopback_host("LOCALHOST"));
92        assert!(is_loopback_host("127.0.0.1"));
93        assert!(is_loopback_host("127.0.0.2"));
94        assert!(is_loopback_host("::1"));
95        assert!(is_loopback_host("[::1]"));
96
97        assert!(!is_loopback_host("example.com"));
98        assert!(!is_loopback_host("0.0.0.0"));
99        assert!(!is_loopback_host("192.168.1.10"));
100    }
101
102    #[test]
103    fn parse_trusted_proxies_accepts_cidr_and_single_ip() {
104        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string(), "192.168.1.1".to_string()]);
105
106        assert!(is_trusted_proxy("10.0.0.5".parse().unwrap(), &trusted));
107        assert!(is_trusted_proxy("192.168.1.1".parse().unwrap(), &trusted));
108        assert!(!is_trusted_proxy("203.0.113.1".parse().unwrap(), &trusted));
109    }
110
111    #[test]
112    fn parse_trusted_proxies_skips_invalid_entries() {
113        let trusted = parse_trusted_proxies(&["not-a-proxy".to_string(), "10.0.0.0/8".to_string()]);
114
115        assert_eq!(trusted.len(), 1);
116        assert!(is_trusted_proxy("10.1.2.3".parse().unwrap(), &trusted));
117    }
118
119    #[test]
120    fn real_ip_uses_leftmost_xff_only_for_trusted_peer() {
121        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
122
123        assert_eq!(
124            real_ip_from_forwarded_for(
125                Some("203.0.113.10, 198.51.100.2"),
126                "10.0.0.5".parse::<IpAddr>().unwrap(),
127                &trusted,
128            ),
129            "203.0.113.10".parse::<IpAddr>().unwrap()
130        );
131        assert_eq!(
132            real_ip_from_forwarded_for(
133                Some("203.0.113.10"),
134                "198.51.100.2".parse::<IpAddr>().unwrap(),
135                &trusted,
136            ),
137            "198.51.100.2".parse::<IpAddr>().unwrap()
138        );
139    }
140
141    #[test]
142    fn real_ip_accepts_forwarded_socket_address_forms() {
143        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
144        let peer = "10.0.0.5".parse::<IpAddr>().unwrap();
145
146        assert_eq!(
147            real_ip_from_forwarded_for(Some("203.0.113.10:54321, 10.0.0.5"), peer, &trusted),
148            "203.0.113.10".parse::<IpAddr>().unwrap()
149        );
150        assert_eq!(
151            real_ip_from_forwarded_for(Some("[2001:db8::1]:443, 10.0.0.5"), peer, &trusted),
152            "2001:db8::1".parse::<IpAddr>().unwrap()
153        );
154    }
155
156    #[test]
157    fn real_ip_falls_back_to_peer_for_invalid_or_missing_xff() {
158        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
159        let peer = "10.0.0.5".parse::<IpAddr>().unwrap();
160
161        assert_eq!(
162            real_ip_from_forwarded_for(Some("not-an-ip"), peer, &trusted),
163            peer
164        );
165        assert_eq!(real_ip_from_forwarded_for(None, peer, &trusted), peer);
166    }
167}