aster_forge_middleware/actix/
rate_limit.rs1use 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
33type 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 #[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 #[must_use]
82 pub fn from_trusted(trusted: Vec<IpNet>) -> Self {
83 Self {
84 trusted,
85 rejection_response: None,
86 }
87 }
88
89 #[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 #[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 #[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 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#[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#[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}