Skip to main content

ironflow_api/rate_limit/
account.rs

1//! Per-account rate limiting for the credential routes.
2//!
3//! The IP bucket alone lets an attacker aim at one account from many
4//! addresses. The account bucket counts every attempt on an email, whatever
5//! address it comes from.
6
7use serde::Deserialize;
8use serde_json::from_slice;
9
10use super::RateLimitKey;
11
12/// Largest body read to find the targeted account. Credential bodies are a
13/// few hundred bytes.
14pub(super) const ACCOUNT_BODY_LIMIT: usize = 64 * 1024;
15
16/// Longest email kept as an account key (RFC 5321 path limit). A longer one
17/// matches no account, so it is not worth a counter.
18const MAX_EMAIL_LEN: usize = 320;
19
20/// Whether the limiter also counts requests against the account they target.
21///
22/// # Examples
23///
24/// ```
25/// use ironflow_api::rate_limit::AccountLimit;
26///
27/// assert_eq!(AccountLimit::default(), AccountLimit::Off);
28/// ```
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30pub enum AccountLimit {
31    /// Only the caller (API key, user or IP) is counted.
32    #[default]
33    Off,
34    /// A JSON body with an `email` field is also counted against that email,
35    /// trimmed and lowercased. Meant for the credential routes.
36    ByEmail,
37}
38
39#[derive(Deserialize)]
40struct TargetAccount {
41    email: String,
42}
43
44/// The account bucket a credential body targets.
45///
46/// `None` for a body that is not JSON or has no usable `email`: the handler
47/// rejects it on its own, and the IP bucket has already counted it.
48pub(super) fn account_key(body: &[u8]) -> Option<RateLimitKey> {
49    let TargetAccount { email } = from_slice(body).ok()?;
50    let email = email.trim().to_lowercase();
51    let usable = !email.is_empty() && email.len() <= MAX_EMAIL_LEN;
52    usable.then_some(RateLimitKey::Account(email))
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn email_is_trimmed_and_lowercased() {
61        assert_eq!(
62            account_key(br#"{"email":"  Alice@IronFlow.dev ","password":"x"}"#),
63            Some(RateLimitKey::Account("alice@ironflow.dev".to_string()))
64        );
65    }
66
67    #[test]
68    fn body_without_email_has_no_account() {
69        assert_eq!(account_key(br#"{"password":"x"}"#), None);
70        assert_eq!(account_key(br#"{"email":"   "}"#), None);
71        assert_eq!(account_key(br#"{"email":42}"#), None);
72    }
73
74    #[test]
75    fn non_json_body_has_no_account() {
76        assert_eq!(account_key(b"email=alice@ironflow.dev"), None);
77        assert_eq!(account_key(b""), None);
78    }
79
80    #[test]
81    fn oversized_email_has_no_account() {
82        let long = format!(r#"{{"email":"{}@x.dev"}}"#, "a".repeat(MAX_EMAIL_LEN));
83        assert_eq!(account_key(long.as_bytes()), None);
84    }
85
86    #[test]
87    fn unicode_email_is_lowercased() {
88        assert_eq!(
89            account_key(r#"{"email":"ÉLODIE@x.dev"}"#.as_bytes()),
90            Some(RateLimitKey::Account("élodie@x.dev".to_string()))
91        );
92    }
93}