throttle_kit/client_ip.rs
1//! Client identity resolution for rate limiting behind proxies.
2//!
3//! The rate-limit key derived from a request is attacker-controllable
4//! territory: any client can set `X-Forwarded-For` (XFF) and, if the
5//! service believes it blindly, mint a fresh GCRA budget per request
6//! (the threat model's former OPEN-1, a complete bypass). This module
7//! resolves a client identity that is secure by default.
8//!
9//! # Resolution algorithm (spec)
10//!
11//! Given the request's direct peer address (from
12//! `ConnectInfo<SocketAddr>` in the request extensions) and the
13//! [`ClientIpConfig`]:
14//!
15//! 1. Direct peer NOT in `trusted_proxies` → client = peer IP (headers
16//! are lies). An **empty** `trusted_proxies` list therefore means the
17//! forwarded header is ignored entirely.
18//! 2. Direct peer trusted → parse the forwarded header (`XFF`, or the
19//! `trusted_header` override), split on `,`, trim each entry, walk
20//! RIGHT-TO-LEFT skipping `num_trusted_hops` entries (our proxy
21//! chain), and take the next entry leftward as the client. NEVER
22//! trust left-to-right: every entry left of the resolved one is
23//! attacker-controlled. Malformed header (missing, non-ASCII, or a
24//! chosen entry that is not an IP address), missing header, or chain
25//! shorter than `num_trusted_hops` → fall back to the peer IP and
26//! emit a `tracing::warn!`.
27//! 3. No `ConnectInfo` available → the caller applies
28//! [`MissingClientPolicy`] (default [`MissingClientPolicy::Reject`]
29//! → fail closed with `503`).
30//!
31//! # What `num_trusted_hops` counts
32//!
33//! The rightmost header entries were appended by *our own trusted
34//! infrastructure* (each trusted proxy appends the address it saw).
35//! `num_trusted_hops` is how many of those rightmost entries belong to
36//! our proxy chain **in front of the direct peer** and must be skipped:
37//!
38//! - `internet → ALB → nginx → app` (both ALB and nginx trusted, nginx
39//! is the direct peer): nginx appends the ALB's address, so XFF is
40//! `"client, alb-ip"` and the default `num_trusted_hops = 1` skips
41//! `alb-ip`, yielding `client`.
42//! - `internet → nginx → app` (nginx is the only proxy and the direct
43//! peer): XFF is `"client"`; set `num_trusted_hops = 0` to take the
44//! rightmost entry. Leaving the default `1` here falls back to the
45//! peer (nginx's) address — all clients then share one bucket. That
46//! is *safe but coarse*; never compensate by trusting entries further
47//! left than your own infrastructure actually appends.
48//!
49//! Spoofed entries always sit LEFT of the entries our proxies appended,
50//! so a correctly configured hop count never reads them.
51//!
52//! # IPv4-mapped IPv6
53//!
54//! v4-mapped IPv6 addresses (`::ffff:a.b.c.d`) are **treated as IPv4**
55//! everywhere: for [`IpNet`] membership and for the resolved identity.
56//! A dual-stack socket reporting the peer as `::ffff:203.0.113.9` thus
57//! matches a `203.0.113.0/24` trust entry and yields the key
58//! `"203.0.113.9"`. They never match IPv6 networks.
59//!
60//! # axum plumbing
61//!
62//! The peer address comes from `ConnectInfo<SocketAddr>`, which axum
63//! only injects when the router is served with
64//! `.into_make_service_with_connect_info::<SocketAddr>()`. Without it,
65//! step 3 applies: requests are rejected (default) or share a fallback
66//! bucket.
67
68use std::borrow::Cow;
69use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
70
71use http::{HeaderMap, HeaderName};
72
73/// Errors from [`IpNet::parse`].
74#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
75pub enum ClientIpError {
76 /// The address portion is not a valid IP address.
77 #[error("invalid IP address: {0}")]
78 InvalidIp(String),
79 /// The prefix length is not a number or exceeds the address family's
80 /// width (32 for IPv4, 128 for IPv6).
81 #[error("invalid prefix length: {0}")]
82 InvalidPrefix(String),
83}
84
85/// An IP network: CIDR prefix membership without any new dependency.
86///
87/// Construct with [`IpNet::parse`] (`"10.0.0.0/8"`, or a bare IP which
88/// becomes `/32` / `/128`) or the struct literals.
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
90pub enum IpNet {
91 /// An IPv4 CIDR range.
92 V4 {
93 /// Network address. Host bits beyond `prefix` are ignored by
94 /// [`IpNet::contains`].
95 addr: Ipv4Addr,
96 /// Prefix length, 0–32.
97 prefix: u8,
98 },
99 /// An IPv6 CIDR range.
100 V6 {
101 /// Network address. Host bits beyond `prefix` are ignored by
102 /// [`IpNet::contains`].
103 addr: Ipv6Addr,
104 /// Prefix length, 0–128.
105 prefix: u8,
106 },
107}
108
109impl IpNet {
110 /// Parse `"10.0.0.0/8"`, `"2001:db8::/32"`, or a bare IP (implicit
111 /// `/32` for IPv4, `/128` for IPv6). A v4-mapped IPv6 address
112 /// (`::ffff:10.0.0.1`) is normalized to IPv4.
113 pub fn parse(s: &str) -> Result<Self, ClientIpError> {
114 let (addr_str, prefix) = match s.split_once('/') {
115 Some((addr, prefix)) => {
116 let prefix = prefix
117 .trim()
118 .parse::<u8>()
119 .map_err(|_| ClientIpError::InvalidPrefix(prefix.to_string()))?;
120 (addr, Some(prefix))
121 }
122 None => (s, None),
123 };
124
125 let addr: IpAddr = addr_str
126 .trim()
127 .parse()
128 .map_err(|_| ClientIpError::InvalidIp(addr_str.to_string()))?;
129 let addr = normalize_ip(addr);
130
131 match (addr, prefix) {
132 (IpAddr::V4(addr), None) => Ok(IpNet::V4 { addr, prefix: 32 }),
133 (IpAddr::V4(addr), Some(prefix)) if prefix <= 32 => Ok(IpNet::V4 {
134 addr: mask_v4(addr, prefix),
135 prefix,
136 }),
137 (IpAddr::V4(_), Some(prefix)) => Err(ClientIpError::InvalidPrefix(prefix.to_string())),
138 (IpAddr::V6(addr), None) => Ok(IpNet::V6 { addr, prefix: 128 }),
139 (IpAddr::V6(addr), Some(prefix)) if prefix <= 128 => Ok(IpNet::V6 {
140 addr: mask_v6(addr, prefix),
141 prefix,
142 }),
143 (IpAddr::V6(_), Some(prefix)) => Err(ClientIpError::InvalidPrefix(prefix.to_string())),
144 }
145 }
146
147 /// Exact CIDR membership at the prefix edge.
148 ///
149 /// A v4-mapped IPv6 argument is evaluated as IPv4 (see [module
150 /// docs](self)), so it matches IPv4 networks only. Cross-family
151 /// membership is otherwise `false`.
152 pub fn contains(&self, ip: IpAddr) -> bool {
153 let ip = normalize_ip(ip);
154 match (self, ip) {
155 (IpNet::V4 { addr, prefix }, IpAddr::V4(other)) => {
156 let mask = v4_mask(*prefix);
157 mask & u32::from(*addr) == mask & u32::from(other)
158 }
159 (IpNet::V6 { addr, prefix }, IpAddr::V6(other)) => {
160 let mask = v6_mask(*prefix);
161 mask & u128::from(*addr) == mask & u128::from(other)
162 }
163 _ => false,
164 }
165 }
166}
167
168/// Contiguous prefix mask for IPv4. No shift overflow: prefix 0 → 0,
169/// prefix ≥ 32 → all ones.
170fn v4_mask(prefix: u8) -> u32 {
171 if prefix == 0 {
172 0
173 } else if prefix >= 32 {
174 u32::MAX
175 } else {
176 u32::MAX << (32 - prefix)
177 }
178}
179
180/// Contiguous prefix mask for IPv6. No shift overflow: prefix 0 → 0,
181/// prefix ≥ 128 → all ones.
182fn v6_mask(prefix: u8) -> u128 {
183 if prefix == 0 {
184 0
185 } else if prefix >= 128 {
186 u128::MAX
187 } else {
188 u128::MAX << (128 - prefix)
189 }
190}
191
192fn mask_v4(addr: Ipv4Addr, prefix: u8) -> Ipv4Addr {
193 Ipv4Addr::from(v4_mask(prefix) & u32::from(addr))
194}
195
196fn mask_v6(addr: Ipv6Addr, prefix: u8) -> Ipv6Addr {
197 Ipv6Addr::from(v6_mask(prefix) & u128::from(addr))
198}
199
200/// v4-mapped IPv6 (`::ffff:a.b.c.d`) is treated as IPv4 everywhere.
201fn normalize_ip(ip: IpAddr) -> IpAddr {
202 match ip {
203 IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
204 Some(v4) => IpAddr::V4(v4),
205 None => IpAddr::V6(v6),
206 },
207 other => other,
208 }
209}
210
211/// Configuration for deriving the rate-limit key from the client IP.
212///
213/// See the [module docs](self) for the resolution algorithm.
214///
215/// The default is secure by default: no trusted proxies means the
216/// forwarded header is ignored and every client is keyed by its socket
217/// address.
218#[derive(Debug, Clone, Default)]
219pub struct ClientIpConfig {
220 /// Peers whose forwarded header we may believe. EMPTY DEFAULT =
221 /// forwarded header ignored entirely (secure by default).
222 pub trusted_proxies: Vec<IpNet>,
223 /// Right-to-left walk: skip this many entries (our proxy chain), the
224 /// next entry leftward is the client. NEVER trust left-to-right.
225 /// Default 1.
226 pub num_trusted_hops: usize,
227 /// Header override for platforms with dedicated headers
228 /// (`CF-Connecting-IP`, `True-Client-IP`). Same trust rules apply.
229 /// Default `None` = `X-Forwarded-For`.
230 pub trusted_header: Option<HeaderName>,
231}
232
233/// Policy for requests whose client identity cannot be resolved
234/// (algorithm step 3: no `ConnectInfo` in the request extensions).
235#[derive(Debug, Clone, Default, PartialEq, Eq)]
236pub enum MissingClientPolicy {
237 /// Default: fail closed with `503` when identity is unresolvable.
238 #[default]
239 Reject,
240 /// Explicit opt-in fallback key (shared bucket). All such requests
241 /// are rate limited as one identity.
242 FallbackKey(Cow<'static, str>),
243}
244
245/// Where a resolved client identity came from.
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum ClientIpSource {
248 /// The direct peer's socket address (header ignored or used as
249 /// fallback).
250 PeerSocket,
251 /// An entry from the forwarded header, after the trust walk.
252 ForwardedHeader,
253}
254
255/// A successfully resolved client identity.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub struct ResolvedClient {
258 /// The client IP. v4-mapped IPv6 is normalized to IPv4.
259 pub ip: IpAddr,
260 /// Whether this came from the socket or the forwarded header.
261 pub source: ClientIpSource,
262}
263
264/// No peer address was available (no `ConnectInfo` in the request
265/// extensions) — the caller applies its [`MissingClientPolicy`].
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub struct MissingClientIdentity;
268
269/// Resolve the client identity for a request.
270///
271/// Implements the algorithm in the [module docs](self) verbatim:
272/// REQ-THROTTLE-100 (untrusted peer → socket address regardless of
273/// headers), REQ-THROTTLE-101 (trusted peer → right-to-left walk minus
274/// hops), REQ-THROTTLE-102 (malformed/missing header → peer IP), and
275/// REQ-THROTTLE-103 (no peer → `Err(MissingClientIdentity)`, caller
276/// applies [`MissingClientPolicy`]).
277///
278/// `peer` is the direct peer address, from
279/// [`peer_ip_from_extensions`] (axum `ConnectInfo<SocketAddr>`).
280pub fn resolve_client_identity(
281 headers: &HeaderMap,
282 peer: Option<IpAddr>,
283 config: &ClientIpConfig,
284) -> Result<ResolvedClient, MissingClientIdentity> {
285 // Step 3: no ConnectInfo — the caller decides.
286 let peer = peer.ok_or(MissingClientIdentity)?;
287
288 // Step 1: untrusted (or no) trusted_proxies — headers are lies.
289 let peer_trusted = !config.trusted_proxies.is_empty()
290 && config.trusted_proxies.iter().any(|net| net.contains(peer));
291 if !peer_trusted {
292 return Ok(ResolvedClient {
293 ip: normalize_ip(peer),
294 source: ClientIpSource::PeerSocket,
295 });
296 }
297
298 // Step 2: trusted peer — walk the forwarded header right-to-left.
299 // `split` is double-ended: rev() + nth(hops) is the spec's
300 // "take entries[len-1-hops]" walk with no intermediate allocation,
301 // and yields nothing when the chain is shorter than the hop count.
302 let header_value = match config.trusted_header.as_ref() {
303 Some(name) => headers.get(name),
304 None => headers.get("x-forwarded-for"),
305 };
306 let resolved = header_value
307 .and_then(|value| value.to_str().ok())
308 .and_then(|value| value.split(',').rev().nth(config.num_trusted_hops))
309 .map(str::trim)
310 .and_then(|entry| entry.parse().ok());
311
312 match resolved {
313 Some(ip) => Ok(ResolvedClient {
314 ip: normalize_ip(ip),
315 source: ClientIpSource::ForwardedHeader,
316 }),
317 None => {
318 tracing::warn!(
319 target: "throttle_kit::client_ip",
320 "malformed or too-short forwarded header; falling back to peer address"
321 );
322 Ok(ResolvedClient {
323 ip: normalize_ip(peer),
324 source: ClientIpSource::PeerSocket,
325 })
326 }
327 }
328}
329
330/// Extract the direct peer IP from axum's `ConnectInfo<SocketAddr>`
331/// request extension, if present.
332///
333/// The router must be served with
334/// `.into_make_service_with_connect_info::<SocketAddr>()` for this to
335/// find anything.
336pub fn peer_ip_from_extensions(extensions: &http::Extensions) -> Option<IpAddr> {
337 extensions
338 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
339 .map(|connect_info| connect_info.0.ip())
340}
341
342/// Convenience wrapper: resolve the client identity for a full request.
343pub fn resolve_from_request<B>(
344 request: &http::Request<B>,
345 config: &ClientIpConfig,
346) -> Result<ResolvedClient, MissingClientIdentity> {
347 resolve_client_identity(
348 request.headers(),
349 peer_ip_from_extensions(request.extensions()),
350 config,
351 )
352}