Skip to main content

aster_forge_middleware/actix/
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;
17
18use actix_governor::{
19    GovernorConfig, GovernorConfigBuilder, KeyExtractor, SimpleKeyExtractionError,
20};
21use actix_web::dev::ServiceRequest;
22use actix_web::http::header::ContentType;
23use actix_web::{HttpResponse, HttpResponseBuilder};
24use governor::NotUntil;
25use governor::clock::QuantaInstant;
26use governor::middleware::NoOpMiddleware;
27use ipnet::IpNet;
28
29pub use crate::shared::rate_limit::{
30    NormalizedStringRateLimiter, RateLimitRejection, retry_after_seconds,
31};
32
33/// Trusted-proxy-aware IP key extractor for `actix-governor`.
34///
35/// The extractor uses the direct peer address by default. When the peer address
36/// matches one of the trusted proxy CIDR entries, the leftmost `X-Forwarded-For`
37/// address is used as the client key. Invalid or missing forwarded addresses
38/// fall back to the direct peer address.
39///
40/// Deployments without a peer address (e.g. Unix domain sockets) fall back to
41/// `127.0.0.1`, so every client shares one rate-limit bucket and a single
42/// burst rejects all of them. Products serving over UDS should disable IP
43/// rate limiting or use [`NormalizedStringRateLimiter`] business keys instead.
44type RejectionResponseFactory =
45    dyn Fn(u64, HttpResponseBuilder) -> HttpResponse + Send + Sync + 'static;
46
47#[derive(Clone)]
48pub struct TrustedProxyIpKeyExtractor {
49    trusted: Vec<IpNet>,
50    rejection_response: Option<Arc<RejectionResponseFactory>>,
51}
52
53impl fmt::Debug for TrustedProxyIpKeyExtractor {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter
56            .debug_struct("TrustedProxyIpKeyExtractor")
57            .field("trusted", &self.trusted)
58            .field(
59                "has_custom_rejection_response",
60                &self.rejection_response.is_some(),
61            )
62            .finish()
63    }
64}
65
66impl TrustedProxyIpKeyExtractor {
67    /// Builds an extractor from raw trusted proxy entries.
68    ///
69    /// Entries may be CIDR ranges or single IP addresses. Invalid entries are
70    /// skipped by `aster_forge_utils::net::parse_trusted_proxies` after logging
71    /// a warning.
72    #[must_use]
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    #[must_use]
82    pub fn from_trusted(trusted: Vec<IpNet>) -> Self {
83        Self {
84            trusted,
85            rejection_response: None,
86        }
87    }
88
89    /// Uses a product-provided response factory when the request exceeds its quota.
90    ///
91    /// The factory receives the retry delay in whole seconds and the response builder created by
92    /// `actix-governor`. Products can use this to preserve their response envelope and error code
93    /// without reimplementing trusted-proxy extraction.
94    #[must_use]
95    pub fn with_rejection_response<F>(mut self, factory: F) -> Self
96    where
97        F: Fn(u64, HttpResponseBuilder) -> HttpResponse + Send + Sync + 'static,
98    {
99        self.rejection_response = Some(Arc::new(factory));
100        self
101    }
102
103    /// Returns whether the provided IP is trusted as a proxy.
104    #[must_use]
105    pub fn is_trusted(&self, ip: IpAddr) -> bool {
106        aster_forge_utils::net::is_trusted_proxy(ip, &self.trusted)
107    }
108
109    /// Resolves the client IP for a request and direct peer IP.
110    #[must_use]
111    pub fn real_ip(&self, req: &ServiceRequest, peer: IpAddr) -> IpAddr {
112        crate::actix::client_ip::real_ip_from_trusted_headers(req.headers(), peer, &self.trusted)
113    }
114}
115
116impl KeyExtractor for TrustedProxyIpKeyExtractor {
117    type Key = IpAddr;
118    type KeyExtractionError = SimpleKeyExtractionError<&'static str>;
119
120    fn extract(&self, req: &ServiceRequest) -> Result<Self::Key, Self::KeyExtractionError> {
121        // A localhost fallback is a bucket key, never proof that a proxy sent these headers.
122        Ok(req
123            .peer_addr()
124            .map_or(IpAddr::V4(Ipv4Addr::LOCALHOST), |socket| {
125                self.real_ip(req, socket.ip())
126            }))
127    }
128
129    fn exceed_rate_limit_response(
130        &self,
131        negative: &NotUntil<QuantaInstant>,
132        mut response: HttpResponseBuilder,
133    ) -> HttpResponse {
134        let retry_after = retry_after_seconds(negative);
135        if let Some(factory) = &self.rejection_response {
136            return factory(retry_after, response);
137        }
138        response
139            .content_type(ContentType::plaintext())
140            .body(format!("Too many requests, retry in {retry_after}s"))
141    }
142}
143
144/// Builds an Actix governor config using the trusted-proxy-aware IP extractor.
145///
146/// The inputs are non-zero to match the application config model and avoid
147/// runtime builder failures.
148///
149/// # Panics
150///
151/// Panics if `actix-governor` rejects a quota whose period and burst size are both non-zero.
152#[expect(
153    clippy::expect_used,
154    reason = "non-zero quota fields make actix-governor finish() infallible"
155)]
156pub fn build_ip_governor_config(
157    seconds_per_request: NonZeroU64,
158    burst_size: NonZeroU32,
159    trusted_proxies: &[String],
160) -> GovernorConfig<TrustedProxyIpKeyExtractor, NoOpMiddleware> {
161    GovernorConfigBuilder::default()
162        .key_extractor(TrustedProxyIpKeyExtractor::new(trusted_proxies))
163        .seconds_per_request(seconds_per_request.get())
164        .burst_size(burst_size.get())
165        .finish()
166        .expect("non-zero rate limit tier should always build")
167}
168
169/// Builds an Actix governor config with a product-provided rejection response.
170///
171/// # Panics
172///
173/// Panics if `actix-governor` rejects a quota whose period and burst size are both non-zero.
174#[expect(
175    clippy::expect_used,
176    reason = "non-zero quota fields make actix-governor finish() infallible"
177)]
178pub fn build_ip_governor_config_with_rejection_response<F>(
179    seconds_per_request: NonZeroU64,
180    burst_size: NonZeroU32,
181    trusted_proxies: &[String],
182    rejection_response: F,
183) -> GovernorConfig<TrustedProxyIpKeyExtractor, NoOpMiddleware>
184where
185    F: Fn(u64, HttpResponseBuilder) -> HttpResponse + Send + Sync + 'static,
186{
187    GovernorConfigBuilder::default()
188        .key_extractor(
189            TrustedProxyIpKeyExtractor::new(trusted_proxies)
190                .with_rejection_response(rejection_response),
191        )
192        .seconds_per_request(seconds_per_request.get())
193        .burst_size(burst_size.get())
194        .finish()
195        .expect("non-zero rate limit tier should always build")
196}
197
198#[cfg(test)]
199mod tests {
200    use super::{TrustedProxyIpKeyExtractor, build_ip_governor_config_with_rejection_response};
201    use actix_governor::{Governor, KeyExtractor};
202    use actix_web::{App, HttpResponse, http::StatusCode, test as actix_test, web};
203    use std::net::IpAddr;
204    use std::num::{NonZeroU32, NonZeroU64};
205
206    #[test]
207    fn trusted_proxy_extractor_accepts_cidr_and_single_ip() {
208        let extractor =
209            TrustedProxyIpKeyExtractor::new(&["10.0.0.0/8".to_string(), "192.168.1.1".to_string()]);
210
211        assert!(extractor.is_trusted("10.0.0.5".parse().unwrap()));
212        assert!(extractor.is_trusted("192.168.1.1".parse().unwrap()));
213        assert!(!extractor.is_trusted("203.0.113.1".parse().unwrap()));
214    }
215
216    #[actix_web::test]
217    async fn trusted_proxy_extractor_uses_leftmost_forwarded_ip_only_from_trusted_peer() {
218        let extractor = TrustedProxyIpKeyExtractor::new(&["10.0.0.0/8".to_string()]);
219        let req = actix_test::TestRequest::default()
220            .peer_addr("10.0.0.5:12345".parse().unwrap())
221            .insert_header(("X-Forwarded-For", "203.0.113.10, 198.51.100.2"))
222            .to_srv_request();
223
224        assert_eq!(
225            extractor.extract(&req).unwrap(),
226            "203.0.113.10".parse::<IpAddr>().unwrap()
227        );
228
229        let untrusted = actix_test::TestRequest::default()
230            .peer_addr("198.51.100.2:12345".parse().unwrap())
231            .insert_header(("X-Forwarded-For", "203.0.113.10"))
232            .to_srv_request();
233        assert_eq!(
234            extractor.extract(&untrusted).unwrap(),
235            "198.51.100.2".parse::<IpAddr>().unwrap()
236        );
237    }
238
239    #[actix_web::test]
240    async fn trusted_proxy_extractor_falls_back_for_invalid_forwarded_ip_or_missing_peer() {
241        let extractor = TrustedProxyIpKeyExtractor::new(&["10.0.0.0/8".to_string()]);
242        let invalid_forwarded = actix_test::TestRequest::default()
243            .peer_addr("10.0.0.5:12345".parse().unwrap())
244            .insert_header(("X-Forwarded-For", "not-an-ip"))
245            .to_srv_request();
246
247        assert_eq!(
248            extractor.extract(&invalid_forwarded).unwrap(),
249            "10.0.0.5".parse::<IpAddr>().unwrap()
250        );
251
252        let missing_peer = actix_test::TestRequest::default().to_srv_request();
253        assert_eq!(
254            extractor.extract(&missing_peer).unwrap(),
255            "127.0.0.1".parse::<IpAddr>().unwrap()
256        );
257
258        let loopback_trusted = TrustedProxyIpKeyExtractor::new(&["127.0.0.1".into()]);
259        let missing_peer = actix_test::TestRequest::default()
260            .insert_header(("x-forwarded-for", "203.0.113.1"))
261            .to_srv_request();
262        assert_eq!(
263            loopback_trusted.extract(&missing_peer).unwrap(),
264            "127.0.0.1".parse::<IpAddr>().unwrap()
265        );
266    }
267
268    #[actix_web::test]
269    async fn custom_rejection_response_preserves_product_envelope() {
270        let config = build_ip_governor_config_with_rejection_response(
271            NonZeroU64::new(60).unwrap(),
272            NonZeroU32::new(1).unwrap(),
273            &[],
274            |retry_after, mut response| {
275                response
276                    .insert_header(("Retry-After", retry_after.to_string()))
277                    .json(serde_json::json!({
278                        "code": "rate_limited",
279                        "retry_after": retry_after,
280                    }))
281            },
282        );
283        let app = actix_test::init_service(
284            App::new()
285                .wrap(Governor::new(&config))
286                .route("/", web::get().to(HttpResponse::Ok)),
287        )
288        .await;
289
290        let first = actix_test::TestRequest::get()
291            .uri("/")
292            .peer_addr("127.0.0.1:12345".parse().unwrap())
293            .to_request();
294        assert_eq!(
295            actix_test::call_service(&app, first).await.status(),
296            StatusCode::OK
297        );
298
299        let second = actix_test::TestRequest::get()
300            .uri("/")
301            .peer_addr("127.0.0.1:12345".parse().unwrap())
302            .to_request();
303        let response = actix_test::call_service(&app, second).await;
304        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
305        assert!(response.headers().contains_key("Retry-After"));
306        let body: serde_json::Value = actix_test::read_body_json(response).await;
307        assert_eq!(body["code"], "rate_limited");
308        assert!(body["retry_after"].as_u64().is_some_and(|value| value > 0));
309    }
310}