Skip to main content

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