aster_forge_actix_middleware/
rate_limit.rs1use 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
34type 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 #[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 #[must_use]
83 pub fn from_trusted(trusted: Vec<IpNet>) -> Self {
84 Self {
85 trusted,
86 rejection_response: None,
87 }
88 }
89
90 #[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 #[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 #[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#[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#[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#[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#[derive(Clone)]
216pub struct NormalizedStringRateLimiter {
217 enabled: bool,
218 limiter: Arc<StringKeyedLimiter>,
219}
220
221impl NormalizedStringRateLimiter {
222 #[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 #[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(¬_until))
243 }
244}
245
246#[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 #[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 assert_eq!(retry_after_seconds(¬_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}