Skip to main content

rustlavel_http/
trusted_proxies.rs

1//! Whose `X-Forwarded-For` to believe.
2//!
3//! Behind a load balancer every request arrives from the balancer's address,
4//! so the client's real address is only available in a header the balancer
5//! added. The trap is that a *header* is something any client can send. A
6//! server that reads `X-Forwarded-For` from whoever supplies it has not
7//! learned the client's address; it has let the client choose one. Anything
8//! keyed on that address — a rate limiter, an audit log, a block list — is
9//! then trivially defeated by a header.
10//!
11//! So the header is believed only when the connection came from a proxy that
12//! was named in advance:
13//!
14//! ```ignore
15//! App::new()?.middleware(TrustProxies::from_config(app.config()))
16//! // or
17//! App::new()?.middleware(TrustProxies::at(["10.0.0.0/8", "172.16.0.0/12"]))
18//! ```
19//!
20//! Without this middleware, [`Request::ip`] is the address of whoever opened
21//! the socket, which is always true even when it is not always useful. With
22//! it, and only for a connection from a trusted proxy, it becomes the
23//! left-most address in the forwarded chain that the trusted proxies did not
24//! themselves add.
25//!
26//! The same applies to the scheme. A proxy that terminates TLS forwards a
27//! plain HTTP request with `X-Forwarded-Proto: https`, and without that
28//! header an application would generate `http://` links on a site that is
29//! entirely `https://`.
30//!
31//! `TrustProxies::any()` exists and is documented as what it is: correct on a
32//! platform where nothing but the platform's own proxy can reach the process
33//! — a Heroku dyno, a Cloud Run container, a pod behind an ingress with no
34//! other route in — and a hole anywhere else.
35
36use crate::handler::BoxFuture;
37use crate::middleware::{Middleware, Next};
38use crate::request::Request;
39use crate::response::Response;
40use rustlavel_core::Config;
41use std::net::IpAddr;
42
43/// What a trusted proxy told us about the original client.
44#[derive(Debug, Clone, Default)]
45pub struct Forwarded {
46    /// The client address, when the chain named one.
47    pub ip: Option<String>,
48    /// `http` or `https`, when the proxy said.
49    pub scheme: Option<String>,
50    /// The `Host` the client asked for, when the proxy said.
51    pub host: Option<String>,
52    /// The port the client connected to, when the proxy said.
53    pub port: Option<u16>,
54}
55
56/// One entry in the trust list: a single address, a CIDR range, or everything.
57#[derive(Debug, Clone, PartialEq, Eq)]
58enum Trusted {
59    Any,
60    Address(IpAddr),
61    Network { base: IpAddr, prefix: u8 },
62}
63
64impl Trusted {
65    fn parse(entry: &str) -> Option<Trusted> {
66        let entry = entry.trim();
67        if entry == "*" || entry.eq_ignore_ascii_case("any") {
68            return Some(Trusted::Any);
69        }
70        match entry.split_once('/') {
71            None => entry.parse().ok().map(Trusted::Address),
72            Some((base, prefix)) => {
73                let base: IpAddr = base.trim().parse().ok()?;
74                let prefix: u8 = prefix.trim().parse().ok()?;
75                let width = if base.is_ipv4() { 32 } else { 128 };
76                (prefix <= width).then_some(Trusted::Network { base, prefix })
77            }
78        }
79    }
80
81    fn contains(&self, address: IpAddr) -> bool {
82        match self {
83            Trusted::Any => true,
84            Trusted::Address(trusted) => *trusted == address,
85            Trusted::Network { base, prefix } => in_network(*base, *prefix, address),
86        }
87    }
88}
89
90/// Whether an address falls inside a CIDR block.
91///
92/// Compared over the raw octets rather than as numbers, so one routine covers
93/// both address families and a `/48` of IPv6 needs no special case. A v4 and a
94/// v6 address never match each other, including a v4-mapped v6 address: two
95/// spellings of one host are still two different things to a config file, and
96/// silently equating them would let a `/8` of private v4 space quietly cover
97/// addresses nobody listed.
98fn in_network(base: IpAddr, prefix: u8, address: IpAddr) -> bool {
99    let (base, address) = match (base, address) {
100        (IpAddr::V4(base), IpAddr::V4(address)) => (base.octets().to_vec(), address.octets().to_vec()),
101        (IpAddr::V6(base), IpAddr::V6(address)) => (base.octets().to_vec(), address.octets().to_vec()),
102        _ => return false,
103    };
104
105    let whole_bytes = (prefix / 8) as usize;
106    if base[..whole_bytes] != address[..whole_bytes] {
107        return false;
108    }
109    let leftover = prefix % 8;
110    if leftover == 0 {
111        return true;
112    }
113    let mask = 0xFFu8 << (8 - leftover);
114    base[whole_bytes] & mask == address[whole_bytes] & mask
115}
116
117#[derive(Debug, Clone, Default)]
118pub struct TrustProxies {
119    proxies: Vec<Trusted>,
120}
121
122impl TrustProxies {
123    /// Trust nobody. Every forwarded header is ignored, which is the default
124    /// and is right for a process reached directly from the internet.
125    pub fn none() -> Self {
126        TrustProxies::default()
127    }
128
129    /// Trust these addresses and ranges: `"10.0.0.0/8"`, `"192.168.1.7"`,
130    /// `"2001:db8::/32"`.
131    ///
132    /// An entry that is not an address or a CIDR block is dropped rather than
133    /// silently widening the list — a typo must never mean "trust everyone".
134    pub fn at<I, S>(proxies: I) -> Self
135    where
136        I: IntoIterator<Item = S>,
137        S: AsRef<str>,
138    {
139        TrustProxies {
140            proxies: proxies.into_iter().filter_map(|p| Trusted::parse(p.as_ref())).collect(),
141        }
142    }
143
144    /// Trust whatever opened the connection.
145    ///
146    /// Correct only where nothing but the platform's own proxy can reach this
147    /// process, and where that proxy replaces the forwarded headers rather
148    /// than appending to them: a Heroku dyno, a Cloud Run container, a pod
149    /// whose only ingress is the ingress. Anywhere a client can open a socket
150    /// to the application directly, this hands every client the ability to
151    /// choose its own address.
152    pub fn any() -> Self {
153        TrustProxies { proxies: vec![Trusted::Any] }
154    }
155
156    /// Read `trustedproxy.proxies` — an array, or a comma-separated string so
157    /// it can come from `.env`. `*` means [`TrustProxies::any`].
158    ///
159    /// The key is Laravel's, from `config/trustedproxy.php`.
160    pub fn from_config(config: &Config) -> Self {
161        TrustProxies::at(config.list("trustedproxy.proxies"))
162    }
163
164    fn trusts(&self, address: IpAddr) -> bool {
165        self.proxies.iter().any(|proxy| proxy.contains(address))
166    }
167
168    /// The client address from a forwarded chain, discarding the trailing
169    /// entries the trusted proxies added themselves.
170    ///
171    /// `X-Forwarded-For: client, proxy-a, proxy-b` is read right to left: each
172    /// trusted hop is dropped, and the first address that is not one of ours
173    /// is the client. Taking the left-most entry instead would take whatever
174    /// the client wrote there before the first proxy appended to it.
175    fn client_from(&self, chain: &str) -> Option<String> {
176        let hops: Vec<&str> = chain.split(',').map(str::trim).filter(|h| !h.is_empty()).collect();
177        for hop in hops.iter().rev() {
178            let address = strip_port(hop).parse::<IpAddr>().ok()?;
179            if !self.trusts(address) {
180                return Some(address.to_string());
181            }
182        }
183        // Every hop was a proxy we trust, so the nearest one is the best answer
184        // available — this is a proxy calling the application about itself.
185        hops.first().map(|hop| strip_port(hop).to_string())
186    }
187}
188
189/// `1.2.3.4:5678` and `[::1]:80` down to the address.
190fn strip_port(hop: &str) -> &str {
191    let hop = hop.trim();
192    if let Some(rest) = hop.strip_prefix('[') {
193        return rest.split(']').next().unwrap_or(hop);
194    }
195    // A bare IPv6 address has several colons; only strip a single trailing one.
196    match hop.rsplit_once(':') {
197        Some((address, _)) if !address.contains(':') => address,
198        _ => hop,
199    }
200}
201
202impl Middleware for TrustProxies {
203    fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
204        let peer = request.peer_addr().map(|addr| addr.ip());
205        // No trust list, or a connection from somewhere not on it: the headers
206        // are whatever the client chose to send, and are ignored.
207        if !peer.is_some_and(|peer| self.trusts(peer)) {
208            return next.run(request);
209        }
210
211        let mut forwarded = Forwarded::default();
212        if let Some(chain) = request.header("x-forwarded-for") {
213            forwarded.ip = self.client_from(chain);
214        }
215        if let Some(scheme) = request.header("x-forwarded-proto") {
216            let scheme = scheme.split(',').next().unwrap_or("").trim().to_ascii_lowercase();
217            if scheme == "http" || scheme == "https" {
218                forwarded.scheme = Some(scheme);
219            }
220        }
221        if let Some(host) = request.header("x-forwarded-host")
222            && let Some(host) = host.split(',').next().map(str::trim).filter(|h| !h.is_empty())
223        {
224            forwarded.host = Some(host.to_string());
225        }
226        if let Some(port) = request.header("x-forwarded-port") {
227            forwarded.port = port.split(',').next().and_then(|p| p.trim().parse().ok());
228        }
229
230        request.extend(forwarded);
231        next.run(request)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::method::Method;
239    use crate::router::Router;
240    use crate::testing::TestClient;
241
242    fn client(trust: TrustProxies) -> TestClient {
243        let mut router = Router::new();
244        router.middleware(trust);
245        router.get("/", |req: Request| async move {
246            Response::text(format!(
247                "{}|{}|{}",
248                req.ip().unwrap_or_else(|| "none".into()),
249                req.scheme(),
250                req.forwarded_host().unwrap_or("none")
251            ))
252        });
253        TestClient::new(router)
254    }
255
256    /// A request as it arrives from `peer`, carrying a forwarded chain.
257    fn from(peer: &str, chain: &str) -> Request {
258        Request::new(Method::Get, "/")
259            .with_peer(format!("{peer}:44321").parse().expect("an address"))
260            .with_header("x-forwarded-for", chain)
261    }
262
263    #[tokio::test]
264    async fn without_a_trust_list_the_header_is_ignored_entirely() {
265        let response = client(TrustProxies::none()).send(from("203.0.113.9", "1.2.3.4")).await;
266        assert_eq!(response.body(), "203.0.113.9|http|none", "the peer, not what it claimed");
267    }
268
269    #[tokio::test]
270    async fn an_untrusted_peer_cannot_choose_its_own_address() {
271        // The attack this exists to stop: a client sending a header to get a
272        // rate limit bucket of its own on every request.
273        let trust = TrustProxies::at(["10.0.0.0/8"]);
274        let response = client(trust).send(from("203.0.113.9", "9.9.9.9")).await;
275        assert_eq!(response.body(), "203.0.113.9|http|none");
276    }
277
278    #[tokio::test]
279    async fn a_trusted_proxy_is_believed() {
280        let trust = TrustProxies::at(["10.0.0.0/8"]);
281        let response = client(trust).send(from("10.1.2.3", "203.0.113.9")).await;
282        assert_eq!(response.body(), "203.0.113.9|http|none");
283    }
284
285    #[tokio::test]
286    async fn trusted_hops_are_stripped_from_the_right() {
287        // client, then two of our own proxies. Reading left to right would
288        // take whatever the client put in the header before the first hop.
289        let trust = TrustProxies::at(["10.0.0.0/8"]);
290        let request = from("10.0.0.2", "203.0.113.9, 10.0.0.1, 10.0.0.2");
291        assert_eq!(client(trust).send(request).await.body(), "203.0.113.9|http|none");
292    }
293
294    #[tokio::test]
295    async fn a_spoofed_prefix_before_the_real_client_is_not_believed() {
296        // The client sent "x-forwarded-for: 9.9.9.9"; the proxy appended the
297        // address it actually saw. The right-most untrusted hop is the truth.
298        let trust = TrustProxies::at(["10.0.0.0/8"]);
299        let request = from("10.0.0.1", "9.9.9.9, 203.0.113.9");
300        assert_eq!(client(trust).send(request).await.body(), "203.0.113.9|http|none");
301    }
302
303    #[tokio::test]
304    async fn ports_are_stripped_from_forwarded_addresses() {
305        let trust = TrustProxies::at(["10.0.0.0/8"]);
306        let request = from("10.0.0.1", "203.0.113.9:51234");
307        assert_eq!(client(trust).send(request).await.body(), "203.0.113.9|http|none");
308    }
309
310    #[tokio::test]
311    async fn the_scheme_and_host_come_from_a_trusted_proxy_only() {
312        let trust = TrustProxies::at(["10.0.0.0/8"]);
313        let trusted = from("10.0.0.1", "203.0.113.9")
314            .with_header("x-forwarded-proto", "https")
315            .with_header("x-forwarded-host", "app.example.com");
316        assert_eq!(client(trust.clone()).send(trusted).await.body(), "203.0.113.9|https|app.example.com");
317
318        let spoofed = from("198.51.100.7", "1.2.3.4")
319            .with_header("x-forwarded-proto", "https")
320            .with_header("x-forwarded-host", "evil.example");
321        assert_eq!(client(trust).send(spoofed).await.body(), "198.51.100.7|http|none");
322    }
323
324    #[tokio::test]
325    async fn any_trusts_whoever_connected() {
326        let response = client(TrustProxies::any()).send(from("203.0.113.9", "1.2.3.4")).await;
327        assert_eq!(response.body(), "1.2.3.4|http|none");
328    }
329
330    #[test]
331    fn cidr_matching_covers_both_families_and_odd_prefixes() {
332        let ten = Trusted::parse("10.0.0.0/8").unwrap();
333        assert!(ten.contains("10.255.255.255".parse().unwrap()));
334        assert!(!ten.contains("11.0.0.1".parse().unwrap()));
335
336        // A prefix that is not a whole number of bytes.
337        let odd = Trusted::parse("192.168.4.0/22").unwrap();
338        assert!(odd.contains("192.168.7.255".parse().unwrap()));
339        assert!(!odd.contains("192.168.8.1".parse().unwrap()));
340
341        let v6 = Trusted::parse("2001:db8::/32").unwrap();
342        assert!(v6.contains("2001:db8:1234::1".parse().unwrap()));
343        assert!(!v6.contains("2001:db9::1".parse().unwrap()));
344        assert!(!v6.contains("10.0.0.1".parse().unwrap()), "families never match");
345
346        assert_eq!(Trusted::parse("not an address"), None);
347        assert_eq!(Trusted::parse("10.0.0.0/33"), None);
348        assert_eq!(Trusted::parse("*"), Some(Trusted::Any));
349    }
350
351    #[test]
352    fn a_typo_in_the_list_is_dropped_rather_than_widening_it() {
353        let trust = TrustProxies::at(["10.0.0.0/8", "hello", ""]);
354        assert_eq!(trust.proxies.len(), 1);
355        assert!(!trust.trusts("203.0.113.9".parse().unwrap()));
356    }
357
358    #[test]
359    fn from_config_reads_a_comma_separated_env_value() {
360        let config = Config::new();
361        config.set("trustedproxy.proxies", "10.0.0.0/8, 192.168.1.7");
362        let trust = TrustProxies::from_config(&config);
363        assert!(trust.trusts("10.9.9.9".parse().unwrap()));
364        assert!(trust.trusts("192.168.1.7".parse().unwrap()));
365        assert!(!trust.trusts("192.168.1.8".parse().unwrap()));
366
367        assert!(!TrustProxies::from_config(&Config::new()).trusts("10.0.0.1".parse().unwrap()));
368    }
369
370    #[test]
371    fn ipv6_hops_keep_their_colons() {
372        assert_eq!(strip_port("[2001:db8::1]:443"), "2001:db8::1");
373        assert_eq!(strip_port("2001:db8::1"), "2001:db8::1");
374        assert_eq!(strip_port("1.2.3.4:80"), "1.2.3.4");
375        assert_eq!(strip_port("1.2.3.4"), "1.2.3.4");
376    }
377}