Skip to main content

cratefield_core/
rate_limit.rs

1//! Rate-limit key helpers (architecture section 11, issues #10/#11/#13):
2//! keys are `ip:<ip>` and `email:<normalized>`, shared across modules so
3//! one venture has one budget per subject. Both modules call these on
4//! every public route, including confirm and status.
5
6use 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/// The client IP for rate limiting, from the headers.
16///
17/// On Workers (`cf-connecting-ip`) the edge sets the address and
18/// `x-forwarded-for` is client-forgeable, so it is ignored on wasm. A
19/// self-hosted runtime sits behind its own proxy, so native builds honor
20/// `x-forwarded-for`'s first hop when the Cloudflare header is absent.
21#[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/// The rate-limit keys for one request: always `ip:<ip>` (or `ip:unknown`
43/// when no address is visible), plus `email:<normalized>` when an address
44/// is known. The limiter is consulted per key, in order.
45#[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/// What a limiter transport error means for the request (issue #133).
55/// Every call site picks one deliberately; it used to be an implicit
56/// "allow" buried in each module's copy of the limiter loop.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum RateLimitFailure {
59    /// Allow the request with a warning: availability first. Sound only
60    /// where durable backstops sit behind the limiter — a captcha gate
61    /// and a DB-enforced [`SendCooldown`](crate::cooldown::SendCooldown),
62    /// as on the waitlist and email-signup joins.
63    FailOpen,
64    /// Deny the request. Correct when the limiter is the only thing
65    /// between the route and an enumeration or brute-force budget.
66    FailClosed,
67}
68
69/// Outcome of consulting the limiter for one request.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum RateLimit {
72    Allowed,
73    Denied { retry_after: Option<Duration> },
74}
75
76/// Shared limiter loop: every key must pass, and a transport error is
77/// resolved by `on_failure` at the call site rather than per-module
78/// convention. No limiter configured (`None`) allows the request:
79/// composition chose to run without one.
80pub 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}