Skip to main content

webx_host/
rate_limit.rs

1//! Per-IP token-bucket rate-limiting middleware.
2//!
3//! Configure via `appsettings.json` `RateLimit` section or manually:
4//!
5//! ```ignore
6//! .use_middleware_with(|| {
7//!     Arc::new(RateLimitMiddleware::from_config(&options.rate_limit))
8//! })
9//! ```
10
11use crate::problem_response::write_problem;
12use std::collections::HashMap;
13use std::net::IpAddr;
14use std::ops::ControlFlow;
15use std::str::FromStr;
16use std::time::Instant;
17use tokio::sync::Mutex;
18use webx_core::config::RateLimitSection;
19use webx_core::error::Result;
20use webx_core::http::IHttpContext;
21use webx_core::middleware::IMiddleware;
22
23struct TokenBucket {
24    tokens: f64,
25    last_refill: Instant,
26}
27
28impl TokenBucket {
29    fn new(burst_size: f64) -> Self {
30        Self {
31            tokens: burst_size,
32            last_refill: Instant::now(),
33        }
34    }
35
36    fn try_consume(&mut self, rate: f64, burst: f64) -> bool {
37        let now = Instant::now();
38        let elapsed = now.duration_since(self.last_refill).as_secs_f64();
39        self.tokens = (self.tokens + elapsed * rate).min(burst);
40        self.last_refill = now;
41
42        if self.tokens >= 1.0 {
43            self.tokens -= 1.0;
44            true
45        } else {
46            false
47        }
48    }
49}
50
51/// Inner rate-limiting state shared behind a [`Mutex`].
52pub struct RateLimiter {
53    buckets: Mutex<HashMap<IpAddr, TokenBucket>>,
54    rate: f64,
55    burst: f64,
56    max_ips: usize,
57    trust_proxy: bool,
58}
59
60impl RateLimiter {
61    pub fn new(requests_per_second: f64, burst_size: u32, max_tracked_ips: usize) -> Self {
62        Self {
63            buckets: Mutex::new(HashMap::new()),
64            rate: requests_per_second,
65            burst: burst_size as f64,
66            max_ips: max_tracked_ips.max(1),
67            trust_proxy: false,
68        }
69    }
70
71    pub fn with_trust_proxy(mut self, trust: bool) -> Self {
72        self.trust_proxy = trust;
73        self
74    }
75
76    async fn allow(&self, ip: IpAddr) -> bool {
77        let mut buckets = self.buckets.lock().await;
78        if !buckets.contains_key(&ip) && buckets.len() >= self.max_ips {
79            evict_oldest(&mut buckets);
80        }
81        let bucket = buckets
82            .entry(ip)
83            .or_insert_with(|| TokenBucket::new(self.burst));
84        bucket.try_consume(self.rate, self.burst)
85    }
86
87    /// Current number of tracked client IPs (for tests).
88    pub async fn tracked_ip_count(&self) -> usize {
89        self.buckets.lock().await.len()
90    }
91
92    /// Seconds a client should wait before retrying (RFC 6585 `Retry-After`).
93    ///
94    /// A bucket refills one token every `1 / rate` seconds; a whole second is
95    /// the smallest useful hint, and 1s is used when the rate is unset.
96    pub fn retry_after_secs(&self) -> u64 {
97        if self.rate <= 0.0 {
98            return 1;
99        }
100        ((1.0 / self.rate).ceil() as u64).max(1)
101    }
102}
103
104fn evict_oldest(buckets: &mut HashMap<IpAddr, TokenBucket>) {
105    if buckets.is_empty() {
106        return;
107    }
108    let oldest = buckets
109        .iter()
110        .min_by_key(|(_, b)| b.last_refill)
111        .map(|(ip, _)| *ip);
112    if let Some(ip) = oldest {
113        buckets.remove(&ip);
114    }
115}
116
117pub struct RateLimitMiddleware {
118    limiter: RateLimiter,
119    trust_proxy: bool,
120    /// CIDR ranges whose peer addresses may supply `X-Forwarded-For`.
121    trusted_proxies: Vec<IpCidr>,
122}
123
124impl RateLimitMiddleware {
125    pub fn new(requests_per_second: f64, burst_size: u32) -> Self {
126        Self::new_with_max_ips(requests_per_second, burst_size, 10_000)
127    }
128
129    pub fn new_with_max_ips(
130        requests_per_second: f64,
131        burst_size: u32,
132        max_tracked_ips: usize,
133    ) -> Self {
134        Self {
135            limiter: RateLimiter::new(requests_per_second, burst_size, max_tracked_ips),
136            trust_proxy: false,
137            trusted_proxies: Vec::new(),
138        }
139    }
140
141    pub fn from_config(cfg: &RateLimitSection) -> Self {
142        let trusted_proxies: Vec<IpCidr> = cfg
143            .trusted_proxies
144            .iter()
145            .filter_map(|value| match IpCidr::parse(value) {
146                Some(cidr) => Some(cidr),
147                None => {
148                    tracing::warn!(
149                        value,
150                        "RateLimit.TrustedProxies entry is not a valid CIDR; ignoring it"
151                    );
152                    None
153                }
154            })
155            .collect();
156        if cfg.trust_proxy && trusted_proxies.is_empty() {
157            tracing::warn!(
158                "RateLimit.TrustProxy is enabled but TrustedProxies is empty; \
159                 forwarded headers are ignored (a client could otherwise spoof them)"
160            );
161        }
162        Self {
163            limiter: RateLimiter::new(cfg.requests_per_second, cfg.burst_size, cfg.max_tracked_ips),
164            trust_proxy: cfg.trust_proxy,
165            trusted_proxies,
166        }
167    }
168}
169
170/// A parsed CIDR range used to identify trusted reverse proxies.
171#[derive(Debug, Clone, Copy)]
172struct IpCidr {
173    network: IpAddr,
174    prefix: u8,
175}
176
177impl IpCidr {
178    fn parse(value: &str) -> Option<Self> {
179        let (addr, prefix) = value.split_once('/')?;
180        let network = IpAddr::from_str(addr.trim()).ok()?;
181        let prefix: u8 = prefix.trim().parse().ok()?;
182        let max_prefix = match network {
183            IpAddr::V4(_) => 32,
184            IpAddr::V6(_) => 128,
185        };
186        (prefix <= max_prefix).then_some(Self { network, prefix })
187    }
188
189    fn contains(&self, ip: IpAddr) -> bool {
190        match (self.network, ip) {
191            (IpAddr::V4(network), IpAddr::V4(ip)) => {
192                let mask = if self.prefix == 0 {
193                    0
194                } else {
195                    u32::MAX << (32 - self.prefix)
196                };
197                (u32::from(network) & mask) == (u32::from(ip) & mask)
198            }
199            (IpAddr::V6(network), IpAddr::V6(ip)) => {
200                let mask = if self.prefix == 0 {
201                    0
202                } else {
203                    u128::MAX << (128 - self.prefix)
204                };
205                (u128::from(network) & mask) == (u128::from(ip) & mask)
206            }
207            _ => false,
208        }
209    }
210}
211
212#[async_trait::async_trait]
213impl IMiddleware for RateLimitMiddleware {
214    async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
215        // No peer address means the host cannot tell clients apart. Falling
216        // back to a constant would turn per-client limiting into one global
217        // bucket, so the limiter passes the request through and the host is
218        // expected to expose `IHttpRequest::remote_addr`.
219        let Some(ip) = extract_client_ip(ctx, self.trust_proxy, &self.trusted_proxies) else {
220            tracing::debug!("rate limit skipped: request has no peer address");
221            return Ok(ControlFlow::Continue(()));
222        };
223
224        if !self.limiter.allow(ip).await {
225            let retry_after = self.limiter.retry_after_secs().to_string();
226            ctx.response_mut().set_header("retry-after", &retry_after);
227            write_problem(ctx, 429, "Too Many Requests").await;
228            return Ok(ControlFlow::Break(()));
229        }
230
231        Ok(ControlFlow::Continue(()))
232    }
233}
234
235/// Resolve the client IP the token bucket is keyed by.
236///
237/// The connection's peer address is authoritative. When `trust_proxy` is on,
238/// forwarded headers are honoured **only if the direct peer address belongs to
239/// one of `trusted_proxies`**; the client is then the right-most
240/// `X-Forwarded-For` entry that is not itself a trusted proxy (a client can
241/// otherwise prepend arbitrary addresses to spoof the key).
242fn extract_client_ip(
243    ctx: &dyn IHttpContext,
244    trust_proxy: bool,
245    trusted_proxies: &[IpCidr],
246) -> Option<IpAddr> {
247    let peer = ctx.request().remote_addr().map(|addr| addr.ip());
248
249    if trust_proxy && !trusted_proxies.is_empty() {
250        let peer_is_trusted = peer.is_some_and(|ip| trusted_proxies.iter().any(|c| c.contains(ip)));
251        if peer_is_trusted {
252            if let Some(forwarded) = ctx.request().header("x-forwarded-for") {
253                if let Some(client) = client_from_forwarded_for(forwarded, trusted_proxies) {
254                    return Some(client);
255                }
256            }
257            if let Some(real) = ctx.request().header("x-real-ip") {
258                if let Ok(ip) = IpAddr::from_str(real.trim()) {
259                    return Some(ip);
260                }
261            }
262        }
263    }
264
265    peer
266}
267
268/// The right-most address in `X-Forwarded-For` that is not a trusted proxy.
269///
270/// Proxies append, so the right-most entries are the ones closest to the
271/// server; skipping trusted proxies from the right yields the real client.
272fn client_from_forwarded_for(value: &str, trusted_proxies: &[IpCidr]) -> Option<IpAddr> {
273    let hops: Vec<IpAddr> = value
274        .split(',')
275        .filter_map(|part| IpAddr::from_str(part.trim()).ok())
276        .collect();
277
278    hops.iter()
279        .rev()
280        .find(|ip| !trusted_proxies.iter().any(|cidr| cidr.contains(**ip)))
281        .copied()
282        .or_else(|| hops.first().copied())
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use std::net::Ipv4Addr;
289
290    #[tokio::test]
291    async fn rate_limiter_evicts_oldest_when_max_ips_reached() {
292        let limiter = RateLimiter::new(100.0, 10, 2);
293        assert!(limiter.allow(IpAddr::V4(Ipv4Addr::new(1, 0, 0, 1))).await);
294        assert!(limiter.allow(IpAddr::V4(Ipv4Addr::new(2, 0, 0, 1))).await);
295        assert_eq!(limiter.tracked_ip_count().await, 2);
296
297        assert!(limiter.allow(IpAddr::V4(Ipv4Addr::new(3, 0, 0, 1))).await);
298        assert_eq!(limiter.tracked_ip_count().await, 2);
299    }
300
301    #[tokio::test]
302    async fn retry_after_matches_the_refill_rate() {
303        // 10 requests/s: a token every 0.1s, so the smallest whole-second hint
304        // is still 1s.
305        let fast = RateLimiter::new(10.0, 1, 10);
306        assert_eq!(fast.retry_after_secs(), 1);
307
308        // 0.5 requests/s: one token every 2s.
309        let slow = RateLimiter::new(0.5, 1, 10);
310        assert_eq!(slow.retry_after_secs(), 2);
311
312        // Never report 0 (that would invite an immediate retry storm).
313        let unset = RateLimiter::new(0.0, 1, 10);
314        assert_eq!(unset.retry_after_secs(), 1);
315    }
316
317    #[test]
318    fn cidr_matches_only_inside_the_range() {
319        let cidr = IpCidr::parse("10.0.0.0/8").expect("valid cidr");
320        assert!(cidr.contains("10.1.2.3".parse().unwrap()));
321        assert!(!cidr.contains("11.0.0.1".parse().unwrap()));
322        assert!(IpCidr::parse("0.0.0.0/0")
323            .unwrap()
324            .contains("203.0.113.9".parse().unwrap()));
325        assert!(IpCidr::parse("not-a-cidr").is_none());
326        assert!(IpCidr::parse("10.0.0.0/33").is_none());
327
328        let v6 = IpCidr::parse("2001:db8::/32").expect("valid v6 cidr");
329        assert!(v6.contains("2001:db8::1".parse().unwrap()));
330        assert!(!v6.contains("2001:db9::1".parse().unwrap()));
331    }
332
333    #[test]
334    fn forwarded_for_skips_trusted_proxies_from_the_right() {
335        let proxies = vec![IpCidr::parse("10.0.0.0/8").unwrap()];
336        assert_eq!(
337            client_from_forwarded_for("203.0.113.7, 10.0.0.2, 10.0.0.3", &proxies),
338            Some("203.0.113.7".parse().unwrap()),
339            "the right-most untrusted hop is the client"
340        );
341        assert_eq!(
342            client_from_forwarded_for("1.1.1.1, 203.0.113.7, 10.0.0.3", &proxies),
343            Some("203.0.113.7".parse().unwrap()),
344            "a spoofed left-most entry must not win"
345        );
346        assert_eq!(
347            client_from_forwarded_for("10.0.0.1, 10.0.0.3", &proxies),
348            Some("10.0.0.1".parse().unwrap()),
349            "when every hop is a trusted proxy fall back to the left-most"
350        );
351    }
352}