Skip to main content

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