From 4615f909eecdf5796ce948e940ac34ccf4d00fe8 Mon Sep 17 00:00:00 2001 From: Justin Troebinger Date: Sun, 1 Nov 2026 14:01:06 +0100 Subject: [PATCH] Add global master password policy setting Adds MASTER_PASSWORD_POLICY, an optional server-wide master password policy in the same JSON format as SSO_MASTER_PASSWORD_POLICY. It is merged with any organization policies (strictest wins) and returned during login and SSO enrollment, so the clients enforce it for all users, not only organization members or SSO users. --- .env.template | 5 +++++ src/api/core/organizations.rs | 19 +++++++++++++++---- src/api/mod.rs | 11 +++++++++-- src/config.rs | 28 +++++++++++++++++++++------- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.env.template b/.env.template index 92362088..c2bbcc5f 100644 --- a/.env.template +++ b/.env.template @@ -244,6 +244,11 @@ ## This setting applies globally to all users. # INCOMPLETE_2FA_TIME_LIMIT=3 +## Optional global master password policy, enforced for all users in addition to any +## organization policies. Uses the same JSON format as SSO_MASTER_PASSWORD_POLICY. +## Note that the Bitwarden clients do not allow a minimum length below 12 characters. +# MASTER_PASSWORD_POLICY='{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' + ## Disable icon downloading ## Set to true to disable icon downloading in the internal icon service. ## This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 3328423e..8bbab915 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1974,8 +1974,8 @@ async fn list_policies_token(org_id: OrganizationId, token: &str, conn: DbConn) // Called during the SSO enrollment return the default policy #[get("/organizations/00000000-01DC-01DC-01DC-000000000000/policies/master-password", rank = 1)] fn get_dummy_master_password_policy() -> JsonResult { - let (enabled, data) = match CONFIG.sso_master_password_policy_value() { - Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), + let (enabled, data) = match config_master_password_policy() { + Some(policy) => (true, policy.to_string()), _ => (false, "null".to_owned()), }; let policy = OrgPolicy::new(FAKE_SSO_IDENTIFIER.into(), OrgPolicyType::MasterPassword, enabled, data); @@ -1987,8 +1987,8 @@ fn get_dummy_master_password_policy() -> JsonResult { async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberHeaders, conn: DbConn) -> JsonResult { let policy = OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::MasterPassword, &conn).await.unwrap_or_else(|| { - let (enabled, data) = match CONFIG.sso_master_password_policy_value() { - Some(policy) if CONFIG.sso_enabled() => (true, policy.to_string()), + let (enabled, data) = match config_master_password_policy() { + Some(policy) => (true, policy.to_string()), _ => (false, "null".to_owned()), }; @@ -1998,6 +1998,17 @@ async fn get_master_password_policy(org_id: OrganizationId, _headers: OrgMemberH Ok(Json(policy.to_json())) } +// The global policy wins over the SSO one since it applies to all users +fn config_master_password_policy() -> Option { + CONFIG.master_password_policy_value().or_else(|| { + if CONFIG.sso_enabled() { + CONFIG.sso_master_password_policy_value() + } else { + None + } + }) +} + #[get("/organizations//policies/", rank = 3)] async fn get_policy(org_id: OrganizationId, pol_type: i32, headers: AdminHeaders, conn: DbConn) -> JsonResult { if org_id != headers.org_id { diff --git a/src/api/mod.rs b/src/api/mod.rs index 450c2113..fbbeda6b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -78,7 +78,7 @@ impl PasswordOrOtpData { #[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")] #[derive(Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", default)] pub struct MasterPasswordPolicy { min_complexity: Option, min_length: Option, @@ -91,7 +91,7 @@ pub struct MasterPasswordPolicy { // Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy async fn master_password_policy(user: &User, conn: &DbConn) -> Value { - let master_password_policies: Vec = + let mut master_password_policies: Vec = OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy( &user.uuid, OrgPolicyType::MasterPassword, @@ -102,6 +102,13 @@ async fn master_password_policy(user: &User, conn: &DbConn) -> Value { .filter_map(|p| serde_json::from_str(&p.data).ok()) .collect(); + // The global policy applies to all users, on top of whatever their organizations require + if let Some(global_policy) = CONFIG.master_password_policy_value() + && let Ok(global_policy) = serde_json::from_value::(global_policy) + { + master_password_policies.push(global_policy); + } + let mut mpp_json = if !master_password_policies.is_empty() { json!(master_password_policies.into_iter().reduce(|acc, policy| { MasterPasswordPolicy { diff --git a/src/config.rs b/src/config.rs index 55d08259..3ac8349f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -628,6 +628,11 @@ make_config! { /// This setting applies globally to all users. incomplete_2fa_time_limit: u16, true, def, 3; + /// Master password policy |> Optional global master password policy, enforced for all users + /// in addition to any organization policies. Uses the same JSON format as the SSO master + /// password policy. Ex format: '{"enforceOnLogin":false,"minComplexity":3,"minLength":12,"requireLower":false,"requireNumbers":false,"requireSpecial":false,"requireUpper":false}' + master_password_policy: String, true, option; + /// Disable icon downloads |> Set to true to disable icon downloading in the internal icon service. /// This still serves existing icons from $ICON_CACHE_FOLDER, without generating any external /// network requests. $ICON_CACHE_TTL must also be set to 0; otherwise, the existing icons @@ -1154,9 +1159,11 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_internal_sso_issuer_url(&cfg.sso_authority)?; validate_internal_sso_redirect_url(&cfg.sso_callback_path)?; - validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; + validate_master_password_policy("sso_master_password_policy", cfg.sso_master_password_policy.as_ref())?; } + validate_master_password_policy("master_password_policy", cfg.master_password_policy.as_ref())?; + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") @@ -1385,17 +1392,18 @@ fn validate_internal_sso_redirect_url(sso_callback_path: &String) -> Result, +fn validate_master_password_policy( + setting_name: &str, + master_password_policy: Option<&String>, ) -> Result, Error> { - let policy = sso_master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); + let policy = master_password_policy.as_ref().map(|mpp| serde_json::from_str::(mpp)); match policy { None => Ok(None), Some(Ok(jsobject @ serde_json::Value::Object(_))) => Ok(Some(jsobject)), - Some(Ok(_)) => err!("Invalid sso_master_password_policy: parsed value is not a JSON object"), + Some(Ok(_)) => err!(format!("Invalid {setting_name}: parsed value is not a JSON object")), Some(Err(error)) => { - err!(format!("Invalid sso_master_password_policy ({error}), Ensure that it's correctly escaped with ''")) + err!(format!("Invalid {setting_name} ({error}), Ensure that it's correctly escaped with ''")) } } } @@ -1767,7 +1775,13 @@ impl Config { } pub fn sso_master_password_policy_value(&self) -> Option { - validate_sso_master_password_policy(self.sso_master_password_policy().as_ref()).ok().flatten() + validate_master_password_policy("sso_master_password_policy", self.sso_master_password_policy().as_ref()) + .ok() + .flatten() + } + + pub fn master_password_policy_value(&self) -> Option { + validate_master_password_policy("master_password_policy", self.master_password_policy().as_ref()).ok().flatten() } pub fn sso_scopes_vec(&self) -> Vec {