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 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 pub fn from_trusted(trusted: Vec<IpNet>) -> Self {
82 Self {
83 trusted,
84 rejection_response: None,
85 }
86 }
87
88 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 pub fn is_trusted(&self, ip: IpAddr) -> bool {
103 aster_forge_utils::net::is_trusted_proxy(ip, &self.trusted)
104 }
105
106 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
139pub 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#[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#[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#[derive(Clone)]
203pub struct NormalizedStringRateLimiter {
204 enabled: bool,
205 limiter: Arc<StringKeyedLimiter>,
206}
207
208impl NormalizedStringRateLimiter {
209 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 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#[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(¬_until),
241 }
242 }
243
244 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 assert_eq!(retry_after_seconds(¬_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}