Skip to main content

web/
client_ip.rs

1//! Deciding which address a request really came from.
2//!
3//! `X-Forwarded-For` is a claim, not a fact: anyone who can reach the port can
4//! set it. It is trustworthy only on the hop written by a proxy we control, so
5//! the header is consulted **only** when the immediate peer is a configured
6//! trusted proxy, and ignored entirely otherwise. Without that check a per-IP
7//! throttle keyed on the header is bypassed by changing one header value.
8
9use axum::http::HeaderMap;
10use std::net::{IpAddr, Ipv6Addr, SocketAddr};
11
12#[cfg(test)]
13mod tests;
14
15/// An address range trusted to speak for its clients.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct Cidr {
18    network: IpAddr,
19    prefix: u8,
20}
21
22/// Network bits kept when throttling an IPv6 client.
23///
24/// A single customer is routinely handed a whole /64, so keying on the full
25/// address would give one attacker 2^64 independent budgets — the limit would
26/// exist but never bind. /64 is the smallest block a client is assumed to
27/// control entirely.
28const IPV6_CLIENT_PREFIX: u32 = 64;
29
30/// Collapses an address to the identity a throttle should count.
31///
32/// IPv4 is used as-is; IPv6 is truncated to [`IPV6_CLIENT_PREFIX`] so host
33/// rotation inside one allocation cannot mint fresh budgets.
34#[must_use]
35pub fn throttle_key(addr: IpAddr) -> IpAddr {
36    match addr.to_canonical() {
37        IpAddr::V4(v4) => IpAddr::V4(v4),
38        IpAddr::V6(v6) => {
39            let masked = u128::from(v6) & (u128::MAX << (128 - IPV6_CLIENT_PREFIX));
40            IpAddr::V6(Ipv6Addr::from(masked))
41        }
42    }
43}
44
45/// Joins every `X-Forwarded-For` value on the request, in order.
46///
47/// A conforming proxy sends one comma-separated header, but nothing stops a
48/// client from sending its own and a proxy from appending a second line. Only
49/// the rightmost hop across all of them is evidence, so they must be read as
50/// one list.
51#[must_use]
52pub fn forwarded_for(headers: &HeaderMap) -> Option<String> {
53    let hops: Vec<&str> = headers
54        .get_all("x-forwarded-for")
55        .iter()
56        .filter_map(|value| value.to_str().ok())
57        .collect();
58
59    (!hops.is_empty()).then(|| hops.join(","))
60}
61
62impl Cidr {
63    /// Parses `10.104.0.0/16`, or a bare address as a single-host range.
64    #[must_use]
65    pub fn parse(spec: &str) -> Option<Self> {
66        let (addr, prefix) = match spec.split_once('/') {
67            Some((addr, prefix)) => (addr, prefix.parse().ok()?),
68            None => (spec, 0),
69        };
70
71        let network: IpAddr = addr.trim().parse().ok()?;
72        let full = if network.is_ipv4() { 32 } else { 128 };
73        let prefix = if spec.contains('/') { prefix } else { full };
74
75        (prefix <= full).then_some(Self { network, prefix })
76    }
77
78    #[must_use]
79    pub fn contains(&self, addr: IpAddr) -> bool {
80        match (self.network, addr) {
81            (IpAddr::V4(net), IpAddr::V4(other)) => {
82                masked(&net.octets(), &other.octets(), self.prefix)
83            }
84            (IpAddr::V6(net), IpAddr::V6(other)) => {
85                masked(&net.octets(), &other.octets(), self.prefix)
86            }
87            // A v4-mapped peer must not match a v6 range, or vice versa.
88            _ => false,
89        }
90    }
91}
92
93/// Compares the first `prefix` bits of two addresses.
94fn masked(network: &[u8], addr: &[u8], prefix: u8) -> bool {
95    let whole = usize::from(prefix / 8);
96    let bits = prefix % 8;
97
98    if network[..whole] != addr[..whole] {
99        return false;
100    }
101
102    if bits == 0 {
103        return true;
104    }
105
106    let mask = 0xffu8 << (8 - bits);
107    network[whole] & mask == addr[whole] & mask
108}
109
110/// Why a trusted-proxy configuration was rejected at startup.
111#[derive(Debug)]
112pub enum TrustedProxyError {
113    Unset,
114    NoUsableEntry(String),
115    Unparseable(String),
116}
117
118impl std::fmt::Display for TrustedProxyError {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            Self::Unset => write!(
122                f,
123                "TRUSTED_PROXIES is not set. Give the CIDRs of the proxies in \
124                 front of this server, or `none` if it is directly exposed"
125            ),
126            Self::NoUsableEntry(spec) => write!(
127                f,
128                "TRUSTED_PROXIES ({spec:?}) contains no usable CIDR; use `none` \
129                 to trust no proxy"
130            ),
131            Self::Unparseable(spec) => write!(
132                f,
133                "TRUSTED_PROXIES ({spec:?}) has an entry that is not a CIDR. Every \
134                 entry must parse: one bad range silently un-trusts an ingress \
135                 path and puts its users on a shared login budget"
136            ),
137        }
138    }
139}
140
141impl std::error::Error for TrustedProxyError {}
142
143/// The proxies whose `X-Forwarded-For` we believe.
144#[derive(Debug, Clone, Default)]
145pub struct TrustedProxies(Vec<Cidr>);
146
147impl TrustedProxies {
148    /// Reads a comma-separated list of CIDRs, skipping anything unparseable.
149    ///
150    /// Trusts nothing when the list is empty, so the peer address is always
151    /// used. Prefer [`Self::from_env`], which refuses both to guess and to
152    /// tolerate a typo.
153    #[must_use]
154    pub fn from_spec(spec: &str) -> Self {
155        Self(Self::entries(spec).into_iter().flatten().collect())
156    }
157
158    /// Each entry, `None` where it did not parse.
159    fn entries(spec: &str) -> Vec<Option<Cidr>> {
160        spec.split(',')
161            .map(str::trim)
162            .filter(|s| !s.is_empty())
163            .map(Cidr::parse)
164            .collect()
165    }
166
167    /// Reads `TRUSTED_PROXIES`, which must be set explicitly.
168    ///
169    /// Neither default is safe to assume, which is why there is none. Trusting
170    /// nothing while actually behind a proxy keys *every* user on the ingress
171    /// address, so one attacker exhausts the shared budget and no one can log
172    /// in. Trusting something while directly exposed lets anyone forge their
173    /// identity. Set a CIDR list, or the literal `none` for a directly-exposed
174    /// deployment.
175    ///
176    /// EVERY entry must parse. Dropping a bad one with a warning would leave a
177    /// typo in one range silently un-trusting one ingress path, collapsing that
178    /// path's users onto a single shared login budget — the failure this
179    /// variable exists to prevent, reintroduced quietly.
180    ///
181    /// # Errors
182    /// If the variable is unset, has no entries, or any entry does not parse.
183    pub fn from_env() -> Result<Self, TrustedProxyError> {
184        let spec = std::env::var("TRUSTED_PROXIES").map_err(|_| TrustedProxyError::Unset)?;
185
186        if spec.trim() == "none" {
187            return Ok(Self(Vec::new()));
188        }
189
190        let entries = Self::entries(&spec);
191        if entries.is_empty() {
192            return Err(TrustedProxyError::NoUsableEntry(spec));
193        }
194        if entries.iter().any(Option::is_none) {
195            return Err(TrustedProxyError::Unparseable(spec));
196        }
197
198        Ok(Self::from_spec(&spec))
199    }
200
201    #[must_use]
202    pub fn trusts(&self, addr: IpAddr) -> bool {
203        let addr = addr.to_canonical();
204        self.0.iter().any(|cidr| cidr.contains(addr))
205    }
206
207    /// Resolves the client address for `peer`, given the request's
208    /// `X-Forwarded-For` value.
209    ///
210    /// Walks the hops right-to-left, skipping ones that are themselves trusted,
211    /// and returns the first address that is not. That entry is the one our own
212    /// proxy observed; everything to its left was supplied by the client and is
213    /// worthless. Falls back to the peer whenever the peer is not trusted, the
214    /// header is absent, or every entry is unusable.
215    ///
216    /// `forwarded_for` must carry *every* `X-Forwarded-For` value the request
217    /// held, in order — see [`forwarded_for`]. Reading only the first would let
218    /// a client that sends its own header push the proxy's observed hop into a
219    /// second line this never looks at.
220    ///
221    /// Addresses are canonicalized, so `::ffff:203.0.113.7` and `203.0.113.7`
222    /// are one client rather than two budgets.
223    #[must_use]
224    pub fn client_ip(&self, peer: SocketAddr, forwarded_for: Option<&str>) -> IpAddr {
225        let peer = peer.ip().to_canonical();
226
227        if !self.trusts(peer) {
228            return peer;
229        }
230
231        forwarded_for
232            .unwrap_or_default()
233            .rsplit(',')
234            .filter_map(|hop| hop.trim().parse::<IpAddr>().ok())
235            .map(|hop| hop.to_canonical())
236            .find(|hop| !self.trusts(*hop))
237            .unwrap_or(peer)
238    }
239}