Enforce a registration token's pins when attaching it and when finishing

`POST /register` checks that a registration matches the username and
email address its token pins, but the token step could attach any valid
token to a registration afterwards. Registering as anyone, then
attaching a pinned token, registered a username the token doesn't pin.
Swapping a passwordless token for one that isn't left a registration
with no password at all.

The token step now refuses a token that doesn't fit the registration:
one that pins another username or another email address than the
registration's, or one that isn't passwordless for a registration
without a password or an upstream link. Finishing a registration checks
the same against the verified email address, and refuses a passwordless
registration without one. Only a tampered registration gets that far,
so these fail like the other last-minute checks there.

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-09 19:57:11 +00:00
co-authored by Claude Opus 5.5
parent d93009a319
commit e6d64744bb
8 changed files with 719 additions and 15 deletions
+167
View File
@@ -435,6 +435,173 @@ mod tests {
csrf_token.form_value().clone()
}
pub(super) async fn add_registration_token(
state: &TestState,
token: &str,
username: Option<&str>,
email: Option<&str>,
passwordless: bool,
) -> mas_data_model::UserRegistrationToken {
let mut repo = state.repository().await.unwrap();
let token = repo
.user_registration_token()
.add(
&mut state.rng(),
&state.clock,
token.to_owned(),
None,
None,
username.map(ToOwned::to_owned),
email.map(ToOwned::to_owned),
passwordless,
)
.await
.unwrap();
repo.save().await.unwrap();
token
}
/// Start a registration in this browser, as `POST /register` and the
/// email verification step leave it
pub(super) async fn add_registration(
state: &TestState,
cookies: &CookieHelper,
username: &str,
verified_email: Option<&str>,
with_password: bool,
token: Option<&mas_data_model::UserRegistrationToken>,
) -> mas_data_model::UserRegistration {
let mut rng = state.rng();
let mut repo = state.repository().await.unwrap();
let mut registration = repo
.user_registration()
.add(
&mut rng,
&state.clock,
username.to_owned(),
None,
None,
None,
)
.await
.unwrap();
if let Some(email) = verified_email {
let authentication = repo
.user_email()
.add_authentication_for_registration(
&mut rng,
&state.clock,
email.to_owned(),
&registration,
)
.await
.unwrap();
let code = repo
.user_email()
.add_authentication_code(
&mut rng,
&state.clock,
chrono::Duration::minutes(5),
&authentication,
"123456".to_owned(),
)
.await
.unwrap();
let authentication = repo
.user_email()
.complete_authentication_with_code(&state.clock, authentication, &code)
.await
.unwrap();
registration = repo
.user_registration()
.set_email_authentication(registration, &authentication)
.await
.unwrap();
}
if with_password {
registration = repo
.user_registration()
.set_password(registration, "hashed".to_owned(), 1)
.await
.unwrap();
}
if let Some(token) = token {
registration = repo
.user_registration()
.set_registration_token(registration, token)
.await
.unwrap();
}
repo.save().await.unwrap();
cookies.import(
super::UserRegistrationSessionsCookie::default()
.add(&registration)
.save(state.cookie_jar(), &state.clock),
);
registration
}
/// Link a registration to an upstream identity, as registering through an
/// upstream provider does
pub(super) async fn link_upstream(
state: &TestState,
registration: mas_data_model::UserRegistration,
) -> mas_data_model::UserRegistration {
let provider_id = provider(state).await;
let mut rng = state.rng();
let mut repo = state.repository().await.unwrap();
let provider = repo
.upstream_oauth_provider()
.lookup(provider_id)
.await
.unwrap()
.unwrap();
let session = repo
.upstream_oauth_session()
.add(
&mut rng,
&state.clock,
&provider,
registration.username.clone(),
None,
None,
)
.await
.unwrap();
let link = repo
.upstream_oauth_link()
.add(
&mut rng,
&state.clock,
&provider,
registration.username.clone(),
None,
)
.await
.unwrap();
let session = repo
.upstream_oauth_session()
.complete_with_link(&state.clock, session, &link, None, None, None, None)
.await
.unwrap();
let registration = repo
.user_registration()
.set_upstream_oauth_authorization_session(registration, &session)
.await
.unwrap();
repo.save().await.unwrap();
registration
}
/// Decode the upstream sessions cookie set by the given response, if any
fn upstream_sessions(
state: &TestState,
+39 -1
View File
@@ -423,7 +423,7 @@ mod tests {
use sqlx::PgPool;
use ulid::Ulid;
use super::super::tests::{csrf_token, mint_csrf_token};
use super::super::tests::{add_registration_token, csrf_token, mint_csrf_token};
use crate::{
SiteConfig,
test_utils::{
@@ -1136,6 +1136,44 @@ mod tests {
assert!(!repo.user().exists("mallory").await.unwrap());
}
/// An invite code which pins an email address can't be used to register
/// another
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_register_with_token_email_mismatch(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
add_registration_token(
&state,
"invite_alice",
None,
Some("alice@example.com"),
false,
)
.await;
let csrf_token = mint_csrf_token(&state, &cookies);
let request = Request::post(&*mas_router::Register::default().path_and_query()).form(
serde_json::json!({
"csrf": csrf_token,
"username": "mallory",
"email": "mallory@example.com",
"password": "correcthorsebatterystaple",
"password_confirm": "correcthorsebatterystaple",
"token": "invite_alice",
"accept_terms": "on",
}),
);
let request = cookies.with_cookies(request);
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
assert_eq!(
form_state(response.body())["fields"]["email"]["errors"],
serde_json::json!([{"kind": "invalid"}])
);
}
/// A passwordless invite code is a way to register on its own: it works
/// with password registration off, and with passwords disabled entirely
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
@@ -225,6 +225,39 @@ pub(crate) async fn get(
None
};
if registration_token
.as_ref()
.is_some_and(|registration_token| {
!super::token_fits(
registration_token,
&registration,
email_authentication
.as_ref()
.map(|authentication| authentication.email.as_str()),
)
})
{
return Err(InternalError::from_anyhow(anyhow::anyhow!(
"Registration token doesn't fit the registration"
)));
}
// Without a password or an upstream link, a passwordless token and a
// verified email address are what the user registers with
if registration.password.is_none()
&& registration
.upstream_oauth_authorization_session_id
.is_none()
&& !(registration_token
.as_ref()
.is_some_and(|registration_token| registration_token.passwordless)
&& email_authentication.is_some())
{
return Err(InternalError::from_anyhow(anyhow::anyhow!(
"Registration has no password, no upstream link, and no passwordless registration token with a verified email address"
)));
}
// If this registration was created from an upstream OAuth session, check
// it is still valid and wasn't linked to a user in the meantime
let upstream_oauth = if let Some(upstream_oauth_authorization_session_id) =
@@ -391,3 +424,206 @@ pub(crate) async fn get(
)
.into_response());
}
#[cfg(test)]
mod tests {
use hyper::{Request, Response, StatusCode, header::LOCATION};
use mas_data_model::UserRegistration;
use mas_router::Route as _;
use sqlx::PgPool;
use crate::{
test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup},
views::register::tests::{add_registration, add_registration_token, link_upstream},
};
async fn finish(
state: &TestState,
cookies: &CookieHelper,
registration: &UserRegistration,
) -> Response<String> {
let request =
Request::get(&*mas_router::RegisterFinish::new(registration.id).path_and_query())
.empty();
state.request(cookies.with_cookies(request)).await
}
/// Assert that finish refused the registration with an error naming
/// `reason`
fn assert_refused(response: &Response<String>, reason: &str) {
response.assert_status(StatusCode::INTERNAL_SERVER_ERROR);
assert!(
response.body().contains(reason),
"response body: {}",
response.body()
);
}
/// A registration matching the pins of its passwordless token goes on to
/// the next step
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_finish_with_pinned_token(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
let token = add_registration_token(
&state,
"invite_alice",
Some("alice"),
Some("alice@example.com"),
true,
)
.await;
let registration = add_registration(
&state,
&cookies,
"alice",
Some("alice@example.com"),
false,
Some(&token),
)
.await;
let response = finish(&state, &cookies, &registration).await;
response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(
LOCATION,
&mas_router::RegisterDisplayName::new(registration.id).path_and_query(),
);
}
/// A registration linked upstream needs neither a password nor a
/// passwordless token
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_finish_upstream_without_password(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
let token = add_registration_token(&state, "plain", None, None, false).await;
for (username, token) in [("alice", None), ("bob", Some(&token))] {
let registration = add_registration(
&state,
&cookies,
username,
Some(&format!("{username}@example.com")),
false,
token,
)
.await;
let registration = link_upstream(&state, registration).await;
let response = finish(&state, &cookies, &registration).await;
response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(
LOCATION,
&mas_router::RegisterDisplayName::new(registration.id).path_and_query(),
);
}
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_finish_refuses_token_pinning_another_username(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
let token =
add_registration_token(&state, "invite_alice", Some("alice"), None, false).await;
let registration = add_registration(
&state,
&cookies,
"mallory",
Some("mallory@example.com"),
true,
Some(&token),
)
.await;
let response = finish(&state, &cookies, &registration).await;
assert_refused(&response, "fit the registration");
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_finish_refuses_token_pinning_another_email(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
let token = add_registration_token(
&state,
"invite_alice",
Some("alice"),
Some("alice@example.com"),
true,
)
.await;
let registration = add_registration(
&state,
&cookies,
"alice",
Some("mallory@example.com"),
false,
Some(&token),
)
.await;
let response = finish(&state, &cookies, &registration).await;
assert_refused(&response, "fit the registration");
// A registration without an email address doesn't match a pinned one
let token =
add_registration_token(&state, "pins_email", None, Some("bob@example.com"), false)
.await;
let registration =
add_registration(&state, &cookies, "bob", None, true, Some(&token)).await;
let response = finish(&state, &cookies, &registration).await;
assert_refused(&response, "fit the registration");
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_finish_refuses_no_password_without_passwordless_token(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
let registration = add_registration(
&state,
&cookies,
"bob",
Some("bob@example.com"),
false,
None,
)
.await;
let response = finish(&state, &cookies, &registration).await;
assert_refused(&response, "no passwordless registration token");
let token = add_registration_token(&state, "plain", None, None, false).await;
let registration = add_registration(
&state,
&cookies,
"carol",
Some("carol@example.com"),
false,
Some(&token),
)
.await;
let response = finish(&state, &cookies, &registration).await;
assert_refused(&response, "fit the registration");
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_finish_refuses_passwordless_token_without_verified_email(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
let token = add_registration_token(&state, "passwordless", None, None, true).await;
let registration =
add_registration(&state, &cookies, "dave", None, false, Some(&token)).await;
let response = finish(&state, &cookies, &registration).await;
assert_refused(&response, "no passwordless registration token");
}
}
@@ -1,9 +1,35 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
use mas_data_model::{UserRegistration, UserRegistrationToken};
pub(crate) mod display_name;
pub(crate) mod finish;
pub(crate) mod registration_token;
pub(crate) mod verify_email;
/// Whether `token` can be used for `registration`, whose email address is
/// `email`: it pins no other username or email address, and it is passwordless
/// if the registration has neither a password nor an upstream link
fn token_fits(
token: &UserRegistrationToken,
registration: &UserRegistration,
email: Option<&str>,
) -> bool {
token
.username
.as_ref()
.is_none_or(|username| *username == registration.username)
&& token
.email
.as_deref()
.is_none_or(|pinned| Some(pinned) == email)
&& (token.passwordless
|| registration.password.is_some()
|| registration
.upstream_oauth_authorization_session_id
.is_some())
}
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -188,6 +189,35 @@ pub(crate) async fn post(
.into_response());
}
let email = match registration.email_authentication_id {
Some(email_authentication_id) => Some(
repo.user_email()
.lookup_authentication(email_authentication_id)
.await?
.context("Could not load the email authentication")
.map_err(InternalError::from_anyhow)?
.email,
),
None => None,
};
if !super::token_fits(&registration_token, &registration, email.as_deref()) {
tracing::warn!("Registration token doesn't fit the registration");
let ctx = RegisterStepsRegistrationTokenContext::new()
.with_form_state(form.to_form_state().with_error_on_field(
RegisterStepsRegistrationTokenFormField::Token,
FieldError::Mismatch,
))
.with_csrf(csrf_token.form_value())
.with_language(locale);
return Ok((
cookie_jar,
Html(templates.render_register_steps_registration_token(&ctx)?),
)
.into_response());
}
// Associate the token with the registration
let registration = repo
.user_registration()
@@ -200,3 +230,200 @@ pub(crate) async fn post(
let destination = mas_router::RegisterFinish::new(registration.id);
Ok((cookie_jar, url_builder.redirect(&destination)).into_response())
}
#[cfg(test)]
mod tests {
use hyper::{Request, Response, StatusCode, header::LOCATION};
use mas_data_model::UserRegistration;
use mas_router::Route as _;
use sqlx::PgPool;
use crate::{
test_utils::{CookieHelper, RequestBuilderExt, ResponseExt, TestState, setup},
views::register::tests::{
add_registration, add_registration_token, link_upstream, mint_csrf_token,
},
};
async fn submit_token(
state: &TestState,
cookies: &CookieHelper,
registration: &UserRegistration,
token: &str,
) -> Response<String> {
let csrf_token = mint_csrf_token(state, cookies);
let request =
Request::post(&*mas_router::RegisterToken::new(registration.id).path_and_query())
.form(serde_json::json!({ "csrf": csrf_token, "token": token }));
state.request(cookies.with_cookies(request)).await
}
/// Assert that the token step refused the token as not fitting the
/// registration, and attached nothing
async fn assert_refused(
state: &TestState,
response: &Response<String>,
registration: &UserRegistration,
) {
response.assert_status(StatusCode::OK);
assert!(
response.body().contains(r#"data-error-kind="mismatch""#),
"response body: {}",
response.body()
);
let mut repo = state.repository().await.unwrap();
let registration = repo
.user_registration()
.lookup(registration.id)
.await
.unwrap()
.unwrap();
assert_eq!(registration.user_registration_token_id, None);
}
/// The token step comes before the email address is verified
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_accepts_token_pinning_an_unverified_email(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
add_registration_token(
&state,
"invite_alice",
Some("alice"),
Some("alice@example.com"),
false,
)
.await;
let registration = add_registration(&state, &cookies, "alice", None, true, None).await;
let mut repo = state.repository().await.unwrap();
let authentication = repo
.user_email()
.add_authentication_for_registration(
&mut state.rng(),
&state.clock,
"alice@example.com".to_owned(),
&registration,
)
.await
.unwrap();
let registration = repo
.user_registration()
.set_email_authentication(registration, &authentication)
.await
.unwrap();
repo.save().await.unwrap();
let response = submit_token(&state, &cookies, &registration, "invite_alice").await;
response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(
LOCATION,
&mas_router::RegisterFinish::new(registration.id).path_and_query(),
);
}
/// A registration linked upstream needs no password, so any token fits it
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_accepts_token_not_passwordless_upstream(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
add_registration_token(&state, "plain", None, None, false).await;
let registration = add_registration(
&state,
&cookies,
"alice",
Some("alice@example.com"),
false,
None,
)
.await;
let registration = link_upstream(&state, registration).await;
let response = submit_token(&state, &cookies, &registration, "plain").await;
response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(
LOCATION,
&mas_router::RegisterFinish::new(registration.id).path_and_query(),
);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_refuses_token_pinning_another_username(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
add_registration_token(&state, "invite_alice", Some("alice"), None, false).await;
let registration = add_registration(
&state,
&cookies,
"mallory",
Some("mallory@example.com"),
true,
None,
)
.await;
let response = submit_token(&state, &cookies, &registration, "invite_alice").await;
assert_refused(&state, &response, &registration).await;
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_refuses_token_pinning_another_email(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
add_registration_token(
&state,
"invite_alice",
None,
Some("alice@example.com"),
false,
)
.await;
let registration = add_registration(
&state,
&cookies,
"mallory",
Some("mallory@example.com"),
true,
None,
)
.await;
let response = submit_token(&state, &cookies, &registration, "invite_alice").await;
assert_refused(&state, &response, &registration).await;
// Nor does it fit a registration without an email address
let registration = add_registration(&state, &cookies, "bob", None, true, None).await;
let response = submit_token(&state, &cookies, &registration, "invite_alice").await;
assert_refused(&state, &response, &registration).await;
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
async fn test_refuses_token_not_passwordless_without_password(pool: PgPool) {
setup();
let state = TestState::from_pool(pool).await.unwrap();
let cookies = CookieHelper::new();
add_registration_token(&state, "plain", None, None, false).await;
let registration = add_registration(
&state,
&cookies,
"alice",
Some("alice@example.com"),
false,
None,
)
.await;
let response = submit_token(&state, &cookies, &registration, "plain").await;
assert_refused(&state, &response, &registration).await;
}
}
+4
View File
@@ -1,3 +1,4 @@
// Copyright 2025, 2026 Element Creations Ltd.
// Copyright 2024, 2025 New Vector Ltd.
// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
//
@@ -34,6 +35,9 @@ pub enum FieldError {
/// That value already exists
Exists,
/// The value doesn't fit what the rest of the flow already holds
Mismatch,
/// Denied by the policy
Policy {
/// Well-known policy code
+2
View File
@@ -28,6 +28,8 @@ Please see LICENSE files in the repository root for full details.
{{ _("mas.errors.field_required") }}
{% elif error.kind == "exists" and field_name == "username" %}
{{ _("mas.errors.username_taken") }}
{% elif error.kind == "mismatch" and field_name == "token" %}
{{ _("mas.errors.registration_token_mismatch") }}
{% elif error.kind == "policy" %}
{% if error.code == "username-too-short" %}
{{ _("mas.errors.username_too_short") }}
+18 -14
View File
@@ -369,27 +369,27 @@
},
"denied_policy": "Denied by policy: %(policy)s",
"@denied_policy": {
"context": "components/errors.html:17:7-58, components/field.html:53:11-62"
"context": "components/errors.html:17:7-58, components/field.html:55:11-62"
},
"email_banned": "Email is banned by the server policy",
"@email_banned": {
"context": "components/field.html:51:11-39"
"context": "components/field.html:53:11-39"
},
"email_domain_banned": "Email domain is banned by the server policy",
"@email_domain_banned": {
"context": "components/field.html:47:11-46"
"context": "components/field.html:49:11-46"
},
"email_domain_not_allowed": "Email domain is not allowed by the server policy",
"@email_domain_not_allowed": {
"context": "components/field.html:45:11-51"
"context": "components/field.html:47:11-51"
},
"email_not_allowed": "Email is not allowed by the server policy",
"@email_not_allowed": {
"context": "components/field.html:49:11-44"
"context": "components/field.html:51:11-44"
},
"field_invalid": "This field is invalid",
"@field_invalid": {
"context": "components/field.html:58:9-38"
"context": "components/field.html:60:9-38"
},
"field_required": "This field is required",
"@field_required": {
@@ -401,28 +401,32 @@
},
"password_mismatch": "Password fields don't match",
"@password_mismatch": {
"context": "components/errors.html:13:7-40, components/field.html:56:9-42"
"context": "components/errors.html:13:7-40, components/field.html:58:9-42"
},
"rate_limit_exceeded": "You've made too many requests in a short period. Please wait a few minutes and try again.",
"@rate_limit_exceeded": {
"context": "components/errors.html:15:7-42, pages/recovery/progress.html:26:11-46"
},
"registration_token_mismatch": "This token can't be used to create this account",
"@registration_token_mismatch": {
"context": "components/field.html:32:9-52"
},
"username_all_numeric": "Username cannot consist solely of numbers",
"@username_all_numeric": {
"context": "components/field.html:39:11-47"
"context": "components/field.html:41:11-47"
},
"username_banned": "Username is banned by the server policy",
"@username_banned": {
"context": "components/field.html:41:11-42",
"context": "components/field.html:43:11-42",
"description": "Error message shown on registration, when the username matches a pattern that is banned by the server policy."
},
"username_invalid_chars": "Username contains invalid characters. Use lowercase letters, numbers, dashes and underscores only.",
"@username_invalid_chars": {
"context": "components/field.html:37:11-49"
"context": "components/field.html:39:11-49"
},
"username_not_allowed": "Username is not allowed by the server policy",
"@username_not_allowed": {
"context": "components/field.html:43:11-47",
"context": "components/field.html:45:11-47",
"description": "Error message shown on registration, when the username *does not match* any of the patterns that are allowed by the server policy."
},
"username_taken": "This username is already taken",
@@ -431,11 +435,11 @@
},
"username_too_long": "Username is too long",
"@username_too_long": {
"context": "components/field.html:35:11-44"
"context": "components/field.html:37:11-44"
},
"username_too_short": "Username is too short",
"@username_too_short": {
"context": "components/field.html:33:11-45"
"context": "components/field.html:35:11-45"
}
},
"legacy_consent": {
@@ -525,7 +529,7 @@
},
"or_separator": "Or",
"@or_separator": {
"context": "components/field.html:116:10-31",
"context": "components/field.html:118:10-31",
"description": "Separator between the login methods"
},
"policy_violation": {