Skip to main content

rws_http/
ip_filter.rs

1//! Addresses and CIDR ranges: [`TrustedProxies`] and the range parsing the
2//! `IpFilter` middleware (in rust-web-server's `ip_filter`) is built on.
3
4/// An address range in IPv6 space; IPv4 rules are stored IPv4-mapped
5/// (`::ffff:0:0/96`), so one comparison covers both families.
6#[derive(Debug, Clone, PartialEq)]
7#[doc(hidden)]
8pub struct IpRange {
9    pub network: u128,
10    pub mask: u128,
11}
12
13/// Peers whose `X-Forwarded-*` / `Forwarded` headers rws believes
14/// (`trusted_proxies`, 18.24.0). The default is loopback and the private
15/// ranges load balancers usually sit in; `*` trusts everyone (the behaviour
16/// before 18.24), `none` no one.
17#[derive(Debug, Clone, PartialEq)]
18pub enum TrustedProxies {
19    All,
20    Ranges(Vec<IpRangeEntry>),
21}
22
23/// One parsed range of [`TrustedProxies`].
24#[derive(Debug, Clone, PartialEq)]
25pub struct IpRangeEntry(IpRange);
26
27/// The default `trusted_proxies`.
28pub const DEFAULT_TRUSTED_PROXIES: &str = "127.0.0.0/8, ::1, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, fc00::/7";
29
30impl TrustedProxies {
31    /// A comma-separated list; empty means the default. `Err` names the
32    /// first entry that isn't an address or range.
33    pub fn parse(list: &str) -> Result<TrustedProxies, String> {
34        let list = if list.trim().is_empty() { DEFAULT_TRUSTED_PROXIES } else { list.trim() };
35        if list == "*" {
36            return Ok(TrustedProxies::All);
37        }
38        if list.eq_ignore_ascii_case("none") {
39            return Ok(TrustedProxies::Ranges(Vec::new()));
40        }
41        list.split(',')
42            .map(str::trim)
43            .filter(|e| !e.is_empty())
44            .map(|e| IpRange::parse(e).map(IpRangeEntry).ok_or_else(|| format!("trusted_proxies: {e:?} is not an address or CIDR range")))
45            .collect::<Result<Vec<_>, _>>()
46            .map(TrustedProxies::Ranges)
47    }
48
49    /// Whether a peer at `ip` may set the forwarding headers.
50    pub fn trusts(&self, ip: &str) -> bool {
51        match self {
52            TrustedProxies::All => true,
53            TrustedProxies::Ranges(ranges) => parse_ip(ip).is_some_and(|a| ranges.iter().any(|r| (a & r.0.mask) == r.0.network)),
54        }
55    }
56}
57
58/// `true` if `entry` is an address or CIDR range `IpFilter` understands.
59pub fn is_valid_entry(entry: &str) -> bool {
60    IpRange::parse(entry).is_some()
61}
62
63impl IpRange {
64    #[doc(hidden)]
65    pub fn parse(entry: &str) -> Option<Self> {
66        let entry = entry.trim();
67        let (addr, prefix_len) = match entry.split_once('/') {
68            Some((addr, len)) => {
69                let len: u32 = len.parse().ok()?;
70                let is_v4 = addr.parse::<std::net::Ipv4Addr>().is_ok();
71                let max = if is_v4 { 32 } else { 128 };
72                if len > max {
73                    return None;
74                }
75                (addr, if is_v4 { len + 96 } else { len })
76            }
77            None => (entry, 128),
78        };
79        let network = parse_ip(addr)?;
80        let mask = if prefix_len == 0 { 0 } else { !0u128 << (128 - prefix_len) };
81        Some(IpRange { network: network & mask, mask })
82    }
83}
84
85/// An IPv4 or IPv6 address as a number in IPv6 space (IPv4 → `::ffff:a.b.c.d`).
86/// A zone suffix (`fe80::1%eth0`) is ignored.
87#[doc(hidden)]
88pub fn parse_ip(s: &str) -> Option<u128> {
89    let s = s.trim().trim_start_matches('[').trim_end_matches(']');
90    let s = s.split('%').next()?;
91    match s.parse::<std::net::IpAddr>().ok()? {
92        std::net::IpAddr::V4(v4) => Some(u128::from(v4.to_ipv6_mapped())),
93        std::net::IpAddr::V6(v6) => Some(u128::from(v6)),
94    }
95}
96
97// ── the client's address ──────────────────────────────────────────────────────
98
99/// The address a request comes from, for everything that judges or records
100/// a client (rate limits, IP filters, the blocklist, WAF rules, logs).
101///
102/// From a `trusted_proxies` peer (a load balancer, a CDN) that is the
103/// rightmost `X-Forwarded-For` address the trusted proxies didn't add
104/// themselves — walking right to left past trusted addresses, as nginx's
105/// `real_ip_recursive` does, so a client can't pick its address by sending
106/// its own `X-Forwarded-For`; without that header, the last `Forwarded:
107/// for=`. From anyone else, or with `client_ip = "peer"`, it is the peer.
108///
109/// Proxying keeps using the peer: the `X-Forwarded-For` rws sends on is the
110/// incoming chain plus the peer, so the next hop can do the same walk.
111pub fn client_ip<'a>(request: &crate::request::Request, peer: &'a str) -> std::borrow::Cow<'a, str> {
112    let config = crate::server_config::ServerConfig::current();
113    client_ip_with(request, peer, &config.trusted_proxies, config.client_ip_forwarded)
114}
115
116/// [`client_ip`] with the trust list and mode given (no config read).
117/// The scheme the client used: `https` on a TLS connection; behind a
118/// trusted proxy that terminates TLS (`trusted_proxies`, unless
119/// `client_ip = "peer"`), what it says in `X-Forwarded-Proto` (its first
120/// value) or else `Forwarded: proto=`; `http` otherwise. For URLs rws hands
121/// back to the client (an agent card, an OAuth redirect URI) and the
122/// `Secure` cookie flag: behind a TLS-terminating load balancer the
123/// connection itself is plain HTTP.
124pub fn request_scheme(request: &crate::request::Request, peer: &str, tls: bool) -> &'static str {
125    let config = crate::server_config::ServerConfig::current();
126    request_scheme_with(request, peer, tls, &config.trusted_proxies, config.client_ip_forwarded)
127}
128
129/// [`request_scheme`] with the trust list given (no config read).
130#[doc(hidden)]
131pub fn request_scheme_with(request: &crate::request::Request, peer: &str, tls: bool, trusted: &TrustedProxies, forwarded: bool) -> &'static str {
132    if tls {
133        return "https";
134    }
135    if !forwarded || !trusted.trusts(peer) {
136        return "http";
137    }
138    let first = |name: &str| request.headers.iter().find(|h| h.name.eq_ignore_ascii_case(name)).map(|h| h.value.split(',').next().unwrap_or("").trim().to_string());
139    let proto = first("x-forwarded-proto").or_else(|| {
140        let element = first("forwarded")?;
141        element.split(';').find_map(|pair| {
142            let (k, v) = pair.split_once('=')?;
143            k.trim().eq_ignore_ascii_case("proto").then(|| v.trim().trim_matches('"').to_string())
144        })
145    });
146    match proto {
147        Some(p) if p.eq_ignore_ascii_case("https") => "https",
148        _ => "http",
149    }
150}
151
152#[doc(hidden)]
153pub fn client_ip_with<'a>(request: &crate::request::Request, peer: &'a str, trusted: &TrustedProxies, forwarded: bool) -> std::borrow::Cow<'a, str> {
154    use std::borrow::Cow;
155    if !forwarded || !trusted.trusts(peer) {
156        return Cow::Borrowed(peer);
157    }
158    // Every X-Forwarded-For line, in order, as one list.
159    let xff: Vec<&str> = request
160        .headers
161        .iter()
162        .filter(|h| h.name.eq_ignore_ascii_case("x-forwarded-for"))
163        .flat_map(|h| h.value.split(','))
164        .collect();
165    let hops: Vec<&str> = if xff.is_empty() {
166        request
167            .headers
168            .iter()
169            .filter(|h| h.name.eq_ignore_ascii_case("forwarded"))
170            .flat_map(|h| h.value.split(','))
171            .filter_map(|element| {
172                element.split(';').find_map(|pair| {
173                    let (k, v) = pair.split_once('=')?;
174                    k.trim().eq_ignore_ascii_case("for").then_some(v)
175                })
176            })
177            .collect()
178    } else {
179        xff
180    };
181    let mut chosen: Option<String> = None;
182    for hop in hops.iter().rev() {
183        let Some(ip) = hop_address(hop) else { break };
184        let trusted_hop = trusted.trusts(&ip);
185        chosen = Some(ip);
186        if !trusted_hop {
187            break;
188        }
189    }
190    chosen.map_or(Cow::Borrowed(peer), Cow::Owned)
191}
192
193/// One hop as an address in its canonical text: `203.0.113.7`,
194/// `203.0.113.7:4711`, `2001:db8::1`, `[2001:db8::1]:443`, `"[…]"` (RFC 7239
195/// quoting). `None` for `unknown`, obfuscated `_names` and garbage.
196fn hop_address(hop: &str) -> Option<String> {
197    let hop = hop.trim().trim_matches('"');
198    let bare = if let Some(rest) = hop.strip_prefix('[') {
199        rest.split(']').next()?
200    } else if hop.matches(':').count() == 1 {
201        hop.split(':').next()?
202    } else {
203        hop
204    };
205    bare.parse::<std::net::IpAddr>().ok().map(|ip| ip.to_string())
206}
207
208#[cfg(test)]
209mod client_ip_tests {
210    use super::*;
211    use crate::header::Header;
212    use crate::request::Request;
213
214    fn with(headers: &[(&str, &str)]) -> Request {
215        Request {
216            method: "GET".into(),
217            request_uri: "/".into(),
218            http_version: "HTTP/1.1".into(),
219            headers: headers.iter().map(|(n, v)| Header::new(n.to_string(), v.to_string())).collect(),
220            body: Default::default(),
221        }
222    }
223    fn private() -> TrustedProxies {
224        TrustedProxies::parse("").unwrap()
225    }
226
227    #[test]
228    fn a_trusted_peer_gives_the_client_in_x_forwarded_for() {
229        let r = with(&[("X-Forwarded-For", "88.99.218.47, 172.30.0.3")]);
230        assert_eq!(client_ip_with(&r, "172.30.0.3", &private(), true), "88.99.218.47");
231    }
232
233    #[test]
234    fn a_client_cant_choose_its_address() {
235        // The client sent 6.6.6.6 itself; the gateway appended what it saw.
236        let r = with(&[("X-Forwarded-For", "6.6.6.6,88.99.218.47, 172.30.0.3")]);
237        assert_eq!(client_ip_with(&r, "172.30.0.3", &private(), true), "88.99.218.47");
238        // A private address in the client's own part is skipped like a proxy's,
239        // but the walk stops at the first public one.
240        let r = with(&[("X-Forwarded-For", "10.9.9.9, 88.99.218.47")]);
241        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "88.99.218.47");
242    }
243
244    #[test]
245    fn an_untrusted_peer_or_peer_mode_keeps_the_peer() {
246        let r = with(&[("X-Forwarded-For", "1.2.3.4")]);
247        assert_eq!(client_ip_with(&r, "88.99.218.47", &private(), true), "88.99.218.47");
248        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), false), "10.0.0.1");
249        assert_eq!(client_ip_with(&r, "10.0.0.1", &TrustedProxies::parse("none").unwrap(), true), "10.0.0.1");
250    }
251
252    #[test]
253    fn several_lines_ports_and_ipv6() {
254        let r = with(&[("X-Forwarded-For", "203.0.113.7:4711"), ("X-Forwarded-For", "10.0.0.2")]);
255        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "203.0.113.7");
256        let r = with(&[("X-Forwarded-For", "[2001:db8::1]:443, fd00::2")]);
257        assert_eq!(client_ip_with(&r, "::1", &private(), true), "2001:db8::1");
258    }
259
260    #[test]
261    fn forwarded_when_there_is_no_x_forwarded_for() {
262        let r = with(&[("Forwarded", "for=\"[2001:db8::7]\";proto=https, for=10.0.0.9")]);
263        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "2001:db8::7");
264    }
265
266    #[test]
267    fn garbage_or_only_trusted_hops() {
268        // An unparseable hop ends the walk at the last address read.
269        let r = with(&[("X-Forwarded-For", "203.0.113.7, unknown, 10.0.0.5")]);
270        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "10.0.0.5");
271        // Nothing usable at all: the peer.
272        let r = with(&[("X-Forwarded-For", "nonsense")]);
273        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "10.0.0.1");
274        let r = with(&[]);
275        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "10.0.0.1");
276        // Every hop trusted: the leftmost.
277        let r = with(&[("X-Forwarded-For", "10.1.1.1, 10.2.2.2")]);
278        assert_eq!(client_ip_with(&r, "10.0.0.1", &private(), true), "10.1.1.1");
279    }
280}