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;
13
14/// The client IP for rate limiting, from the headers.
15///
16/// On Workers (`cf-connecting-ip`) the edge sets the address and
17/// `x-forwarded-for` is client-forgeable, so it is ignored on wasm. A
18/// self-hosted runtime sits behind its own proxy, so native builds honor
19/// `x-forwarded-for`'s first hop when the Cloudflare header is absent.
20#[must_use]
21pub fn client_ip(headers: &HeaderMap) -> Option<String> {
22    if let Some(ip) = headers
23        .get("cf-connecting-ip")
24        .and_then(|v| v.to_str().ok())
25    {
26        return Some(ip.trim().to_owned());
27    }
28    #[cfg(not(target_arch = "wasm32"))]
29    if let Some(first) = headers
30        .get(HeaderName::from_static("x-forwarded-for"))
31        .and_then(|v| v.to_str().ok())
32        .and_then(|list| list.split(',').next())
33        .map(str::trim)
34        .filter(|first| !first.is_empty())
35    {
36        return Some(first.to_owned());
37    }
38    None
39}
40
41/// The rate-limit keys for one request: always `ip:<ip>` (or `ip:unknown`
42/// when no address is visible), plus `email:<normalized>` when an address
43/// is known. The limiter is consulted per key, in order.
44#[must_use]
45pub fn rate_limit_keys(remote_ip: Option<&str>, email: Option<&str>) -> Vec<String> {
46    let mut keys = vec![format!("ip:{}", remote_ip.unwrap_or("unknown"))];
47    if let Some(email) = email {
48        keys.push(format!("email:{}", email::normalize(email)));
49    }
50    keys
51}
52
53/// What a limiter transport error means for the request (issue #133).
54/// Every call site picks one deliberately; it used to be an implicit
55/// "allow" buried in each module's copy of the limiter loop.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum RateLimitFailure {
58    /// Allow the request with a warning: availability first. Sound only
59    /// where durable backstops sit behind the limiter — a captcha gate
60    /// and a DB-enforced [`SendCooldown`](crate::cooldown::SendCooldown),
61    /// as on the waitlist and email-signup joins.
62    FailOpen,
63    /// Deny the request. Correct when the limiter is the only thing
64    /// between the route and an enumeration or brute-force budget.
65    FailClosed,
66}
67
68/// Outcome of consulting the limiter for one request.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum RateLimit {
71    Allowed,
72    /// The blocking [`Decision`] rides along, so the caller can answer
73    /// with its quota (`RateLimit-*` headers, issue #538) and its
74    /// `retry_after` without a second look at the limiter.
75    Denied {
76        decision: Decision,
77    },
78}
79
80/// Shared limiter loop: every key must pass, and a transport error is
81/// resolved by `on_failure` at the call site rather than per-module
82/// convention. No limiter configured (`None`) allows the request:
83/// composition chose to run without one.
84pub async fn check_rate_limit(
85    limiter: Option<&Arc<dyn RateLimiter>>,
86    keys: &[String],
87    on_failure: RateLimitFailure,
88) -> RateLimit {
89    let Some(limiter) = limiter else {
90        return RateLimit::Allowed;
91    };
92    for key in keys {
93        match limiter.limit(key).await {
94            Ok(decision) if decision.ok => {}
95            Ok(decision) => {
96                return RateLimit::Denied { decision };
97            }
98            Err(err) => {
99                // Which way the failure resolved is the composition's
100                // security posture, so it rides the forwarder too (issue
101                // #441): on wasm the tracing event goes nowhere, and a
102                // silently flipped fail-open is not a posture but a hole.
103                // The key rides the structured event only behind the
104                // field-name redaction (`key` is a secret field, issue
105                // #135), so the forwarded line redacts it the same way:
106                // an address or an IP the event never showed must not
107                // reach Workers Logs because the format! did.
108                let redacted_key = crate::logging::redacted_value("key", key);
109                if on_failure == RateLimitFailure::FailClosed {
110                    let detail =
111                        format!("rate limiter unavailable; denying: {err} (key: {redacted_key})");
112                    tracing::warn!(error = %err, key = %key, "rate limiter unavailable; denying");
113                    crate::logging::forward_control_event(
114                        crate::logging::ControlLevel::Warn,
115                        &detail,
116                    );
117                    return RateLimit::Denied {
118                        decision: Decision {
119                            ok: false,
120                            retry_after: None,
121                            quota: None,
122                        },
123                    };
124                }
125                let detail =
126                    format!("rate limiter unavailable; allowing: {err} (key: {redacted_key})");
127                tracing::warn!(error = %err, key = %key, "rate limiter unavailable; allowing");
128                crate::logging::forward_control_event(crate::logging::ControlLevel::Warn, &detail);
129            }
130        }
131    }
132    RateLimit::Allowed
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use axum::http::header;
139    use std::time::Duration;
140
141    #[test]
142    fn keys_are_ip_then_normalized_email() {
143        let keys = rate_limit_keys(Some("203.0.113.7"), Some(" Nick@Example.COM "));
144        assert_eq!(keys, ["ip:203.0.113.7", "email:nick@example.com"]);
145    }
146
147    #[test]
148    fn missing_ip_falls_back_to_unknown() {
149        assert_eq!(rate_limit_keys(None, None), ["ip:unknown"]);
150    }
151
152    #[test]
153    fn prefers_cf_connecting_ip() {
154        let mut headers = HeaderMap::new();
155        headers.insert(
156            "cf-connecting-ip",
157            header::HeaderValue::from_static("198.51.100.9"),
158        );
159        headers.insert(
160            header::HeaderName::from_static("x-forwarded-for"),
161            header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
162        );
163        assert_eq!(client_ip(&headers).as_deref(), Some("198.51.100.9"));
164    }
165
166    #[cfg(not(target_arch = "wasm32"))]
167    #[test]
168    fn native_honors_forwarded_for_first_hop() {
169        let mut headers = HeaderMap::new();
170        headers.insert(
171            header::HeaderName::from_static("x-forwarded-for"),
172            header::HeaderValue::from_static("1.2.3.4, 5.6.7.8"),
173        );
174        assert_eq!(client_ip(&headers).as_deref(), Some("1.2.3.4"));
175    }
176
177    struct StubLimiter {
178        ok: bool,
179        transport_error: bool,
180        quota: bool,
181    }
182
183    #[async_trait::async_trait]
184    impl RateLimiter for StubLimiter {
185        async fn limit(&self, _key: &str) -> Result<Decision, crate::ports::RateLimitError> {
186            if self.transport_error {
187                return Err(crate::ports::RateLimitError::Transport("down".to_owned()));
188            }
189            Ok(Decision {
190                ok: self.ok,
191                retry_after: (!self.ok).then(|| Duration::from_secs(7)),
192                quota: self.quota.then(|| crate::ports::Quota {
193                    limit: 30,
194                    remaining: 0,
195                    reset: Duration::from_secs(9),
196                }),
197            })
198        }
199    }
200
201    fn limiter(ok: bool, transport_error: bool) -> Arc<dyn RateLimiter> {
202        Arc::new(StubLimiter {
203            ok,
204            transport_error,
205            quota: false,
206        })
207    }
208
209    fn quota_limiter() -> Arc<dyn RateLimiter> {
210        Arc::new(StubLimiter {
211            ok: false,
212            transport_error: false,
213            quota: true,
214        })
215    }
216
217    fn ip_key() -> Vec<String> {
218        vec!["ip:203.0.113.7".to_owned()]
219    }
220
221    #[pollster::test]
222    async fn all_keys_passing_allows() {
223        let keys = vec!["ip:203.0.113.7".to_owned(), "email:a@b.co".to_owned()];
224        let result = check_rate_limit(
225            Some(&limiter(true, false)),
226            &keys,
227            RateLimitFailure::FailClosed,
228        )
229        .await;
230        assert!(matches!(result, RateLimit::Allowed), "{result:?}");
231    }
232
233    #[pollster::test]
234    async fn a_denied_key_carries_its_retry_after() {
235        let result = check_rate_limit(
236            Some(&limiter(false, false)),
237            &ip_key(),
238            RateLimitFailure::FailClosed,
239        )
240        .await;
241        assert!(
242            matches!(&result, RateLimit::Denied { decision }
243                if decision.retry_after == Some(Duration::from_secs(7)) && decision.quota.is_none()),
244            "{result:?}"
245        );
246    }
247
248    #[pollster::test]
249    async fn a_denied_key_carries_its_quota_whole() {
250        let result = check_rate_limit(
251            Some(&quota_limiter()),
252            &ip_key(),
253            RateLimitFailure::FailClosed,
254        )
255        .await;
256        assert!(
257            matches!(&result, RateLimit::Denied { decision }
258                if decision.quota.as_ref().is_some_and(|quota| quota.limit == 30
259                    && quota.reset == Duration::from_secs(9))),
260            "{result:?}"
261        );
262    }
263
264    #[pollster::test]
265    async fn transport_errors_resolve_at_the_call_site() {
266        let open = check_rate_limit(
267            Some(&limiter(true, true)),
268            &ip_key(),
269            RateLimitFailure::FailOpen,
270        )
271        .await;
272        assert!(matches!(open, RateLimit::Allowed), "{open:?}");
273        let closed = check_rate_limit(
274            Some(&limiter(true, true)),
275            &ip_key(),
276            RateLimitFailure::FailClosed,
277        )
278        .await;
279        assert!(
280            matches!(&closed, RateLimit::Denied { decision }
281                if decision.retry_after.is_none() && decision.quota.is_none()),
282            "{closed:?}"
283        );
284    }
285
286    #[pollster::test]
287    async fn no_limiter_is_a_composition_choice_not_a_failure() {
288        let result = check_rate_limit(None, &ip_key(), RateLimitFailure::FailClosed).await;
289        assert!(matches!(result, RateLimit::Allowed), "{result:?}");
290    }
291}