From 32d85d03bb5ec401d1378f4cd60139be1d8db3f4 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:13:32 +0200 Subject: [PATCH 1/7] Fix organization import failing with missing field groups (#7699) --- src/api/core/organizations.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 9082297f..c0c90426 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -132,7 +132,6 @@ struct FullCollectionData { name: String, groups: Vec, users: Vec, - id: Option, external_id: Option, } @@ -1793,11 +1792,22 @@ async fn bulk_public_keys( use super::ciphers::CipherData; use super::ciphers::update_cipher_from_data; +// The import endpoint only ever uses the name/id/external_id of a collection. +// Bitwarden's own server ignores `groups`/`users` here too, so do not make them +// mandatory: clients are free to leave them out. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImportCollectionData { + name: String, + id: Option, + external_id: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct ImportData { ciphers: Vec, - collections: Vec, + collections: Vec, collection_relationships: Vec, } From 2ffad8775d8712329aab7d00a05a94f64098170b Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:13:40 +0200 Subject: [PATCH 2/7] Add `pm-32413-multi-client-password-management` feature flag (#7677) --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index 5f6f374c..d22145b8 100644 --- a/.env.template +++ b/.env.template @@ -390,6 +390,7 @@ ## ## The following flags are available: ## - "pm-5594-safari-account-switching": Enable account switching in Safari. (Safari >= 2026.2.0) +## - "pm-32413-multi-client-password-management": Enable changing the master password directly in the client. (Desktop/Extension >= 2026.4.0) ## - "ssh-agent": Enable SSH agent support on Desktop. (Desktop >= 2024.12.0) ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) diff --git a/src/config.rs b/src/config.rs index 87bea195..7e21ecf1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1425,6 +1425,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "desktop-ui-migration-milestone-4", // Auth Team "pm-5594-safari-account-switching", + "pm-32413-multi-client-password-management", // Autofill Team "ssh-agent", "ssh-agent-v2", From 277e1536ebe426296519f8bd8bf99f5f6c1c5c77 Mon Sep 17 00:00:00 2001 From: The CRahn <5043504+crahn@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:13:51 -0500 Subject: [PATCH 3/7] Log IP/username on two-factor email-login credential failures (#7654) The three "Username or password is incorrect" errors in send_email_login() (email.rs) don't log the client IP or submitted identifier, unlike the equivalent wrong-password error in password_login() (identity.rs), which logs both via format!("IP: {}. Username: {username}.", ip.ip). This makes the two code paths inconsistent for the same underlying error, and means log-based tooling that keys on the identity.rs error's "IP: x.x.x.x" pattern can't do the same for this endpoint. Bring email.rs's three call sites in line with identity.rs's existing format. The two email-present branches log IP+Username (the email submitted); the device-identifier-only branch (SSO path, no email in scope) logs IP+Device instead of fabricating a username. Verified: cargo build/test/clippy/fmt all pass with the sqlite feature (matching one leg of this repo's own CI matrix), including the two existing unit tests in this file. --- src/api/core/two_factor/email.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 44ba2e7f..3667b871 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -63,13 +63,19 @@ async fn send_email_login(data: Json, client_headers: Client let user = if let Some(email) = email { let Some(user) = User::find_by_mail(email, &conn).await else { - err!("Username or password is incorrect. Try again.") + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Username: {email}.", client_headers.ip.ip) + ) }; if let Some(master_password_hash) = master_password_hash { // Check password if !user.check_valid_password(master_password_hash) { - err!("Username or password is incorrect. Try again.") + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Username: {email}.", client_headers.ip.ip) + ) } } else if let Some(auth_request_id) = auth_request_id { let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { @@ -96,7 +102,10 @@ async fn send_email_login(data: Json, client_headers: Client }; // SSO login only sends device id, so we get the user by the most recently used device let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else { - err!("Username or password is incorrect. Try again.") + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip) + ) }; user From 57fbed1bed2e42b540cb790dd536e02633c4f445 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 8 Sep 2026 10:14:01 +0000 Subject: [PATCH 4/7] Support admin reset 2fa (#7435) * Support admin reset 2fa * Fix recovery email --------- Co-authored-by: Timshel --- playwright/tests/organization.smtp.spec.ts | 7 +- src/api/core/organizations.rs | 89 ++++++++++++------- src/auth.rs | 12 ++- src/config.rs | 2 +- src/db/models/event.rs | 8 +- src/mail.rs | 14 ++- .../email/admin_account_recovery.hbs | 12 +++ ...ml.hbs => admin_account_recovery.html.hbs} | 11 ++- .../templates/email/admin_reset_password.hbs | 4 - 9 files changed, 112 insertions(+), 47 deletions(-) create mode 100644 src/static/templates/email/admin_account_recovery.hbs rename src/static/templates/email/{admin_reset_password.html.hbs => admin_account_recovery.html.hbs} (55%) delete mode 100644 src/static/templates/email/admin_reset_password.hbs diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 6d0eb859..1e97ed5d 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -127,6 +127,9 @@ test('Organization is visible', async ({ page }) => { }); test('Recover user password', async ({ page }) => { + await logUser(test, page, users.user2, { mailBuffer: mail2Buffer }); + await activateTOTP(test, page, users.user2); + await logUser(test, page, users.user1, { mailBuffer: mail1Buffer }); let newPassword = "TotoNewPassword"; @@ -138,9 +141,10 @@ test('Recover user password', async ({ page }) => { await page.getByRole('menuitem', { name: 'Recover account' }).click(); await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); await page.getByRole('textbox', { name: 'Confirm new master password * (' }).fill(newPassword); + await page.getByRole('checkbox', { name: 'Reset two-step login' }).check(); await page.getByRole('button', { name: 'Save' }).click(); await utils.checkNotification(page, 'Account recovery success'); - await mail2Buffer.expect((m) => m.subject.includes('Master Password Has Been Changed')); + await mail2Buffer.expect((m) => m.subject.includes('Admin account recovery from Test organization')); }); let user2 = { @@ -150,6 +154,7 @@ test('Recover user password', async ({ page }) => { }; await logUser(test, page, user2, { mailBuffer: mail2Buffer, + mail2fa: true, notNewDevice: true, }); }); diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index c0c90426..4f490854 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use num_traits::FromPrimitive; -use rocket::{Route, serde::json::Json}; +use rocket::{Route, http::Status, serde::json::Json}; use serde_json::Value; use crate::{ @@ -17,7 +17,8 @@ use crate::{ models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, MembershipType, - OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, User, UserId, + OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, + UserId, }, }, mail, @@ -390,7 +391,7 @@ async fn get_org_collections(org_id: OrganizationId, headers: ManagerHeadersLoos } if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); + err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } Ok(Json(json!({ @@ -886,11 +887,11 @@ struct OrgIdData { #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { - err_code!("Resource not found.", "Organization id's do not match", rocket::http::Status::NotFound.code); + err_code!("Resource not found.", "Organization id's do not match", Status::NotFound.code); } if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); + err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } Ok(Json(json!({ @@ -954,7 +955,7 @@ async fn get_members( } if !headers.membership.has_full_access() { - err_code!("Resource not found.", "User does not have full access", rocket::http::Status::NotFound.code); + err_code!("Resource not found.", "User does not have full access", Status::NotFound.code); } let mut users_json = Vec::new(); @@ -2486,7 +2487,7 @@ async fn get_groups_data( || Collection::has_manageable_collection_by_user(&org_id, &headers.membership.user_uuid, &conn).await }; if !allowed { - err_code!("Resource not found.", "User does not have access", rocket::http::Status::NotFound.code); + err_code!("Resource not found.", "User does not have access", Status::NotFound.code); } let groups: Vec = if CONFIG.org_groups_enabled() { @@ -2937,8 +2938,8 @@ struct OrganizationUserResetPasswordEnrollmentRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserRecoverAccountRequest { - new_master_password_hash: String, - key: String, + new_master_password_hash: Option, + key: Option, #[serde(default)] reset_master_password: bool, @@ -2982,12 +2983,7 @@ async fn put_recover_account( conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { - let req = data.into_inner(); - if req.reset_master_password && !req.reset_two_factor { - recover_account(org_id, member_id, headers, req, conn, nt).await - } else { - err!("Unsupported operation") - } + recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await } // Deprecated since `v2026.4.2` @@ -3007,7 +3003,7 @@ async fn recover_account( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, - reset_request: OrganizationUserRecoverAccountRequest, + req: OrganizationUserRecoverAccountRequest, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -3022,7 +3018,7 @@ async fn recover_account( err!("User to reset isn't member of required organization") }; - let Some(user) = User::find_by_uuid(&member.user_uuid, &conn).await else { + let Some(mut user) = User::find_by_uuid(&member.user_uuid, &conn).await else { err!("User not found") }; @@ -3035,29 +3031,56 @@ async fn recover_account( err!("Organization user must be confirmed for password reset functionality"); } - // Sending email before resetting password to ensure working email configuration and the resulting - // user notification. Also this might add some protection against security flaws and misuse - if let Err(e) = mail::send_admin_reset_password(&user.email, user.display_name(), &org.name).await { + let fallback_2fa_email = if req.reset_two_factor && CONFIG.email_2fa_auto_fallback() { + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() + } else { + false + }; + + // Sending email first ensure working email configuration and the resulting user notification. + // Also this might add some protection against security flaws and misuse + if let Err(e) = mail::send_admin_account_recovery( + &user.email, + user.display_name(), + &org.name, + req.reset_master_password, + req.reset_two_factor, + fallback_2fa_email, + ) + .await + { err!(format!("Error sending user reset password email: {e:#?}")); } - let mut user = user; - user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) - .await?; + if req.reset_master_password { + if let Some(key) = req.key + && let Some(hash) = req.new_master_password_hash + { + user.set_password(hash.as_str(), Some(key), true, None, &conn).await?; + } else { + err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code); + } + } + + if req.reset_two_factor { + TwoFactor::delete_all_by_user(&user.uuid, &conn).await?; + if !fallback_2fa_email || two_factor::email::find_and_activate_email_2fa(&user.uuid, &conn).await.is_err() { + two_factor::enforce_2fa_policy(&user, &headers.user.uuid, headers.device.atype, &headers.ip.ip, &conn) + .await?; + } + } + user.save(&conn).await?; nt.send_logout(&user, None, &conn).await; - log_event( - EventType::OrganizationUserAdminResetPassword, - &member_id, - &org_id, - &headers.user.uuid, - headers.device.atype, - &headers.ip.ip, - &conn, - ) - .await; + if req.reset_master_password { + headers.log_event(EventType::OrganizationUserAdminResetPassword, &member_id, &org_id, &conn).await; + } + + if req.reset_two_factor { + headers.log_event(EventType::OrganizationUserAdminResetTwoFactor, &member_id, &org_id, &conn).await; + } Ok(()) } diff --git a/src/auth.rs b/src/auth.rs index 762088e5..07373389 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -23,14 +23,14 @@ use rocket::{ use crate::{ CONFIG, - api::ApiResult, + api::{ApiResult, core::log_event}, config::PathType, db::{ DbConn, models::{ AttachmentId, CipherId, Collection, CollectionId, Device, DeviceId, DeviceType, EmergencyAccessId, - Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, SendFileId, - SendId, User, UserId, UserStampException, + EventType, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, OrganizationId, + SendFileId, SendId, User, UserId, UserStampException, }, }, error::Error, @@ -822,6 +822,12 @@ pub struct AdminHeaders { pub org_id: OrganizationId, } +impl AdminHeaders { + pub async fn log_event(&self, event_type: EventType, source_uuid: &str, org_id: &OrganizationId, conn: &DbConn) { + log_event(event_type, source_uuid, org_id, &self.user.uuid, self.device.atype, &self.ip.ip, conn).await; + } +} + #[rocket::async_trait] impl<'r> FromRequest<'r> for AdminHeaders { type Error = &'static str; diff --git a/src/config.rs b/src/config.rs index 7e21ecf1..37fc3e85 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1745,7 +1745,7 @@ where reg!("email/email_footer"); reg!("email/email_footer_text"); - reg!("email/admin_reset_password", ".html"); + reg!("email/admin_account_recovery", ".html"); reg!("email/change_email_existing", ".html"); reg!("email/change_email_invited", ".html"); reg!("email/change_email", ".html"); diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 86cbf5d0..1f307979 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -43,7 +43,7 @@ pub struct Event { pub provider_org_uuid: Option, } -// Upstream enum: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/EventType.cs +// Upstream enum: https://github.com/bitwarden/server/blob/v2026.6.2/src/Core/Dirt/Enums/EventType.cs #[derive(Debug, Copy, Clone)] pub enum EventType { // User @@ -108,6 +108,12 @@ pub enum EventType { OrganizationUserRejectedAuthRequest = 1514, OrganizationUserDeleted = 1515, // Both user and organization user data were deleted OrganizationUserLeft = 1516, // User voluntarily left the organization + // OrganizationUserAutomaticallyConfirmed = 1517, + // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy + OrganizationUserAdminResetTwoFactor = 1519, + // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, + // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, + // OrganizationUserNotificationBannerActionClicked = 1522, // Organization OrganizationUpdated = 1600, diff --git a/src/mail.rs b/src/mail.rs index a7e5e5ae..b20f2853 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -633,14 +633,24 @@ pub async fn send_test(address: &str) -> EmptyResult { send_email(address, &subject, body_html, body_text).await } -pub async fn send_admin_reset_password(address: &str, user_name: &str, org_name: &str) -> EmptyResult { +pub async fn send_admin_account_recovery( + address: &str, + user_name: &str, + org_name: &str, + reset_password: bool, + reset_2fa: bool, + fallback_2fa_email: bool, +) -> EmptyResult { let (subject, body_html, body_text) = get_text( - "email/admin_reset_password", + "email/admin_account_recovery", json!({ "url": CONFIG.domain(), "img_src": CONFIG._smtp_img_src(), "user_name": user_name, "org_name": org_name, + "reset_password": reset_password, + "reset_2fa": reset_2fa, + "fallback_2fa_email": fallback_2fa_email, }), )?; send_email(address, &subject, body_html, body_text).await diff --git a/src/static/templates/email/admin_account_recovery.hbs b/src/static/templates/email/admin_account_recovery.hbs new file mode 100644 index 00000000..a35a1d05 --- /dev/null +++ b/src/static/templates/email/admin_account_recovery.hbs @@ -0,0 +1,12 @@ +Admin account recovery from {{org_name}} organization + +{{#if reset_password}} +The master password for {{user_name}} has been changed. +{{/if}} +{{#if reset_2fa}} +Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}} +{{/if}} + +If you did not initiate this request, please reach out to your administrator immediately. + +{{> email/email_footer_text }} diff --git a/src/static/templates/email/admin_reset_password.html.hbs b/src/static/templates/email/admin_account_recovery.html.hbs similarity index 55% rename from src/static/templates/email/admin_reset_password.html.hbs rename to src/static/templates/email/admin_account_recovery.html.hbs index d9749d22..cf8eebed 100644 --- a/src/static/templates/email/admin_reset_password.html.hbs +++ b/src/static/templates/email/admin_account_recovery.html.hbs @@ -1,10 +1,17 @@ -Master Password Has Been Changed +Admin account recovery from {{org_name}} organization {{> email/email_header }}
- The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately. + {{#if reset_password}} + The master password for {{user_name}} has been changed. + {{/if}} + {{#if reset_2fa}} + Your two-step verification providers have been reset.{{#if fallback_2fa_email}} Email two factor has been activated as a fallback.{{/if}} + {{/if}} +
+ If you did not initiate this request, please reach out to your administrator immediately.
diff --git a/src/static/templates/email/admin_reset_password.hbs b/src/static/templates/email/admin_reset_password.hbs deleted file mode 100644 index f70423f1..00000000 --- a/src/static/templates/email/admin_reset_password.hbs +++ /dev/null @@ -1,4 +0,0 @@ -Master Password Has Been Changed - -The master password for {{user_name}} has been changed by an administrator in your {{org_name}} organization. If you did not initiate this request, please reach out to your administrator immediately. -{{> email/email_footer_text }} From f1ff61300844b0664907393c0fe93f5092654784 Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 8 Sep 2026 12:14:07 +0200 Subject: [PATCH 5/7] fix(security): revoke 2FA remember tokens when credentials or 2FA change (#7682) --- src/api/core/two_factor/mod.rs | 5 +++-- src/api/identity.rs | 6 ++++++ src/db/models/device.rs | 12 ++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index c95fb297..0eb6563e 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -16,8 +16,8 @@ use crate::{ db::{ DbConn, DbPool, models::{ - DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, TwoFactor, - TwoFactorIncomplete, TwoFactorType, User, UserId, + Device, DeviceType, EventType, Membership, MembershipType, OrgPolicyType, Organization, OrganizationId, + TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId, }, }, mail, @@ -151,6 +151,7 @@ async fn disable_twofactor(data: Json, headers: Headers, c if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { twofactor.delete(&conn).await?; + Device::clear_twofactor_remember_by_user(&user.uuid, &conn).await?; log_user_event(EventType::UserDisabled2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) .await; } diff --git a/src/api/identity.rs b/src/api/identity.rs index 2b1ddfb1..a2525d9b 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -905,6 +905,12 @@ async fn twofactor_auth( // Remove all twofactors from the user TwoFactor::delete_all_by_user(&user.uuid, conn).await?; + + // No device may keep skipping 2FA once every second factor is gone. + // `device` is cleared in memory too, since saving it later would restore its token. + Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?; + device.delete_twofactor_remember(); + enforce_2fa_policy(user, &user.uuid, device.atype, &ip.ip, conn).await?; log_user_event(EventType::UserRecovered2fa as i32, &user.uuid, device.atype, &ip.ip, conn).await; diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 6c1b686a..cc8f1cec 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -266,10 +266,22 @@ impl Device { let devices = Self::find_by_user(user_uuid, conn).await; for mut device in devices { device.refresh_token = Device::generate_refresh_token(); + device.twofactor_remember = None; device.save(false, conn).await?; } Ok(()) } + + pub async fn clear_twofactor_remember_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::update(devices::table) + .filter(devices::user_uuid.eq(user_uuid)) + .set(devices::twofactor_remember.eq::>(None)) + .execute(conn) + .map_res("Error removing two factor remember tokens") + }) + .await + } } #[derive(Display)] From f1c36b8c1d9b2cdd0f1cf6f1c4062f81c3a70302 Mon Sep 17 00:00:00 2001 From: Bryan Date: Tue, 8 Sep 2026 12:14:16 +0200 Subject: [PATCH 6/7] fix(security): rate limit prelogin and auth request endpoints (#7681) --- src/api/core/accounts.rs | 16 +++++++++++----- src/api/identity.rs | 8 ++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 626f22bb..3ea6eada 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1340,11 +1340,13 @@ pub struct PreloginData { } #[post("/accounts/prelogin", data = "")] -async fn post_prelogin(data: Json, conn: DbConn) -> Json { - prelogin(data, conn).await +async fn post_prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { + prelogin(data, ip, conn).await } -pub async fn prelogin(data: Json, conn: DbConn) -> Json { +pub async fn prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { + crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; + let data: PreloginData = data.into_inner(); let (kdf_type, kdf_iter, kdf_mem, kdf_para) = match User::find_by_mail(&data.email, &conn).await { @@ -1352,7 +1354,7 @@ pub async fn prelogin(data: Json, conn: DbConn) -> Json { None => (User::CLIENT_KDF_TYPE_DEFAULT, User::CLIENT_KDF_ITER_DEFAULT, None, None), }; - Json(json!({ + Ok(Json(json!({ "kdf": kdf_type, "kdfIterations": kdf_iter, "kdfMemory": kdf_mem, @@ -1364,7 +1366,7 @@ pub async fn prelogin(data: Json, conn: DbConn) -> Json { "parallelism": kdf_para }, "salt": null, - })) + }))) } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Models/Request/Accounts/SecretVerificationRequestModel.cs @@ -1595,6 +1597,8 @@ async fn post_auth_request( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { + crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?; + let data = data.into_inner(); let Some(user) = User::find_by_mail(&data.email, &conn).await else { @@ -1756,6 +1760,8 @@ async fn get_auth_request_response( client_headers: ClientHeaders, conn: DbConn, ) -> JsonResult { + crate::ratelimit::check_limit_unauthenticated(&client_headers.ip.ip)?; + let Some(auth_request) = AuthRequest::find_by_uuid(&auth_request_id, &conn).await else { err!("AuthRequest doesn't exist", "User not found") }; diff --git a/src/api/identity.rs b/src/api/identity.rs index a2525d9b..7bd12a78 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -1056,13 +1056,13 @@ async fn json_err_twofactor( } #[post("/accounts/prelogin", data = "")] -async fn post_prelogin(data: Json, conn: DbConn) -> Json { - prelogin(data, conn).await +async fn post_prelogin(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { + prelogin(data, ip, conn).await } #[post("/accounts/prelogin/password", data = "")] -async fn prelogin_password(data: Json, conn: DbConn) -> Json { - prelogin(data, conn).await +async fn prelogin_password(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { + prelogin(data, ip, conn).await } #[post("/accounts/register", data = "")] From b7667e27bf3500a2446d39446b1a7b10b8b25991 Mon Sep 17 00:00:00 2001 From: niniconi <112842746+niniconi@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:42:58 +0800 Subject: [PATCH 7/7] fix: Correct invalid comment syntax in .dockerignore (#7274) Docker only recognizes `#` as a valid comment indicator in .dockerignore files. Using `//` causes the lines to be incorrectly parsed as glob patterns rather than comments. While this may not cause fatal errors if no matching files exist, it is syntactically invalid and could lead to unexpected behavior. Corrected the syntax to use `#`. --- .dockerignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index a9a358a3..d6ac6b9b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,7 @@ -// Ignore everything +# Ignore everything * -// Allow what is needed +# Allow what is needed !.git !docker/healthcheck.sh !docker/start.sh