cratefield_core/
rate_limit.rs1use axum::http::HeaderMap;
7#[cfg(not(target_arch = "wasm32"))]
8use axum::http::HeaderName;
9
10use crate::email;
11use crate::ports::{Decision, RateLimiter};
12use std::sync::Arc;
13use std::time::Duration;
14
15#[must_use]
22pub fn client_ip(headers: &HeaderMap) -> Option<String> {
23 if let Some(ip) = headers
24 .get("cf-connecting-ip")
25 .and_then(|v| v.to_str().ok())
26 {
27 return Some(ip.trim().to_owned());
28 }
29 #[cfg(not(target_arch = "wasm32"))]
30 if let Some(first) = headers
31 .get(HeaderName::from_static("x-forwarded-for"))
32 .and_then(|v| v.to_str().ok())
33 .and_then(|list| list.split(',').next())
34 .map(str::trim)
35 .filter(|first| !first.is_empty())
36 {
37 return Some(first.to_owned());
38 }
39 None
40}
41
42#[must_use]
46pub fn rate_limit_keys(remote_ip: Option<&str>, email: Option<&str>) -> Vec<String> {
47 let mut keys = vec![format!("ip:{}", remote_ip.unwrap_or("unknown"))];
48 if let Some(email) = email {
49 keys.push(format!("email:{}", email::normalize(email)));
50 }
51 keys
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum RateLimitFailure {
59 FailOpen,
64 FailClosed,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum RateLimit {
72 Allowed,
73 Denied { retry_after: Option<Duration> },
74}
75
76pub async fn check_rate_limit(
81 limiter: Option<&Arc<dyn RateLimiter>>,
82 keys: &[String],
83 on_failure: RateLimitFailure,
84) -> RateLimit {
85 let Some(limiter) = limiter else {
86 return RateLimit::Allowed;
87 };
88 for key in keys {
89 match limiter.limit(key).await {
90 Ok(Decision { ok: true, .. }) => {}
91 Ok(Decision {
92 ok: false,
93 retry_after,
94 }) => {
95 return RateLimit::Denied { retry_after };
96 }
97 Err(err) => {
98 if on_failure == RateLimitFailure::FailClosed {
99 tracing::warn!(error = %err, key = %key, "rate limiter unavailable; denying");
100 return RateLimit::Denied { retry_after: None };
101 }
102 tracing::warn!(error = %err, key = %key, "rate limiter unavailable; allowing");
103 }
104 }
105 }
106 RateLimit::Allowed
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use axum::http::header;
113
114 #[test]
115 fn keys_are_ip_then_normalized_email() {
116 let keys = rate_limit_keys(Some("203.0.113.7"), Some(" Nick@Example.COM "));
117 assert_eq!(keys, ["ip:203.0.113.7", "email:nick@example.com"]);
118 }
119
120 #[test]
121 fn missing_ip_falls_back_to_unknown() {
122 assert_eq!(rate_limit_keys(None, None), ["ip:unknown"]);
123 }
124
125 #[test]
126 fn prefers_cf_connecting_ip() {
127 let mut headers = HeaderMap::new();
128 headers.insert(
129 "cf-connecting-ip",
130 header::HeaderValue::from_static("198.51.100.9"),
131 );
132 headers.insert(
133 header::HeaderName::from_static("x-forwarded-for"),
134 header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
135 );
136 assert_eq!(client_ip(&headers).as_deref(), Some("198.51.100.9"));
137 }
138
139 #[cfg(not(target_arch = "wasm32"))]
140 #[test]
141 fn native_honors_forwarded_for_first_hop() {
142 let mut headers = HeaderMap::new();
143 headers.insert(
144 header::HeaderName::from_static("x-forwarded-for"),
145 header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
146 );
147 assert_eq!(client_ip(&headers).as_deref(), Some("1.2.3.4"));
148 }
149
150 struct StubLimiter {
151 ok: bool,
152 transport_error: bool,
153 }
154
155 #[async_trait::async_trait]
156 impl RateLimiter for StubLimiter {
157 async fn limit(&self, _key: &str) -> Result<Decision, crate::ports::RateLimitError> {
158 if self.transport_error {
159 return Err(crate::ports::RateLimitError::Transport("down".to_owned()));
160 }
161 Ok(Decision {
162 ok: self.ok,
163 retry_after: (!self.ok).then(|| Duration::from_secs(7)),
164 })
165 }
166 }
167
168 fn limiter(ok: bool, transport_error: bool) -> Arc<dyn RateLimiter> {
169 Arc::new(StubLimiter {
170 ok,
171 transport_error,
172 })
173 }
174
175 fn ip_key() -> Vec<String> {
176 vec!["ip:203.0.113.7".to_owned()]
177 }
178
179 #[pollster::test]
180 async fn all_keys_passing_allows() {
181 let keys = vec!["ip:203.0.113.7".to_owned(), "email:a@b.co".to_owned()];
182 let result = check_rate_limit(
183 Some(&limiter(true, false)),
184 &keys,
185 RateLimitFailure::FailClosed,
186 )
187 .await;
188 assert!(matches!(result, RateLimit::Allowed), "{result:?}");
189 }
190
191 #[pollster::test]
192 async fn a_denied_key_carries_its_retry_after() {
193 let result = check_rate_limit(
194 Some(&limiter(false, false)),
195 &ip_key(),
196 RateLimitFailure::FailClosed,
197 )
198 .await;
199 assert!(
200 matches!(&result, RateLimit::Denied { retry_after } if *retry_after == Some(Duration::from_secs(7))),
201 "{result:?}"
202 );
203 }
204
205 #[pollster::test]
206 async fn transport_errors_resolve_at_the_call_site() {
207 let open = check_rate_limit(
208 Some(&limiter(true, true)),
209 &ip_key(),
210 RateLimitFailure::FailOpen,
211 )
212 .await;
213 assert!(matches!(open, RateLimit::Allowed), "{open:?}");
214 let closed = check_rate_limit(
215 Some(&limiter(true, true)),
216 &ip_key(),
217 RateLimitFailure::FailClosed,
218 )
219 .await;
220 assert!(
221 matches!(&closed, RateLimit::Denied { retry_after: None }),
222 "{closed:?}"
223 );
224 }
225
226 #[pollster::test]
227 async fn no_limiter_is_a_composition_choice_not_a_failure() {
228 let result = check_rate_limit(None, &ip_key(), RateLimitFailure::FailClosed).await;
229 assert!(matches!(result, RateLimit::Allowed), "{result:?}");
230 }
231}