Skip to main content

http_handle/
abuse.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2// Copyright (c) 2023 - 2026 HTTP Handle
3
4//! Abuse protection beyond the per-minute rate limit: per-client
5//! bursts, per-route quotas, and global load shedding that spares
6//! trusted clients.
7//!
8//! An [`AbusePolicy`], set with
9//! [`ServerBuilder::abuse_policy`](crate::ServerBuilder::abuse_policy),
10//! is checked for every request whose client address is known, before
11//! the WAF and routing. The checks run in this order and the first
12//! limit exceeded answers:
13//!
14//! 1. **Burst**: at most [`Limit::max_requests`] per client in
15//!    [`Limit::window_ms`]. `429 Too Many Requests`.
16//! 2. **Route quota**: per client, for requests that match the named
17//!    [`Route`](crate::routing::Route). `429 Too Many Requests`.
18//! 3. **Global**: all clients together. Untrusted clients get
19//!    `503 Service Unavailable` once it is exceeded; clients in
20//!    [`AbusePolicy::trusted`] are still served.
21//!
22//! Routes listed in [`AbusePolicy::exempt_routes`] skip every check.
23//! Counting uses fixed windows. Each refusal logs a `warn` line naming
24//! the reason, the scope and the client, carries a `Retry-After`
25//! header, and is counted in [`stats`] and on `/metrics`.
26//!
27//! # Examples
28//!
29//! ```rust
30//! use http_handle::abuse::{AbusePolicy, Limit};
31//! use http_handle::Server;
32//!
33//! let policy = AbusePolicy {
34//!     burst: Some(Limit { max_requests: 20, window_ms: 1_000 }),
35//!     trusted: vec!["10.0.0.0/8".into()],
36//!     ..AbusePolicy::default()
37//! };
38//! let server = Server::builder()
39//!     .address("127.0.0.1:0")
40//!     .document_root(".")
41//!     .abuse_policy(policy)
42//!     .expect("valid policy")
43//!     .build()
44//!     .expect("server");
45//! assert!(server.abuse_policy().is_some());
46//! ```
47
48use crate::response::Response;
49use serde::{Deserialize, Serialize};
50use std::collections::HashMap;
51use std::net::IpAddr;
52use std::sync::atomic::{AtomicUsize, Ordering};
53use std::sync::{Arc, Mutex};
54use std::time::{Duration, Instant};
55use thiserror::Error;
56
57/// A request budget over a fixed window.
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct Limit {
60    /// Requests allowed per window.
61    pub max_requests: u32,
62    /// Window length in milliseconds.
63    pub window_ms: u64,
64}
65
66/// A per-client quota for one route.
67#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
68pub struct RouteQuota {
69    /// Name of the route, as given to [`Route::new`](crate::routing::Route::new).
70    pub route: String,
71    /// The budget.
72    pub limit: Limit,
73}
74
75/// Abuse controls. Every part is optional.
76#[derive(
77    Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize,
78)]
79#[serde(default)]
80pub struct AbusePolicy {
81    /// Per-client burst limit.
82    pub burst: Option<Limit>,
83    /// Per-client quotas for named routes.
84    pub route_quotas: Vec<RouteQuota>,
85    /// Limit across all clients; trusted clients are exempt.
86    pub global: Option<Limit>,
87    /// Trusted clients: IP addresses or CIDR blocks such as
88    /// `10.0.0.0/8` or `fd00::/8`.
89    pub trusted: Vec<String>,
90    /// Routes the policy does not apply to.
91    pub exempt_routes: Vec<String>,
92}
93
94/// Why an abuse policy was rejected.
95#[derive(Clone, Debug, PartialEq, Eq, Error)]
96pub enum AbuseError {
97    /// A limit allows no requests or has an empty window.
98    #[error(
99        "{0}: max_requests and window_ms must be greater than zero"
100    )]
101    InvalidLimit(String),
102    /// A trusted entry is not an IP address or CIDR block.
103    #[error("invalid trusted address `{0}`")]
104    InvalidTrusted(String),
105    /// Two quotas name the same route.
106    #[error("duplicate quota for route `{0}`")]
107    DuplicateQuota(String),
108}
109
110impl AbusePolicy {
111    /// Checks limits, trusted entries and quotas.
112    ///
113    /// # Errors
114    ///
115    /// Returns an [`AbuseError`] for a zero limit, an unparsable
116    /// trusted entry, or two quotas for one route.
117    pub fn validate(&self) -> Result<(), AbuseError> {
118        self.validate_limits()?;
119        if let Some(bad) =
120            self.trusted.iter().find(|t| parse_cidr(t).is_none())
121        {
122            return Err(AbuseError::InvalidTrusted(bad.clone()));
123        }
124        for (i, quota) in self.route_quotas.iter().enumerate() {
125            if self.route_quotas[..i]
126                .iter()
127                .any(|q| q.route == quota.route)
128            {
129                return Err(AbuseError::DuplicateQuota(
130                    quota.route.clone(),
131                ));
132            }
133        }
134        Ok(())
135    }
136
137    fn validate_limits(&self) -> Result<(), AbuseError> {
138        let named =
139            |name: &str, limit: &Limit| (name.to_string(), *limit);
140        let limits = self
141            .burst
142            .iter()
143            .map(|l| named("burst", l))
144            .chain(self.global.iter().map(|l| named("global", l)))
145            .chain(self.route_quotas.iter().map(|q| {
146                named(&format!("route `{}`", q.route), &q.limit)
147            }));
148        for (name, limit) in limits {
149            if limit.max_requests == 0 || limit.window_ms == 0 {
150                return Err(AbuseError::InvalidLimit(name));
151            }
152        }
153        Ok(())
154    }
155
156    fn is_trusted(&self, ip: IpAddr) -> bool {
157        self.trusted
158            .iter()
159            .filter_map(|t| parse_cidr(t))
160            .any(|(net, bits)| in_network(ip, net, bits))
161    }
162}
163
164/// Which limit refused a request.
165#[derive(Clone, Copy, Debug, PartialEq, Eq)]
166pub enum AbuseReason {
167    /// Per-client burst limit.
168    Burst,
169    /// Per-client route quota.
170    RouteQuota,
171    /// Global limit.
172    Global,
173}
174
175/// A refused request, as logged.
176#[derive(Clone, Debug, PartialEq, Eq)]
177pub struct AbuseEvent {
178    /// The limit that refused it.
179    pub reason: AbuseReason,
180    /// What was counted: `client`, `route:<name>` or `global`.
181    pub scope: String,
182    /// The client address.
183    pub client: IpAddr,
184    /// Seconds until the window resets, at least 1.
185    pub retry_after_secs: u64,
186}
187
188impl AbuseEvent {
189    /// One `key=value` line, the form written to the log.
190    pub fn to_log_line(&self) -> String {
191        let (reason, action) = match self.reason {
192            AbuseReason::Burst => ("burst", "throttled"),
193            AbuseReason::RouteQuota => ("route_quota", "throttled"),
194            AbuseReason::Global => ("global", "shed"),
195        };
196        format!(
197            "abuse reason={reason} scope={} action={action} client={}",
198            self.scope, self.client
199        )
200    }
201
202    /// The response sent instead of the resource.
203    pub(crate) fn response(&self) -> Response {
204        let mut response = match self.reason {
205            AbuseReason::Global => Response::new(
206                503,
207                "SERVICE UNAVAILABLE",
208                b"Server busy".to_vec(),
209            ),
210            _ => Response::new(
211                429,
212                "TOO MANY REQUESTS",
213                b"Rate limit exceeded".to_vec(),
214            ),
215        };
216        response.add_header("Content-Type", "text/plain");
217        response.add_header(
218            "Retry-After",
219            &self.retry_after_secs.to_string(),
220        );
221        response
222    }
223}
224
225/// One limit to check: reason, scope, counter key, budget.
226type Check = (AbuseReason, String, Option<IpAddr>, Limit);
227
228/// Fixed-window counters keyed by scope and client.
229type Windows = HashMap<(String, Option<IpAddr>), (Instant, u32)>;
230
231/// Entries kept before expired windows are pruned.
232const MAX_TRACKED: usize = 65_536;
233
234/// An [`AbusePolicy`] with its counters.
235#[derive(Clone, Debug, Serialize, Deserialize)]
236#[serde(try_from = "AbusePolicy", into = "AbusePolicy")]
237pub(crate) struct AbuseGuard {
238    policy: AbusePolicy,
239    windows: Arc<Mutex<Windows>>,
240}
241
242impl PartialEq for AbuseGuard {
243    fn eq(&self, other: &Self) -> bool {
244        self.policy == other.policy
245    }
246}
247
248impl Eq for AbuseGuard {}
249
250impl TryFrom<AbusePolicy> for AbuseGuard {
251    type Error = AbuseError;
252
253    fn try_from(policy: AbusePolicy) -> Result<Self, Self::Error> {
254        policy.validate()?;
255        Ok(Self {
256            policy,
257            windows: Arc::default(),
258        })
259    }
260}
261
262impl From<AbuseGuard> for AbusePolicy {
263    fn from(guard: AbuseGuard) -> Self {
264        guard.policy
265    }
266}
267
268impl AbuseGuard {
269    pub(crate) fn policy(&self) -> &AbusePolicy {
270        &self.policy
271    }
272
273    /// Counts the request and returns the event when a limit refuses
274    /// it. `route` is the name of the matched route, if any.
275    pub(crate) fn check(
276        &self,
277        client: IpAddr,
278        route: Option<&str>,
279    ) -> Option<AbuseEvent> {
280        let policy = &self.policy;
281        if route.is_some_and(|r| {
282            policy.exempt_routes.iter().any(|e| e == r)
283        }) {
284            return None;
285        }
286        let now = Instant::now();
287        let mut windows = match self.windows.lock() {
288            Ok(guard) => guard,
289            Err(poisoned) => poisoned.into_inner(),
290        };
291        prune(&mut windows, now, self.longest_window());
292        let checks = self.checks(client, route);
293        checks.into_iter().flatten().find_map(
294            |(reason, scope, key, limit)| {
295                let retry = hit(
296                    &mut windows,
297                    (scope.clone(), key),
298                    limit,
299                    now,
300                )?;
301                Some(AbuseEvent {
302                    reason,
303                    scope,
304                    client,
305                    retry_after_secs: retry,
306                })
307            },
308        )
309    }
310
311    /// The longest configured window.
312    fn longest_window(&self) -> Duration {
313        let policy = &self.policy;
314        let longest = policy
315            .burst
316            .iter()
317            .chain(policy.global.iter())
318            .chain(policy.route_quotas.iter().map(|q| &q.limit))
319            .map(|l| l.window_ms)
320            .max()
321            .unwrap_or(0);
322        Duration::from_millis(longest)
323    }
324
325    /// Number of live counter windows.
326    #[cfg(test)]
327    pub(crate) fn tracked(&self) -> usize {
328        self.windows.lock().map_or(0, |w| w.len())
329    }
330
331    /// The limits that apply to `client` on `route`, in check order,
332    /// each with its reason, scope and counter key.
333    fn checks(
334        &self,
335        client: IpAddr,
336        route: Option<&str>,
337    ) -> [Option<Check>; 3] {
338        let policy = &self.policy;
339        let quota = route.and_then(|r| {
340            policy.route_quotas.iter().find(|q| q.route == r)
341        });
342        [
343            policy.burst.map(|l| {
344                (
345                    AbuseReason::Burst,
346                    "client".to_string(),
347                    Some(client),
348                    l,
349                )
350            }),
351            quota.map(|q| {
352                let scope = format!("route:{}", q.route);
353                (AbuseReason::RouteQuota, scope, Some(client), q.limit)
354            }),
355            policy.global.filter(|_| !policy.is_trusted(client)).map(
356                |l| {
357                    (AbuseReason::Global, "global".to_string(), None, l)
358                },
359            ),
360        ]
361    }
362}
363
364/// Counts one request in the window for `key`. Returns the seconds to
365/// wait when the window is already full.
366fn hit(
367    windows: &mut Windows,
368    key: (String, Option<IpAddr>),
369    limit: Limit,
370    now: Instant,
371) -> Option<u64> {
372    let length = Duration::from_millis(limit.window_ms);
373    let entry = windows.entry(key).or_insert((now, 0));
374    if now.duration_since(entry.0) >= length {
375        *entry = (now, 0);
376    }
377    if entry.1 >= limit.max_requests {
378        let left = length.saturating_sub(now.duration_since(entry.0));
379        return Some(left.as_secs_f64().ceil().max(1.0) as u64);
380    }
381    entry.1 += 1;
382    None
383}
384
385/// Bounds the table once it reaches [`MAX_TRACKED`]: drops windows
386/// older than `longest`, and if every window is still live (a flood
387/// of distinct clients) starts again from an empty table, so memory
388/// and per-request work stay bounded.
389fn prune(windows: &mut Windows, now: Instant, longest: Duration) {
390    if windows.len() < MAX_TRACKED {
391        return;
392    }
393    windows
394        .retain(|_, (start, _)| now.duration_since(*start) < longest);
395    if windows.len() >= MAX_TRACKED / 2 {
396        log::warn!(
397            "abuse counters reset: {} live windows exceed the cap",
398            windows.len()
399        );
400        windows.clear();
401    }
402}
403
404/// `10.0.0.0/8` to (address, 8); a bare address gets a full mask.
405fn parse_cidr(value: &str) -> Option<(IpAddr, u8)> {
406    let (address, bits) = match value.split_once('/') {
407        Some((address, bits)) => {
408            (address, Some(bits.parse::<u8>().ok()?))
409        }
410        None => (value, None),
411    };
412    let address: IpAddr = address.trim().parse().ok()?;
413    let max = if address.is_ipv4() { 32 } else { 128 };
414    let bits = bits.unwrap_or(max);
415    (bits <= max).then_some((address, bits))
416}
417
418fn in_network(ip: IpAddr, net: IpAddr, bits: u8) -> bool {
419    let (ip, net, width) = match (ip, net) {
420        (IpAddr::V4(a), IpAddr::V4(b)) => {
421            (u128::from(a.to_bits()), u128::from(b.to_bits()), 32)
422        }
423        (IpAddr::V6(a), IpAddr::V6(b)) => {
424            (a.to_bits(), b.to_bits(), 128)
425        }
426        _ => return false,
427    };
428    let shift = width - u32::from(bits);
429    shift >= width || (ip >> shift) == (net >> shift)
430}
431
432static BURST: AtomicUsize = AtomicUsize::new(0);
433static ROUTE_QUOTA: AtomicUsize = AtomicUsize::new(0);
434static GLOBAL: AtomicUsize = AtomicUsize::new(0);
435
436/// Counts of refused requests since the process started.
437#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
438pub struct AbuseStats {
439    /// Refused by the burst limit.
440    pub burst: usize,
441    /// Refused by a route quota.
442    pub route_quota: usize,
443    /// Shed by the global limit.
444    pub global: usize,
445}
446
447/// Returns the abuse counters.
448pub fn stats() -> AbuseStats {
449    AbuseStats {
450        burst: BURST.load(Ordering::Relaxed),
451        route_quota: ROUTE_QUOTA.load(Ordering::Relaxed),
452        global: GLOBAL.load(Ordering::Relaxed),
453    }
454}
455
456/// Prometheus text lines for the `/metrics` endpoint.
457pub(crate) fn metrics_lines() -> String {
458    let s = stats();
459    format!(
460        "http_handle_abuse_total{{reason=\"burst\"}} {}\nhttp_handle_abuse_total{{reason=\"route_quota\"}} {}\nhttp_handle_abuse_total{{reason=\"global\"}} {}\n",
461        s.burst, s.route_quota, s.global
462    )
463}
464
465/// Logs and counts `event`.
466pub(crate) fn record(event: &AbuseEvent) {
467    log::warn!("{}", event.to_log_line());
468    let counter = match event.reason {
469        AbuseReason::Burst => &BURST,
470        AbuseReason::RouteQuota => &ROUTE_QUOTA,
471        AbuseReason::Global => &GLOBAL,
472    };
473    let _ = counter.fetch_add(1, Ordering::Relaxed);
474}