Skip to main content

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}