From 56f193dfbcdb594d6ee1f7bf6ae3d31b7f43c202 Mon Sep 17 00:00:00 2001 From: Devon Hudson Date: Sat, 10 Oct 2026 19:34:48 +0000 Subject: [PATCH] Compare a registration token's pinned email address ignoring case The rest of MAS compares email addresses ignoring case. Comparing the pin exactly made a token pinning `Alice@Example.com` unusable by a registration whose address came from an upstream provider as `alice@example.com`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LpxhUvvbRYpQSxhidPnnP7 --- .../handlers/src/views/register/password.rs | 39 ++++++++++++++++++- .../handlers/src/views/register/steps/mod.rs | 2 +- .../register/steps/registration_token.rs | 33 ++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/crates/handlers/src/views/register/password.rs b/crates/handlers/src/views/register/password.rs index 458911acf..2930fe866 100644 --- a/crates/handlers/src/views/register/password.rs +++ b/crates/handlers/src/views/register/password.rs @@ -127,7 +127,11 @@ pub(super) async fn register( state.add_error_on_field(RegisterFormField::Username, FieldError::Invalid); } - if pinned_email.is_some_and(|pinned| Some(pinned) != email.as_deref()) { + if pinned_email.is_some_and(|pinned| { + !email + .as_deref() + .is_some_and(|email| pinned.eq_ignore_ascii_case(email)) + }) { state.add_error_on_field(RegisterFormField::Email, FieldError::Invalid); } @@ -1172,6 +1176,39 @@ mod tests { assert!(!repo.user().exists("mallory").await.unwrap()); } + /// An invite code's pinned email address matches whatever its case + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_register_with_token_email_in_another_case(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": "alice", + "email": "Alice@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::SEE_OTHER); + } + /// An invite code which pins an email address can't be used to register /// another #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] diff --git a/crates/handlers/src/views/register/steps/mod.rs b/crates/handlers/src/views/register/steps/mod.rs index dfcf1e151..61e932566 100644 --- a/crates/handlers/src/views/register/steps/mod.rs +++ b/crates/handlers/src/views/register/steps/mod.rs @@ -26,7 +26,7 @@ fn token_fits( && token .email .as_deref() - .is_none_or(|pinned| Some(pinned) == email) + .is_none_or(|pinned| email.is_some_and(|email| pinned.eq_ignore_ascii_case(email))) && (token.passwordless || registration.password.is_some() || registration diff --git a/crates/handlers/src/views/register/steps/registration_token.rs b/crates/handlers/src/views/register/steps/registration_token.rs index abf66c0e7..2b8555e78 100644 --- a/crates/handlers/src/views/register/steps/registration_token.rs +++ b/crates/handlers/src/views/register/steps/registration_token.rs @@ -325,6 +325,39 @@ mod tests { ); } + /// A token's pinned email address matches whatever its case + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_accepts_token_pinning_the_email_in_another_case(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, + "alice", + Some("alice@example.com"), + true, + None, + ) + .await; + + let response = submit_token(&state, &cookies, ®istration, "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) {