Skip to main content

ullun/
proxy.rs

1//! Trusted reverse proxies and the real client address
2//!
3//! Behind a load balancer every connection comes from the proxy, and the client's
4//! address travels in `X-Forwarded-For` / `Forwarded`. Those headers are written
5//! by *clients* too: a naive "take the first entry" lets anyone choose their own
6//! address, which defeats rate limits, allow-lists and audit logs. The rule that
7//! works: only believe the header when the connection comes from a proxy you
8//! run, and read it from the right, skipping hops that are your own proxies.
9//!
10//! ```no_run
11//! use ullun::prelude::*;
12//! use ullun::proxy::TrustedProxies;
13//!
14//! # async fn run() {
15//! App::new()
16//!     // the load balancers in front of this service
17//!     .trusted_proxies(TrustedProxies::new().trust("10.0.0.0/8").unwrap())
18//!     .get("/ip", |req: Request| async move {
19//!         Ok(Response::text(req.client_ip().map(|ip| ip.to_string()).unwrap_or_default()))
20//!     })
21//!     .run("0.0.0.0:3000")
22//!     .await
23//!     .unwrap();
24//! # }
25//! ```
26//!
27//! With trusted proxies configured:
28//!
29//! * [`Request::client_ip`] is the first address from the right that is not one
30//!   of your proxies (`Forwarded` wins over `X-Forwarded-For`); from any other
31//!   peer it is the peer address itself, whatever the headers say
32//! * [`Request::is_secure`] also accepts `X-Forwarded-Proto: https` /
33//!   `Forwarded: proto=https` from a trusted peer
34//! * the default [`RateLimiter`](crate::rate_limit::RateLimiter) key and the
35//!   [`AccessLog`](crate::observability::AccessLog) use the client address
36//!
37//! Without them nothing changes: `client_ip()` is the peer address.
38
39use crate::request::Request;
40use std::net::IpAddr;
41use std::str::FromStr;
42
43/// A network such as `10.0.0.0/8` or `fd00::/8` (a bare address is a single host)
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct Cidr {
46    addr: IpAddr,
47    prefix: u8,
48}
49
50impl FromStr for Cidr {
51    type Err = String;
52
53    fn from_str(s: &str) -> Result<Self, String> {
54        let (addr, prefix) = match s.split_once('/') {
55            Some((a, p)) => (a, Some(p)),
56            None => (s, None),
57        };
58        let addr: IpAddr = addr
59            .trim()
60            .parse()
61            .map_err(|_| format!("invalid address in {s:?}"))?;
62        let addr = addr.to_canonical();
63        let max = if addr.is_ipv4() { 32 } else { 128 };
64        let prefix = match prefix {
65            Some(p) => p
66                .trim()
67                .parse::<u8>()
68                .ok()
69                .filter(|p| *p <= max)
70                .ok_or_else(|| format!("invalid prefix length in {s:?}"))?,
71            None => max,
72        };
73        Ok(Self { addr, prefix })
74    }
75}
76
77impl Cidr {
78    pub fn contains(&self, ip: IpAddr) -> bool {
79        let ip = ip.to_canonical();
80        match (self.addr, ip) {
81            (IpAddr::V4(net), IpAddr::V4(ip)) => {
82                prefix_matches(&net.octets(), &ip.octets(), self.prefix)
83            }
84            (IpAddr::V6(net), IpAddr::V6(ip)) => {
85                prefix_matches(&net.octets(), &ip.octets(), self.prefix)
86            }
87            _ => false,
88        }
89    }
90}
91
92fn prefix_matches(net: &[u8], ip: &[u8], prefix: u8) -> bool {
93    let full = (prefix / 8) as usize;
94    if net[..full] != ip[..full] {
95        return false;
96    }
97    let rest = prefix % 8;
98    rest == 0 || {
99        let mask = 0xffu8 << (8 - rest);
100        net[full] & mask == ip[full] & mask
101    }
102}
103
104/// The proxies whose forwarding headers are believed
105#[derive(Debug, Clone, Default)]
106pub struct TrustedProxies {
107    nets: Vec<Cidr>,
108}
109
110impl TrustedProxies {
111    /// Nobody is trusted yet
112    pub fn new() -> Self {
113        Self::default()
114    }
115
116    /// Trust a network or address, e.g. `"10.0.0.0/8"`, `"203.0.113.7"`, `"fd00::/8"`
117    pub fn trust(mut self, cidr: &str) -> Result<Self, String> {
118        self.nets.push(cidr.parse()?);
119        self.nets.dedup();
120        Ok(self)
121    }
122
123    /// Loopback, private (RFC 1918), link-local and unique-local addresses: a
124    /// proxy inside your own network. Do not use it when untrusted machines can
125    /// reach the service from such an address.
126    pub fn private_networks() -> Self {
127        let mut t = Self::new();
128        for net in [
129            "127.0.0.0/8",
130            "10.0.0.0/8",
131            "172.16.0.0/12",
132            "192.168.0.0/16",
133            "169.254.0.0/16",
134            "::1/128",
135            "fc00::/7",
136            "fe80::/10",
137        ] {
138            t.nets.push(net.parse().expect("built-in network"));
139        }
140        t
141    }
142
143    pub fn contains(&self, ip: IpAddr) -> bool {
144        self.nets.iter().any(|n| n.contains(ip))
145    }
146
147    pub fn is_empty(&self) -> bool {
148        self.nets.is_empty()
149    }
150}
151
152/// Address inside a `for=` / `X-Forwarded-For` element: plain, with port,
153/// bracketed IPv6, quoted
154fn parse_hop(raw: &str) -> Option<IpAddr> {
155    let s = raw.trim().trim_matches('"');
156    if let Some(rest) = s.strip_prefix('[') {
157        return rest.split(']').next()?.parse().ok();
158    }
159    if let Ok(ip) = s.parse() {
160        return Some(ip);
161    }
162    // ip:port (IPv4 only; a bare IPv6 address has several colons)
163    let (host, port) = s.rsplit_once(':')?;
164    port.parse::<u16>().ok()?;
165    host.parse().ok()
166}
167
168/// Elements of the `Forwarded` header as (for, proto) per hop
169fn forwarded_hops(value: &str) -> Vec<(Option<String>, Option<String>)> {
170    value
171        .split(',')
172        .map(|element| {
173            let mut for_ = None;
174            let mut proto = None;
175            for pair in element.split(';') {
176                if let Some((k, v)) = pair.split_once('=') {
177                    let v = v.trim().trim_matches('"').to_string();
178                    match k.trim().to_ascii_lowercase().as_str() {
179                        "for" => for_ = Some(v),
180                        "proto" => proto = Some(v.to_ascii_lowercase()),
181                        _ => {}
182                    }
183                }
184            }
185            (for_, proto)
186        })
187        .collect()
188}
189
190impl Request {
191    /// The address of the real client, see [`crate::proxy`]. `None` only when
192    /// the request has no peer address at all.
193    pub fn client_ip(&self) -> Option<IpAddr> {
194        let peer = self.remote_addr?.ip();
195        let Some(trusted) = self.trusted_proxies.as_deref() else {
196            return Some(peer);
197        };
198        if !trusted.contains(peer) {
199            return Some(peer);
200        }
201        let hops: Vec<String> = match self.headers.get("forwarded").and_then(|v| v.to_str().ok()) {
202            Some(f) => forwarded_hops(f)
203                .into_iter()
204                .map(|(for_, _)| for_.unwrap_or_default())
205                .collect(),
206            None => self
207                .headers
208                .get_all("x-forwarded-for")
209                .iter()
210                .filter_map(|v| v.to_str().ok())
211                .flat_map(|v| v.split(','))
212                .map(|h| h.trim().to_string())
213                .collect(),
214        };
215        let mut known = peer;
216        for hop in hops.iter().rev() {
217            match parse_hop(hop) {
218                Some(ip) if trusted.contains(ip) => known = ip,
219                Some(ip) => return Some(ip),
220                // "unknown" / obfuscated identifiers: nothing more can be learned
221                None => return Some(known),
222            }
223        }
224        Some(known)
225    }
226
227    /// Scheme announced by a trusted proxy: `Some(true)` for https
228    pub(crate) fn forwarded_https(&self) -> bool {
229        let (Some(trusted), Some(peer)) = (self.trusted_proxies.as_deref(), self.remote_addr)
230        else {
231            return false;
232        };
233        if !trusted.contains(peer.ip()) {
234            return false;
235        }
236        // the hop closest to us is the one our own proxy wrote
237        if let Some(f) = self.headers.get("forwarded").and_then(|v| v.to_str().ok()) {
238            if let Some((_, Some(proto))) = forwarded_hops(f).into_iter().next_back() {
239                return proto == "https";
240            }
241        }
242        self.headers
243            .get("x-forwarded-proto")
244            .and_then(|v| v.to_str().ok())
245            .and_then(|v| v.rsplit(',').next())
246            .is_some_and(|v| v.trim().eq_ignore_ascii_case("https"))
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    fn ip(s: &str) -> IpAddr {
255        s.parse().unwrap()
256    }
257
258    #[test]
259    fn cidr_matching() {
260        let net: Cidr = "10.1.0.0/16".parse().unwrap();
261        assert!(net.contains(ip("10.1.200.3")));
262        assert!(!net.contains(ip("10.2.0.1")));
263        let odd: Cidr = "172.16.0.0/12".parse().unwrap();
264        assert!(odd.contains(ip("172.31.255.255")));
265        assert!(!odd.contains(ip("172.32.0.1")));
266        let host: Cidr = "203.0.113.7".parse().unwrap();
267        assert!(host.contains(ip("203.0.113.7")) && !host.contains(ip("203.0.113.8")));
268        let v6: Cidr = "fd00::/8".parse().unwrap();
269        assert!(v6.contains(ip("fd12::1")) && !v6.contains(ip("fe80::1")));
270        // families do not mix, mapped addresses are unwrapped
271        assert!(!net.contains(ip("fd00::1")));
272        assert!(net.contains(ip("::ffff:10.1.0.9")));
273        let all: Cidr = "0.0.0.0/0".parse().unwrap();
274        assert!(all.contains(ip("8.8.8.8")));
275    }
276
277    #[test]
278    fn cidr_rejects_garbage() {
279        for bad in [
280            "",
281            "10.0.0.0/33",
282            "10.0.0.0/x",
283            "nope",
284            "::1/129",
285            "10.0.0/8",
286        ] {
287            assert!(bad.parse::<Cidr>().is_err(), "{bad}");
288        }
289    }
290
291    #[test]
292    fn hop_parsing() {
293        assert_eq!(parse_hop("203.0.113.9"), Some(ip("203.0.113.9")));
294        assert_eq!(parse_hop(" 203.0.113.9:4711 "), Some(ip("203.0.113.9")));
295        assert_eq!(parse_hop("\"[2001:db8::1]:4711\""), Some(ip("2001:db8::1")));
296        assert_eq!(parse_hop("2001:db8::1"), Some(ip("2001:db8::1")));
297        assert_eq!(parse_hop("unknown"), None);
298        assert_eq!(parse_hop("_hidden"), None);
299        assert_eq!(parse_hop(""), None);
300    }
301
302    #[test]
303    fn private_networks_preset() {
304        let t = TrustedProxies::private_networks();
305        for private in [
306            "127.0.0.1",
307            "10.9.9.9",
308            "192.168.1.1",
309            "172.20.0.1",
310            "::1",
311            "fd00::5",
312        ] {
313            assert!(t.contains(ip(private)), "{private}");
314        }
315        for public in ["8.8.8.8", "172.32.0.1", "2001:db8::1"] {
316            assert!(!t.contains(ip(public)), "{public}");
317        }
318        assert!(TrustedProxies::new().is_empty());
319    }
320}