mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-15 04:31:20 +00:00
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:
committed by
Quentin Gliech
co-authored by
Claude Opus 5.5
parent
40c316ca29
commit
059bc0bb0f
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user