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.
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 client IP from `X-Forwarded-For`, or the direct peer address when
53/// the peer is not a trusted proxy or the chain carries no trustworthy client.
54///
55/// The chain is walked from the right (the address closest to `peer`, which
56/// trusted proxies append on the left of their own hop) towards the left,
57/// skipping every address inside the trusted set. The first untrusted address
58/// is the client as attested by the trusted proxies; everything further to its
59/// left is client-supplied and must never win. Without this right-to-left walk
60/// a client behind one untrusted proxy could plant an arbitrary address.
61#[must_use]
62pub fn real_ip_from_forwarded_for(
63    x_forwarded_for: Option<&str>,
64    peer: IpAddr,
65    trusted: &[IpNet],
66) -> IpAddr {
67    if trusted.is_empty() || !is_trusted_proxy(peer, trusted) {
68        return peer;
69    }
70    let Some(forwarded) = x_forwarded_for else {
71        return peer;
72    };
73    for entry in forwarded.split(',').rev() {
74        let Some(ip) = parse_forwarded_ip(entry) else {
75            // Malformed entries are client-controlled noise: stop trusting the
76            // remainder of the chain and fall back to the direct peer.
77            return peer;
78        };
79        if !is_trusted_proxy(ip, trusted) {
80            return ip;
81        }
82    }
83    // Every entry was a trusted proxy (or the header was empty): the client is
84    // the direct peer we already attested.
85    peer
86}
87
88fn parse_forwarded_ip(value: &str) -> Option<IpAddr> {
89    let value = value.trim();
90    value
91        .parse::<IpAddr>()
92        .or_else(|_| value.parse::<SocketAddr>().map(|address| address.ip()))
93        .ok()
94}
95
96#[cfg(test)]
97mod tests {
98    use super::{
99        is_loopback_host, is_trusted_proxy, parse_trusted_proxies, real_ip_from_forwarded_for,
100    };
101    use std::net::IpAddr;
102
103    #[test]
104    fn detects_loopback_hosts() {
105        assert!(is_loopback_host("localhost"));
106        assert!(is_loopback_host("LOCALHOST"));
107        assert!(is_loopback_host("127.0.0.1"));
108        assert!(is_loopback_host("127.0.0.2"));
109        assert!(is_loopback_host("::1"));
110        assert!(is_loopback_host("[::1]"));
111
112        assert!(!is_loopback_host("example.com"));
113        assert!(!is_loopback_host("0.0.0.0"));
114        assert!(!is_loopback_host("192.168.1.10"));
115    }
116
117    #[test]
118    fn parse_trusted_proxies_accepts_cidr_and_single_ip() {
119        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string(), "192.168.1.1".to_string()]);
120
121        assert!(is_trusted_proxy("10.0.0.5".parse().unwrap(), &trusted));
122        assert!(is_trusted_proxy("192.168.1.1".parse().unwrap(), &trusted));
123        assert!(!is_trusted_proxy("203.0.113.1".parse().unwrap(), &trusted));
124    }
125
126    #[test]
127    fn parse_trusted_proxies_skips_invalid_entries() {
128        let trusted = parse_trusted_proxies(&["not-a-proxy".to_string(), "10.0.0.0/8".to_string()]);
129
130        assert_eq!(trusted.len(), 1);
131        assert!(is_trusted_proxy("10.1.2.3".parse().unwrap(), &trusted));
132    }
133
134    #[test]
135    fn real_ip_walks_the_chain_right_to_left_and_stops_at_the_first_untrusted_hop() {
136        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
137        let peer = "10.0.0.5".parse::<IpAddr>().unwrap();
138
139        // client -> proxy(198.51.100.2) -> trusted(10.0.0.5): the proxied
140        // client address 198.51.100.2 is the first untrusted hop from the
141        // right; the leftmost 203.0.113.10 is client-supplied and must lose.
142        assert_eq!(
143            real_ip_from_forwarded_for(Some("203.0.113.10, 198.51.100.2"), peer, &trusted,),
144            "198.51.100.2".parse::<IpAddr>().unwrap()
145        );
146
147        // client -> trusted proxy only: the sole entry is the real client.
148        assert_eq!(
149            real_ip_from_forwarded_for(Some("203.0.113.10"), peer, &trusted,),
150            "203.0.113.10".parse::<IpAddr>().unwrap()
151        );
152
153        // a spoofed client-supplied chain must not overwrite the attested hop
154        assert_eq!(
155            real_ip_from_forwarded_for(Some("6.6.6.6, 203.0.113.10"), peer, &trusted,),
156            "203.0.113.10".parse::<IpAddr>().unwrap()
157        );
158    }
159
160    #[test]
161    fn real_ip_accepts_forwarded_socket_address_forms() {
162        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
163        let peer = "10.0.0.5".parse::<IpAddr>().unwrap();
164
165        assert_eq!(
166            real_ip_from_forwarded_for(Some("203.0.113.10:54321, 10.0.0.5"), peer, &trusted),
167            "203.0.113.10".parse::<IpAddr>().unwrap()
168        );
169        assert_eq!(
170            real_ip_from_forwarded_for(Some("[2001:db8::1]:443, 10.0.0.5"), peer, &trusted),
171            "2001:db8::1".parse::<IpAddr>().unwrap()
172        );
173    }
174
175    #[test]
176    fn real_ip_stops_at_malformed_entries_and_falls_back_to_peer() {
177        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
178        let peer = "10.0.0.5".parse::<IpAddr>().unwrap();
179
180        // a malformed hop to the right of the client invalidates the chain:
181        // trust cannot skip past unparseable entries.
182        assert_eq!(
183            real_ip_from_forwarded_for(Some("203.0.113.10, not-an-ip, 10.0.0.9"), peer, &trusted,),
184            peer
185        );
186        assert_eq!(real_ip_from_forwarded_for(None, peer, &trusted), peer);
187        assert_eq!(real_ip_from_forwarded_for(Some(""), peer, &trusted), peer);
188    }
189
190    #[test]
191    fn real_ip_falls_back_to_peer_for_invalid_or_missing_xff() {
192        let trusted = parse_trusted_proxies(&["10.0.0.0/8".to_string()]);
193        let peer = "10.0.0.5".parse::<IpAddr>().unwrap();
194
195        assert_eq!(
196            real_ip_from_forwarded_for(Some("not-an-ip"), peer, &trusted),
197            peer
198        );
199        assert_eq!(real_ip_from_forwarded_for(None, peer, &trusted), peer);
200    }
201}