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}