feat(magic-links): add rate limiting + archirecture documentation

This commit is contained in:
Edouard Vanbelle
2026-06-02 14:23:31 +02:00
parent 64d081ad0b
commit 21c06da700
11 changed files with 530 additions and 17 deletions
+39 -6
View File
@@ -714,6 +714,21 @@ pub struct MagicLinkConfig {
/// `partner.com` does NOT match `eng.partner.com`. List every
/// subdomain explicitly.
pub allowed_email_domains: Vec<String>,
/// Per-sharer ceiling on email-typed grant invitations from
/// `POST /api/grants`. Keyed on `caller_id`. Exceeding the ceiling
/// returns 429. Default: 50/hour.
pub invite_per_caller_per_hour: u32,
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`,
/// keyed on the normalised recipient address. Anti-bombing.
/// Exceeding the ceiling is silently absorbed (uniform 200) so
/// the response shape can't be used as an enumeration oracle.
/// Default: 5/hour.
pub send_per_email_per_hour: u32,
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`,
/// keyed on the trusted client IP. Bounds the cost of an attacker
/// spreading low per-email volume across many target addresses.
/// Default: 200/hour.
pub send_per_ip_per_hour: u32,
}
impl Default for MagicLinkConfig {
@@ -722,6 +737,9 @@ impl Default for MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: Vec::new(),
invite_per_caller_per_hour: 50,
send_per_email_per_hour: 5,
send_per_ip_per_hour: 200,
}
}
}
@@ -1350,6 +1368,24 @@ impl AppConfig {
.filter(|d| !d.is_empty())
.collect();
}
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR")
&& let Ok(n) = v.parse::<u32>()
&& n > 0
{
config.magic_link.invite_per_caller_per_hour = n;
}
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR")
&& let Ok(n) = v.parse::<u32>()
&& n > 0
{
config.magic_link.send_per_email_per_hour = n;
}
if let Ok(v) = env::var("OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR")
&& let Ok(n) = v.parse::<u32>()
&& n > 0
{
config.magic_link.send_per_ip_per_hour = n;
}
config
}
@@ -1410,9 +1446,8 @@ mod tests {
#[test]
fn allowlist_matches_case_insensitively() {
let cfg = MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: vec!["partner-a.com".to_string(), "partner-b.io".to_string()],
..MagicLinkConfig::default()
};
assert!(cfg.is_email_allowed("alice@partner-a.com"));
// Uppercase domain in the email — must still match.
@@ -1425,9 +1460,8 @@ mod tests {
#[test]
fn allowlist_does_not_match_subdomains_implicitly() {
let cfg = MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: vec!["partner.com".to_string()],
..MagicLinkConfig::default()
};
assert!(cfg.is_email_allowed("alice@partner.com"));
// Subdomain must be listed explicitly — exact match only.
@@ -1439,9 +1473,8 @@ mod tests {
#[test]
fn malformed_email_fails_closed() {
let cfg = MagicLinkConfig {
ttl_hours: 24,
allow_external_users: true,
allowed_email_domains: vec!["partner.com".to_string()],
..MagicLinkConfig::default()
};
// No `@` — rejected even though allowlist is set.
assert!(!cfg.is_email_allowed("not-an-email"));
+52
View File
@@ -932,6 +932,38 @@ impl AppServiceFactory {
user_profile_rate_limiter: Arc::new(
crate::interfaces::middleware::rate_limit::RateLimiter::new(60, 60, 50_000),
),
// PR 12 — per-sharer email-invite ceiling: caller_id-keyed.
// Defends against a compromised account spamming external
// invites (each invite mints a new external user + email).
// Limits come from MagicLinkConfig so tests / operators can
// tune them via OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR.
email_invite_rate_limiter: Arc::new(
crate::interfaces::middleware::rate_limit::RateLimiter::new(
self.config.magic_link.invite_per_caller_per_hour,
3_600,
50_000,
),
),
// PR 12 — per-target-email send ceiling on
// /api/auth/magic-link/send. Stops the endpoint from being
// an email-bombing primitive against a known address.
magic_link_send_per_email_rate_limiter: Arc::new(
crate::interfaces::middleware::rate_limit::RateLimiter::new(
self.config.magic_link.send_per_email_per_hour,
3_600,
50_000,
),
),
// PR 12 — per-IP backstop on /api/auth/magic-link/send.
// Bounds the damage if an attacker spreads a low per-email
// rate across many target addresses.
magic_link_send_per_ip_rate_limiter: Arc::new(
crate::interfaces::middleware::rate_limit::RateLimiter::new(
self.config.magic_link.send_per_ip_per_hour,
3_600,
50_000,
),
),
};
let email_bundle = build_email_sender(&self.config.smtp);
app_state.email_sender = email_bundle.sender;
@@ -1316,6 +1348,26 @@ pub struct AppState {
/// authenticated caller covers any legitimate UI rendering while
/// throttling enumeration.
pub user_profile_rate_limiter: Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
/// Per-sharer ceiling on `POST /api/grants` invitations whose
/// subject is `{ type: "email" }`. 50 per hour keyed on
/// `caller_id`. Anonymous attackers can't reach this code path
/// (the route is auth-protected); this defends against a
/// compromised internal account or a malicious admin.
pub email_invite_rate_limiter: Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
/// Per-target-email ceiling on `POST /api/auth/magic-link/send`. 5
/// per hour keyed on the **normalised** target email. Exceeding
/// the cap is silently absorbed: the handler still returns the
/// uniform 200 anti-enumeration response, but no new mail is
/// dispatched. Authenticated callers bypass this limit.
pub magic_link_send_per_email_rate_limiter:
Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
/// Per-source-IP backstop on `POST /api/auth/magic-link/send`. 200
/// per hour keyed on the trusted client IP (respects
/// `OXICLOUD_TRUST_PROXY_CIDR`). Bounds the cost of a single
/// attacker spreading 5/hr requests over a wide email list.
/// Authenticated callers bypass this limit.
pub magic_link_send_per_ip_rate_limiter:
Arc<crate::interfaces::middleware::rate_limit::RateLimiter>,
}
// All AppState construction is done via struct literal in build_app_state().