Skip the registration rate limit for invite registrations

The registration rate limit is keyed on the client IP only, three per
hour by default, so a team invited together from behind one NAT could
only accept three invites an hour. An invite registers only the one
username it pins, and is minted by an admin client for one address, so
it can't be used to register in bulk. The email authentication rate
limits still apply to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
This commit is contained in:
Devon Hudson
2026-10-13 15:30:54 +02:00
committed by Quentin Gliech
co-authored by Claude Opus 5.5
parent 40c316ca29
commit 059bc0bb0f
+119 -2
View File
@@ -255,8 +255,13 @@ pub(super) async fn register(
}
if state.is_valid() {
// Check the rate limit if we are about to process the form
if let Err(e) = limiter.check_registration(requester) {
// Check the rate limit if we are about to process the form. Invites
// skip the registration limit, so that people behind one address
// can accept theirs together.
let is_invite = registration_token
.as_ref()
.is_some_and(UserRegistrationToken::is_invite);
if !is_invite && let Err(e) = limiter.check_registration(requester) {
tracing::warn!(error = &e as &dyn std::error::Error);
state.add_error_on_form(FormError::RateLimitExceeded);
}
@@ -420,10 +425,13 @@ pub(super) async fn render(
#[cfg(test)]
mod tests {
use std::net::IpAddr;
use hyper::{
Request, Response, StatusCode,
header::{CONTENT_TYPE, LOCATION},
};
use mas_config::RateLimitingConfig;
use mas_router::Route;
use mas_storage::{RepositoryAccess, user::UserRegistrationTokenRepository};
use sqlx::PgPool;
@@ -1325,4 +1333,113 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::METHOD_NOT_ALLOWED);
}
/// Post the registration form from the given IP address
async fn register_from(
state: &TestState,
ip: IpAddr,
mut form: serde_json::Value,
) -> Response<String> {
let cookies = CookieHelper::new();
form["csrf"] = mint_csrf_token(state, &cookies).into();
form["accept_terms"] = "on".into();
let request = Request::post(&*mas_router::Register::default().path_and_query())
.client_ip(ip)
.form(form);
state.request(cookies.with_cookies(request)).await
}
/// Invites skip the registration rate limit, so people behind one address
/// can accept theirs together
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_invites_skip_rate_limit(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let ip = IpAddr::from([192, 0, 2, 1]);
// Past the registration limit, and within the email one
let limits = RateLimitingConfig::default();
let burst = limits.registration.burst.get();
assert!(burst < limits.email_authentication.per_ip.burst.get());
for i in 0..=burst {
let token = format!("invite_{i}");
add_registration_token(
&state,
&token,
Some(&format!("guest{i}")),
Some(&format!("guest{i}@example.com")),
true,
)
.await;
let response = register_from(&state, ip, serde_json::json!({ "token": token })).await;
response.assert_status(StatusCode::SEE_OTHER);
}
}
/// A code which isn't an invite is still subject to the registration rate
/// limit
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_with_token_rate_limited(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let password = "correcthorsebatterystaple";
for (ip, token, username, email, passwordless) in [
([192, 0, 2, 1], "username_only", Some("bob"), None, true),
(
[192, 0, 2, 2],
"email_only",
None,
Some("carol@example.com"),
true,
),
(
[192, 0, 2, 3],
"not_passwordless",
Some("dave"),
Some("dave@example.com"),
false,
),
] {
add_registration_token(&state, token, username, email, passwordless).await;
let ip = IpAddr::from(ip);
// Spend the limit
for i in 0..RateLimitingConfig::default().registration.burst.get() {
let response = register_from(
&state,
ip,
serde_json::json!({
"username": format!("user{i}"),
"email": format!("user{i}+{token}@example.com"),
"password": password,
"password_confirm": password,
}),
)
.await;
response.assert_status(StatusCode::SEE_OTHER);
}
let response = register_from(
&state,
ip,
serde_json::json!({
"token": token,
"username": username.unwrap_or("erin"),
"email": email.unwrap_or("erin@example.com"),
"password": password,
"password_confirm": password,
}),
)
.await;
response.assert_status(StatusCode::OK);
assert_eq!(
form_state(response.body())["errors"],
serde_json::json!([{"kind": "rate_limit_exceeded"}]),
"with {token}"
);
}
}
}