feat(magic-links): add rate limiting + archirecture documentation
This commit is contained in:
+39
-6
@@ -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"));
|
||||
|
||||
@@ -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().
|
||||
|
||||
@@ -914,9 +914,17 @@ pub struct SendMagicLinkDto {
|
||||
/// the absence of the entire feature is visible from any other
|
||||
/// endpoint touching `/api/auth/magic-link/*`.
|
||||
///
|
||||
/// Per-target-email rate limit is scheduled for PR 12 — without it,
|
||||
/// the endpoint could be used as an email-bombing primitive against a
|
||||
/// known recipient address.
|
||||
/// PR 12 rate limits:
|
||||
/// - **Per-source-IP**, 200/hour — bounds the cost of one attacker
|
||||
/// spreading low per-email volumes over many target addresses.
|
||||
/// - **Per-target-email**, 5/hour, keyed on the normalised email —
|
||||
/// stops the endpoint from being an email-bombing primitive against
|
||||
/// a single known recipient.
|
||||
/// Both caps return the uniform 200 (never 429 to anonymous callers,
|
||||
/// otherwise the status itself becomes an enumeration oracle); the
|
||||
/// real reason is recorded in the audit channel.
|
||||
/// Authenticated callers (Authorization header or access cookie
|
||||
/// present) bypass both limits.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/auth/magic-link/send",
|
||||
@@ -929,7 +937,7 @@ pub struct SendMagicLinkDto {
|
||||
)]
|
||||
pub async fn send_magic_link(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(body): Json<SendMagicLinkDto>,
|
||||
req: axum::http::Request<axum::body::Body>,
|
||||
) -> Result<Response, AppError> {
|
||||
let Some(invite_svc) = state.magic_link_invite_service.as_ref() else {
|
||||
return Err(AppError::new(
|
||||
@@ -939,6 +947,91 @@ pub async fn send_magic_link(
|
||||
));
|
||||
};
|
||||
|
||||
// Authentication signal — presence (not validity) of Bearer header
|
||||
// OR access cookie. We deliberately don't decode the JWT here: a
|
||||
// stale-cookie holder gets a 401 from any other endpoint they
|
||||
// touch, and the worst-case bypass of these anti-flood caps is a
|
||||
// narrow window where an attacker keeps a single expired cookie
|
||||
// alive. False-negatives (a logged-in user being rate-limited
|
||||
// resending to themselves) are the real cost we're avoiding.
|
||||
let headers = req.headers().clone();
|
||||
let is_authenticated = headers.contains_key(axum::http::header::AUTHORIZATION)
|
||||
|| crate::interfaces::api::cookie_auth::extract_cookie_value(
|
||||
&headers,
|
||||
crate::interfaces::api::cookie_auth::ACCESS_COOKIE,
|
||||
)
|
||||
.is_some();
|
||||
|
||||
let client_ip = crate::interfaces::middleware::rate_limit::extract_client_ip(&req);
|
||||
|
||||
// Body parsing — manual because Request<Body> already consumed
|
||||
// any chance of a Json extractor. 4 KiB is generous for
|
||||
// `{ "email": "..." }`.
|
||||
let body_bytes = axum::body::to_bytes(req.into_body(), 4 * 1024)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Request body too large or unreadable",
|
||||
"InvalidInput",
|
||||
)
|
||||
})?;
|
||||
let body: SendMagicLinkDto = serde_json::from_slice(&body_bytes).map_err(|e| {
|
||||
AppError::new(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("Invalid JSON body: {e}"),
|
||||
"InvalidInput",
|
||||
)
|
||||
})?;
|
||||
|
||||
let uniform_ok = || {
|
||||
let payload = serde_json::json!({
|
||||
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||
});
|
||||
(StatusCode::OK, Json(payload)).into_response()
|
||||
};
|
||||
|
||||
if !is_authenticated {
|
||||
// Per-IP backstop fires first — covers the case where an
|
||||
// attacker iterates many distinct emails to spread the
|
||||
// per-email budget thin.
|
||||
if state
|
||||
.magic_link_send_per_ip_rate_limiter
|
||||
.check_and_increment(&client_ip)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_ip",
|
||||
ip = %client_ip,
|
||||
"Per-IP rate limit exceeded on /api/auth/magic-link/send"
|
||||
);
|
||||
return Ok(uniform_ok());
|
||||
}
|
||||
|
||||
// Per-target-email cap, keyed on the normalised form so
|
||||
// casing/IDN-host tricks don't multiply the budget. Malformed
|
||||
// addresses skip this check and fall through to the service,
|
||||
// which records its own audit entry under reason="malformed_email".
|
||||
if let Ok(normalised) =
|
||||
crate::domain::services::email_normalize::normalize_email(&body.email)
|
||||
&& state
|
||||
.magic_link_send_per_email_rate_limiter
|
||||
.check_and_increment(&normalised)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "auth.magic_link_send",
|
||||
reason = "rate_limited_email",
|
||||
ip = %client_ip,
|
||||
"Per-target-email rate limit exceeded on /api/auth/magic-link/send"
|
||||
);
|
||||
return Ok(uniform_ok());
|
||||
}
|
||||
}
|
||||
|
||||
// The service swallows every operational outcome and logs the truth
|
||||
// via the audit channel; we surface only an internal error (DB down,
|
||||
// etc.). Anti-enumeration means we always return the same body.
|
||||
@@ -947,8 +1040,5 @@ pub async fn send_magic_link(
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"message": "If an account exists for that email, a sign-in link will be sent.",
|
||||
});
|
||||
Ok((StatusCode::OK, Json(payload)).into_response())
|
||||
Ok(uniform_ok())
|
||||
}
|
||||
|
||||
@@ -116,6 +116,27 @@ pub async fn create_grant(
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
// PR 12 — per-sharer ceiling: 50 email-invitations / hour
|
||||
// per caller. Hitting the cap returns 429 because the
|
||||
// caller is authenticated and rate-limit visibility leaks
|
||||
// nothing they don't already know about their own
|
||||
// behaviour.
|
||||
if state
|
||||
.email_invite_rate_limiter
|
||||
.check_and_increment(&caller_id.to_string())
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "audit",
|
||||
event = "grants.email_invite",
|
||||
reason = "rate_limited",
|
||||
caller_id = %caller_id,
|
||||
"Per-sharer email-invite rate limit exceeded"
|
||||
);
|
||||
return crate::interfaces::middleware::rate_limit::too_many_requests(
|
||||
state.email_invite_rate_limiter.retry_after(),
|
||||
);
|
||||
}
|
||||
match invite_svc.resolve_or_create_recipient(&email).await {
|
||||
Ok(user) => (Subject::User(user.id()), Some(user)),
|
||||
Err(e) => return AppError::from(e).into_response(),
|
||||
|
||||
@@ -94,7 +94,11 @@ pub fn extract_client_ip<B>(req: &Request<B>) -> String {
|
||||
}
|
||||
|
||||
/// Build a rate-limit response with the standard `Retry-After` header.
|
||||
fn too_many_requests(retry_after: u64) -> Response {
|
||||
///
|
||||
/// Public so handlers that do their own (non-middleware) rate checks —
|
||||
/// e.g. the email-invite branch of `POST /api/grants`, where the limit
|
||||
/// only applies to one subject variant — can return the same shape.
|
||||
pub fn too_many_requests(retry_after: u64) -> Response {
|
||||
let body = serde_json::json!({
|
||||
"error": "Too many requests",
|
||||
"retry_after_secs": retry_after,
|
||||
|
||||
Reference in New Issue
Block a user