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, per route: a sign-up flood does not exhaust
36    /// the sign-in bucket of the same email. Meant for the credential routes.
37    ByEmail,
38}
39
40#[derive(Deserialize)]
41struct TargetAccount {
42    email: String,
43}
44
45/// The account bucket a credential body targets on `route`.
46///
47/// `None` for a body that is not JSON or has no usable `email`: the handler
48/// rejects it on its own, and the IP bucket has already counted it.
49pub(super) fn account_key(route: &str, body: &[u8]) -> Option<RateLimitKey> {
50    let TargetAccount { email } = from_slice(body).ok()?;
51    let email = email.trim().to_lowercase();
52    let usable = !email.is_empty() && email.len() <= MAX_EMAIL_LEN;
53    usable.then(|| RateLimitKey::Account {
54        route: route.to_string(),
55        email,
56    })
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn sign_in(email: &str) -> Option<RateLimitKey> {
64        Some(RateLimitKey::Account {
65            route: "/sign-in".to_string(),
66            email: email.to_string(),
67        })
68    }
69
70    #[test]
71    fn email_is_trimmed_and_lowercased() {
72        assert_eq!(
73            account_key(
74                "/sign-in",
75                br#"{"email":"  Alice@IronFlow.dev ","password":"x"}"#
76            ),
77            sign_in("alice@ironflow.dev")
78        );
79    }
80
81    #[test]
82    fn body_without_email_has_no_account() {
83        assert_eq!(account_key("/sign-in", br#"{"password":"x"}"#), None);
84        assert_eq!(account_key("/sign-in", br#"{"email":"   "}"#), None);
85        assert_eq!(account_key("/sign-in", br#"{"email":42}"#), None);
86    }
87
88    #[test]
89    fn non_json_body_has_no_account() {
90        assert_eq!(account_key("/sign-in", b"email=alice@ironflow.dev"), None);
91        assert_eq!(account_key("/sign-in", b""), None);
92    }
93
94    #[test]
95    fn oversized_email_has_no_account() {
96        let long = format!(r#"{{"email":"{}@x.dev"}}"#, "a".repeat(MAX_EMAIL_LEN));
97        assert_eq!(account_key("/sign-in", long.as_bytes()), None);
98    }
99
100    #[test]
101    fn unicode_email_is_lowercased() {
102        assert_eq!(
103            account_key("/sign-in", r#"{"email":"ÉLODIE@x.dev"}"#.as_bytes()),
104            sign_in("élodie@x.dev")
105        );
106    }
107
108    #[test]
109    fn routes_get_distinct_account_keys() {
110        let body = br#"{"email":"alice@ironflow.dev"}"#;
111        let sign_up = account_key("/sign-up", body);
112        assert_eq!(account_key("/sign-in", body), sign_in("alice@ironflow.dev"));
113        assert_eq!(
114            sign_up,
115            Some(RateLimitKey::Account {
116                route: "/sign-up".to_string(),
117                email: "alice@ironflow.dev".to_string(),
118            })
119        );
120        assert_ne!(sign_up, account_key("/sign-in", body));
121    }
122}