aster_forge_actix_middleware/
rate_limit.rs

1//! Shared Actix rate-limit building blocks.
2//!
3//! This module keeps product-neutral rate-limit mechanics in Forge while leaving
4//! product response envelopes and protocol-specific error bodies in application
5//! crates. It provides:
6//!
7//! - a trusted-proxy-aware IP key extractor for `actix-governor`;
8//! - small quota helpers for building Actix governor configs from non-zero
9//!   `(seconds_per_request, burst_size)` pairs;
10//! - a keyed string limiter for protocol endpoints that rate-limit by usernames,
11//!   emails, or other normalized business keys.
12
13use std::fmt;
14use std::net::{IpAddr, Ipv4Addr};
15use std::num::{NonZeroU32, NonZeroU64};
16use std::sync::Arc;
17use std::time::Duration;
18
19use actix_governor::{
20    GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
21};
22use actix_web::dev::ServiceRequest;
23use actix_web::http::header::ContentType;
24use actix_web::{HttpResponse, HttpResponseBuilder};
25use governor::clock::{Clock, DefaultClock, QuantaInstant};
26use governor::middleware::NoOpMiddleware;
27use governor::state::keyed::DefaultKeyedStateStore;
28use governor::{NotUntil, Quota, RateLimiter};
29use ipnet::IpNet;
30
31type StringKeyedLimiter =
32    RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock, NoOpMiddleware>;
33
34/// Trusted-proxy-aware IP key extractor for `actix-governor`.
35///
36/// The extractor uses the direct peer address by default. When the peer address
37/// matches one of the trusted proxy CIDR entries, the leftmost `X-Forwarded-For`
38/// address is used as the client key. Invalid or missing forwarded addresses
39/// fall back to the direct peer address.
40///
41/// Deployments without a peer address (e.g. Unix domain sockets) fall back to
42/// `127.0.0.1`, so every client shares one rate-limit bucket and a single
43/// burst rejects all of them. Products serving over UDS should disable IP
44/// rate limiting or use [`NormalizedStringRateLimiter`] business keys instead.
45type RejectionResponseFactory =
46    dyn Fn(u64, HttpResponseBuilder) -> HttpResponse + Send + Sync + 'static;
47
48#[derive(Clone)]
49pub struct TrustedProxyIpKeyExtractor {
50    trusted: Vec<IpNet>,
51    rejection_response: Option<Arc<RejectionResponseFactory>>,
52}
53
54impl fmt::Debug for TrustedProxyIpKeyExtractor {
55    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
56        formatter
57            .debug_struct("TrustedProxyIpKeyExtractor")
58            .field("trusted", &self.trusted)
59            .field(
60                "has_custom_rejection_response",
61                &self.rejection_response.is_some(),
62            )
63            .finish()
64    }
65}
66
67impl TrustedProxyIpKeyExtractor {
68    /// Builds an extractor from raw trusted proxy entries.
69    ///
70    /// Entries may be CIDR ranges or single IP addresses. Invalid entries are
71    /// skipped by `aster_forge_utils::net::parse_trusted_proxies` after logging
72    /// a warning.
73    #[must_use]
74    pub fn new(trusted_proxies: &[String]) -> Self {
75        Self {
76            trusted: aster_forge_utils::net::parse_trusted_proxies(trusted_proxies),
77            rejection_response: None,
78        }
79    }
80
81    /// Builds an extractor from an already parsed trusted proxy list.
82    #[must_use]
83    pub fn from_trusted(trusted: Vec<IpNet>) -> Self {
84        Self {
85            trusted,
86            rejection_response: None,
87        }
88    }
89
90    /// Uses a product-provided response factory when the request exceeds its quota.
91    ///
92    /// The factory receives the retry delay in whole seconds and the response builder created by
93    /// `actix-governor`. Products can use this to preserve their response envelope and error code
94    /// without reimplementing trusted-proxy extraction.
95    #[must_use]
96    pub fn with_rejection_response<F>(mut self, factory: F) -> Self
97    where
98        F: Fn(u64, HttpResponseBuilder) -> HttpResponse + Send + Sync + 'static,
99    {
100        self.rejection_response = Some(Arc::new(factory));
101        self
102    }
103
104    /// Returns whether the provided IP is trusted as a proxy.
105    #[must_use]
106    pub fn is_trusted(&self, ip: IpAddr) -> bool {
107        aster_forge_utils::net::is_trusted_proxy(ip, &self.trusted)
108    }
109
110    /// Resolves the client IP for a request and direct peer IP.
111    #[must_use]
112    pub fn real_ip(&self, req: &ServiceRequest, peer: IpAddr) -> IpAddr {
113        crate::client_ip::real_ip_from_trusted_headers(req.headers(), peer, &self.trusted)
114    }
115}
116
117impl KeyExtractor for TrustedProxyIpKeyExtractor {
118    type Key = IpAddr;
119    type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
120
121    fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
122        let peer = req
123            .peer_addr()
124            .map_or(IpAddr::V4(Ipv4Addr::LOCALHOST), |socket| socket.ip());
125        Ok(self.real_ip(req, peer))
126    }
127
128    fn exceed_rate_limit_response(
129        &self,
130        negative: &NotUntil<QuantaInstant>,
131        mut response: HttpResponseBuilder,
132    ) -> HttpResponse {
133        let retry_after = retry_after_seconds(negative);
134        if let Some(factory) = &self.rejection_response {
135            return factory(retry_after, response);
136        }
137        response
138            .content_type(ContentType::plaintext())
139            .body(format!("Too many requests, retry in {retry_after}s"))
140    }
141}
142
143/// Returns the retry delay in whole seconds for a governor rejection.
144///
145/// Sub-second waits round up to one second so clients never see a zero delay
146/// that invites an immediate retry.
147#[must_use]
148pub fn retry_after_seconds(not_until: &NotUntil<QuantaInstant>) -> u64 {
149    not_until
150        .wait_time_from(DefaultClock::default().now())
151        .as_secs()
152        .max(1)
153}
154
155/// Builds an Actix governor config using the trusted-proxy-aware IP extractor.
156///
157/// The inputs are non-zero to match the application config model and avoid
158/// runtime builder failures.
159///
160/// # Panics
161///
162/// Panics if `actix-governor` rejects a quota whose period and burst size are both non-zero.
163#[expect(
164    clippy::expect_used,
165    reason = "non-zero quota fields make actix-governor finish() infallible"
166)]
167pub fn build_ip_governor_config(
168    seconds_per_request: NonZeroU64,
169    burst_size: NonZeroU32,
170    trusted_proxies: &[String],
171) -> GovernorConfig<TrustedProxyIpKeyExtractor, NoOpMiddleware> {
172    GovernorConfigBuilder::default()
173        .key_extractor(TrustedProxyIpKeyExtractor::new(trusted_proxies))
174        .seconds_per_request(seconds_per_request.get())
175        .burst_size(burst_size.get())
176        .finish()
177        .expect("non-zero rate limit tier should always build")
178}
179
180/// Builds an Actix governor config with a product-provided rejection response.
181///
182/// # Panics
183///
184/// Panics if `actix-governor` rejects a quota whose period and burst size are both non-zero.
185#[expect(
186    clippy::expect_used,
187    reason = "non-zero quota fields make actix-governor finish() infallible"
188)]
189pub fn build_ip_governor_config_with_rejection_response<F>(
190    seconds_per_request: NonZeroU64,
191    burst_size: NonZeroU32,
192    trusted_proxies: &[String],
193    rejection_response: F,
194) -> GovernorConfig<TrustedProxyIpKeyExtractor, NoOpMiddleware>
195where
196    F: Fn(u64, HttpResponseBuilder) -> HttpResponse + Send + Sync + 'static,
197{
198    GovernorConfigBuilder::default()
199        .key_extractor(
200            TrustedProxyIpKeyExtractor::new(trusted_proxies)
201                .with_rejection_response(rejection_response),
202        )
203        .seconds_per_request(seconds_per_request.get())
204        .burst_size(burst_size.get())
205        .finish()
206        .expect("non-zero rate limit tier should always build")
207}
208
209/// A keyed string rate limiter with product-neutral key normalization.
210///
211/// The limiter trims surrounding whitespace and lowercases keys before checking
212/// the quota. This suits usernames, email addresses, provider IDs, and similar
213/// business-unique identifiers where accidental case differences should not
214/// bypass a rate limit.
215#[derive(Clone)]
216pub struct NormalizedStringRateLimiter {
217    enabled: bool,
218    limiter: Arc<StringKeyedLimiter>,
219}
220
221impl NormalizedStringRateLimiter {
222    /// Builds a limiter from a non-zero quota and enabled flag.
223    #[must_use]
224    pub fn new(enabled: bool, seconds_per_request: NonZeroU64, burst_size: NonZeroU32) -> Self {
225        Self {
226            enabled,
227            limiter: Arc::new(build_string_keyed_limiter(seconds_per_request, burst_size)),
228        }
229    }
230
231    /// Checks a raw key after trimming whitespace and lowercasing it.
232    #[must_use]
233    pub fn check(&self, raw_key: &str) -> Option<RateLimitRejection> {
234        if !self.enabled {
235            return None;
236        }
237
238        let key = raw_key.trim().to_ascii_lowercase();
239        self.limiter
240            .check_key(&key)
241            .err()
242            .map(|not_until| RateLimitRejection::from_not_until(&not_until))
243    }
244}
245
246/// Product-neutral rate-limit rejection metadata.
247#[derive(Debug, Clone, Copy, PartialEq, Eq)]
248pub struct RateLimitRejection {
249    retry_after_seconds: u64,
250}
251
252impl RateLimitRejection {
253    fn from_not_until(not_until: &NotUntil<QuantaInstant>) -> Self {
254        Self {
255            retry_after_seconds: retry_after_seconds(not_until),
256        }
257    }
258
259    /// Returns how many seconds clients should wait before retrying.
260    #[must_use]
261    pub const fn retry_after_seconds(self) -> u64 {
262        self.retry_after_seconds
263    }
264}
265
266#[expect(
267    clippy::expect_used,
268    reason = "NonZeroU64 seconds_per_request creates a non-zero duration"
269)]
270fn build_string_keyed_limiter(
271    seconds_per_request: NonZeroU64,
272    burst_size: NonZeroU32,
273) -> StringKeyedLimiter {
274    let quota = Quota::with_period(Duration::from_secs(seconds_per_request.get()))
275        .expect("non-zero rate limit tier should always build")
276        .allow_burst(burst_size);
277    RateLimiter::keyed(quota)
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{
283        NormalizedStringRateLimiter, TrustedProxyIpKeyExtractor,
284        build_ip_governor_config_with_rejection_response, retry_after_seconds,
285    };
286    use actix_governor::{Governor, KeyExtractor};
287    use actix_web::{App, HttpResponse, http::StatusCode, test as actix_test, web};
288    use std::net::IpAddr;
289    use std::num::{NonZeroU32, NonZeroU64};
290
291    #[test]
292    fn trusted_proxy_extractor_accepts_cidr_and_single_ip() {
293        let extractor =
294            TrustedProxyIpKeyExtractor::new(&["10.0.0.0/8".to_string(), "192.168.1.1".to_string()]);
295
296        assert!(extractor.is_trusted("10.0.0.5".parse().unwrap()));
297        assert!(extractor.is_trusted("192.168.1.1".parse().unwrap()));
298        assert!(!extractor.is_trusted("203.0.113.1".parse().unwrap()));
299    }
300
301    #[actix_web::test]
302    async fn trusted_proxy_extractor_uses_leftmost_forwarded_ip_only_from_trusted_peer() {
303        let extractor = TrustedProxyIpKeyExtractor::new(&["10.0.0.0/8".to_string()]);
304        let req = actix_test::TestRequest::default()
305            .peer_addr("10.0.0.5:12345".parse().unwrap())
306            .insert_header(("X-Forwarded-For", "203.0.113.10, 198.51.100.2"))
307            .to_srv_request();
308
309        assert_eq!(
310            extractor.extract(&req).unwrap(),
311            "203.0.113.10".parse::<IpAddr>().unwrap()
312        );
313
314        let untrusted = actix_test::TestRequest::default()
315            .peer_addr("198.51.100.2:12345".parse().unwrap())
316            .insert_header(("X-Forwarded-For", "203.0.113.10"))
317            .to_srv_request();
318        assert_eq!(
319            extractor.extract(&untrusted).unwrap(),
320            "198.51.100.2".parse::<IpAddr>().unwrap()
321        );
322    }
323
324    #[actix_web::test]
325    async fn trusted_proxy_extractor_falls_back_for_invalid_forwarded_ip_or_missing_peer() {
326        let extractor = TrustedProxyIpKeyExtractor::new(&["10.0.0.0/8".to_string()]);
327        let invalid_forwarded = actix_test::TestRequest::default()
328            .peer_addr("10.0.0.5:12345".parse().unwrap())
329            .insert_header(("X-Forwarded-For", "not-an-ip"))
330            .to_srv_request();
331
332        assert_eq!(
333            extractor.extract(&invalid_forwarded).unwrap(),
334            "10.0.0.5".parse::<IpAddr>().unwrap()
335        );
336
337        let missing_peer = actix_test::TestRequest::default().to_srv_request();
338        assert_eq!(
339            extractor.extract(&missing_peer).unwrap(),
340            "127.0.0.1".parse::<IpAddr>().unwrap()
341        );
342    }
343
344    #[actix_web::test]
345    async fn custom_rejection_response_preserves_product_envelope() {
346        let config = build_ip_governor_config_with_rejection_response(
347            NonZeroU64::new(60).unwrap(),
348            NonZeroU32::new(1).unwrap(),
349            &[],
350            |retry_after, mut response| {
351                response
352                    .insert_header(("Retry-After", retry_after.to_string()))
353                    .json(serde_json::json!({
354                        "code": "rate_limited",
355                        "retry_after": retry_after,
356                    }))
357            },
358        );
359        let app = actix_test::init_service(
360            App::new()
361                .wrap(Governor::new(&config))
362                .route("/", web::get().to(HttpResponse::Ok)),
363        )
364        .await;
365
366        let first = actix_test::TestRequest::get()
367            .uri("/")
368            .peer_addr("127.0.0.1:12345".parse().unwrap())
369            .to_request();
370        assert_eq!(
371            actix_test::call_service(&app, first).await.status(),
372            StatusCode::OK
373        );
374
375        let second = actix_test::TestRequest::get()
376            .uri("/")
377            .peer_addr("127.0.0.1:12345".parse().unwrap())
378            .to_request();
379        let response = actix_test::call_service(&app, second).await;
380        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
381        assert!(response.headers().contains_key("Retry-After"));
382        let body: serde_json::Value = actix_test::read_body_json(response).await;
383        assert_eq!(body["code"], "rate_limited");
384        assert!(body["retry_after"].as_u64().is_some_and(|value| value > 0));
385    }
386
387    #[test]
388    fn retry_after_seconds_rounds_sub_second_waits_up_to_one() {
389        let quota = governor::Quota::with_period(std::time::Duration::from_secs(1))
390            .unwrap()
391            .allow_burst(NonZeroU32::new(1).unwrap());
392        let limiter = governor::RateLimiter::keyed(quota);
393
394        assert!(limiter.check_key(&"key").is_ok());
395        let not_until = limiter
396            .check_key(&"key")
397            .expect_err("second immediate check should be rate limited");
398
399        // The remaining wait is strictly below one second (some nanoseconds have
400        // elapsed since the first check), so truncating whole seconds would
401        // report 0 and tell the client to retry immediately.
402        assert_eq!(retry_after_seconds(&not_until), 1);
403    }
404
405    #[test]
406    fn normalized_string_limiter_can_be_disabled() {
407        let limiter = NormalizedStringRateLimiter::new(
408            false,
409            NonZeroU64::new(60).unwrap(),
410            NonZeroU32::new(1).unwrap(),
411        );
412
413        assert!(limiter.check("admin@example.com").is_none());
414        assert!(limiter.check("admin@example.com").is_none());
415    }
416
417    #[test]
418    fn normalized_string_limiter_trims_and_lowercases_keys() {
419        let limiter = NormalizedStringRateLimiter::new(
420            true,
421            NonZeroU64::new(60).unwrap(),
422            NonZeroU32::new(1).unwrap(),
423        );
424
425        assert!(limiter.check("Admin@Example.com").is_none());
426        assert!(limiter.check("other@example.com").is_none());
427        assert!(limiter.check(" admin@example.com ").is_some());
428    }
429}