From 42aa3ee1c77758086abe8cb16df7d9f222fe6885 Mon Sep 17 00:00:00 2001 From: Cassian433 Date: Tue, 6 Oct 2026 22:40:49 +0530 Subject: [PATCH 01/43] Close WebSocket connections that stop responding (#7807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both notification hubs send a Ping every 15 seconds but ignore the Pong, so the loop only ends on a Close frame, EOF or a socket error. When a client disappears without the TCP connection being closed, for example behind a reverse proxy or CDN that keeps the upstream connection open, none of these ever happens. The connection, its file descriptor and its map entry are then kept forever, until the process runs out of file descriptors and stops accepting connections and opening the database. Keep track of when something was last received from the client, Pongs included, and close the connection when nothing was received for 30 seconds. This is the default ClientTimeoutInterval of ASP.NET Core SignalR, which the official server uses. Fixes #7805 Co-authored-by: Cassian433 <262026629+Cassian433@users.noreply.github.com> Co-authored-by: Daniel García --- src/api/notifications.rs | 38 +++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 8bfcd518..32204916 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -1,7 +1,7 @@ use std::{ net::IpAddr, sync::{Arc, LazyLock}, - time::Duration, + time::{Duration, Instant}, }; use chrono::{NaiveDateTime, Utc}; @@ -41,6 +41,14 @@ pub static WS_ANONYMOUS_SUBSCRIPTIONS: LazyLock = LazyLock::new(|| !CONFIG.enable_websocket() && !CONFIG.push_enabled()); pub fn routes() -> Vec { @@ -156,12 +164,16 @@ fn websockets_hub<'r>( rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(Duration::from_secs(15)); + let mut interval = tokio::time::interval(WS_PING_INTERVAL); + let mut last_received = Instant::now(); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { + // Any message, including a Pong, means the client is still there + last_received = Instant::now(); + match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), @@ -195,7 +207,13 @@ fn websockets_hub<'r>( } } - _ = interval.tick() => yield Message::Ping(create_ping()) + _ = interval.tick() => { + // The client stopped responding without closing the connection, drop it + if last_received.elapsed() > WS_CLIENT_TIMEOUT { + break; + } + yield Message::Ping(create_ping()); + } } } }} @@ -229,12 +247,16 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R rocket_ws::Stream! { ws => { let mut ws = ws; let _guard = guard; - let mut interval = tokio::time::interval(Duration::from_secs(15)); + let mut interval = tokio::time::interval(WS_PING_INTERVAL); + let mut last_received = Instant::now(); loop { tokio::select! { res = ws.next() => { match res { Some(Ok(message)) => { + // Any message, including a Pong, means the client is still there + last_received = Instant::now(); + match message { // Respond to any pings Message::Ping(ping) => yield Message::Pong(ping), @@ -268,7 +290,13 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R } } - _ = interval.tick() => yield Message::Ping(create_ping()) + _ = interval.tick() => { + // The client stopped responding without closing the connection, drop it + if last_received.elapsed() > WS_CLIENT_TIMEOUT { + break; + } + yield Message::Ping(create_ping()); + } } } }} From 415df400f49d35a2999fa0292c8d244664f58b26 Mon Sep 17 00:00:00 2001 From: Iain Date: Tue, 6 Oct 2026 18:21:46 +0100 Subject: [PATCH 02/43] Validate the Duo and SSO purge schedules on startup (#7819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- src/config.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/config.rs b/src/config.rs index 7d35461c..bf6ea724 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1266,6 +1266,14 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`AUTH_REQUEST_PURGE_SCHEDULE` is not a valid cron expression") } + if !cfg.duo_context_purge_schedule.is_empty() && cfg.duo_context_purge_schedule.parse::().is_err() { + err!("`DUO_CONTEXT_PURGE_SCHEDULE` is not a valid cron expression") + } + + if !cfg.purge_incomplete_sso_auth.is_empty() && cfg.purge_incomplete_sso_auth.parse::().is_err() { + err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression") + } + if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { From de6d2066b32c913b27c63abc84b23542b2e5517e Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:25:51 +0800 Subject: [PATCH 03/43] docs: fix "runnning" typo in playwright README (#7787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: yuwk <1729065730@qq.com> Co-authored-by: Daniel García --- playwright/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/README.md b/playwright/README.md index 000725d7..4b35e040 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -62,7 +62,7 @@ DOCKER_BUILDKIT=1 docker compose --profile playwright --env-file test.env run Pl ### Keep services running -If you want you can keep the DB and Keycloak runnning (states are not impacted by the tests): +If you want you can keep the DB and Keycloak running (states are not impacted by the tests): ```bash PW_KEEP_SERVICE_RUNNING=true npx playwright test From 8b56926077aa3976403451d17b897ae00ce3957c Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Wed, 7 Oct 2026 01:26:09 +0800 Subject: [PATCH 04/43] docs: fix Playwright test env link (#7739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- playwright/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/playwright/README.md b/playwright/README.md index 4b35e040..cd18bebd 100644 --- a/playwright/README.md +++ b/playwright/README.md @@ -2,7 +2,7 @@ This allows running integration tests using [Playwright](https://playwright.dev/). \ -It usse its own [test.env](/test/scenarios/test.env) with different ports to not collide with a running dev instance. +It uses its own [test.env](./test.env) with different ports to not collide with a running dev instance. ## Install From c687cacb6b96f4d4ca24f22c5ce12417373f1f5d Mon Sep 17 00:00:00 2001 From: Jogius Date: Tue, 6 Oct 2026 19:29:58 +0200 Subject: [PATCH 05/43] Fix group import to update groups on data change (#7671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Daniel García --- src/api/core/public.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/api/core/public.rs b/src/api/core/public.rs index fbb1f855..1ee80515 100644 --- a/src/api/core/public.rs +++ b/src/api/core/public.rs @@ -137,9 +137,13 @@ async fn ldap_import(data: Json, token: PublicToken, conn: DbConn if CONFIG.org_groups_enabled() { for group_data in &data.groups { - let group_uuid = if let Some(group) = + let group_uuid = if let Some(mut group) = Group::find_by_external_id_and_org(&group_data.external_id, &org_id, &conn).await { + if group.name != group_data.name { + group.name = group_data.name.clone(); + group.save(&conn).await?; + } group.uuid } else { let mut group = From 8647af8f89a7d639efd79c339dabbc9215cf8dde Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:28:51 +0200 Subject: [PATCH 06/43] [Web 2026.9.0] Support new Emergency Access password payload (#7746) * Support new emergency access password payload * Apply PR 7747 review changes to emergency access * Add Playwright test for emergency access takeover --- playwright/tests/emergency.spec.ts | 131 ++++++++++++++++++ src/api/core/accounts.rs | 41 ++++-- src/api/core/emergency_access.rs | 34 ++++- src/db/models/user.rs | 4 + .../send_emergency_access_invite.html.hbs | 2 +- 5 files changed, 193 insertions(+), 19 deletions(-) create mode 100644 playwright/tests/emergency.spec.ts diff --git a/playwright/tests/emergency.spec.ts b/playwright/tests/emergency.spec.ts new file mode 100644 index 00000000..4c836986 --- /dev/null +++ b/playwright/tests/emergency.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, type Page, type TestInfo, Test } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; +import { activateTOTP } from './setups/2fa'; + +let users = utils.loadEnv(); +let mailserver; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } +}); + +async function emergencyAccess(test: Test, page: Page, user: { name: string }) { + await test.step('Navigate', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Emergency access' }).click(); + await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible(); + }); +} + +test('Emergency access', async ({ browser, page }) => { + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + const mailBuffer = mailserver.buffer(users.user1.email); + const mailBuffer2 = mailserver.buffer(users.user2.email); + + await createAccount(test, page, users.user1); + await createAccount(test, page2, users.user2); + + await test.step('Add test2', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Add emergency contact' }).click(); + await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email); + await page.getByRole('radio', { name: 'Takeover Can reset your' }).check(); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'User(s) invited'); + }); + + await test.step('Accept', async () => { + const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name); + const pageE = await context2.newPage(); + await pageE.setContent(email.html); + const link = await pageE.getByTestId("emergency").getAttribute("href"); + await pageE.close(); + + await page2.goto(link); + await utils.checkNotification(page2, 'Invitation accepted'); + }); + + await test.step('Confirm', async () => { + await emergencyAccess(test, page, users.user1); + await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible(); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await utils.checkNotification(page, users.user2.name + ' confirmed'); + await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed"); + }); + + await test.step('Request', async () => { + await emergencyAccess(test, page2, users.user2); + await page2.getByRole('button', { name: 'Options' }).click(); + await page2.getByRole('menuitem', { name: 'Request Access' }).click(); + await page2.getByRole('button', { name: 'Request Access' }).click(); + await utils.checkNotification(page2, 'Emergency access requested'); + await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated"); + }); + + await test.step('Approved', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Approve' }).click(); + await page.getByRole('button', { name: 'Approve' }).click(); + await utils.checkNotification(page, 'Emergency access approved'); + await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved"); + }); + await activateTOTP(test, page, users.user1); + + let newPassword = "TotoNewPassword"; + await test.step('Access', async () => { + await emergencyAccess(test, page2, users.user2); + await page2.getByRole('button', { name: 'Options' }).click(); + await page2.getByRole('menuitem', { name: 'Takeover' }).click(); + await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); + await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword); + await page2.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page2, 'Password reset for ' + users.user1.name); + }); + + await test.step('Changed no 2fa', async () => { + users.user1.password = newPassword; + await logUser(test, page, users.user1); + }); + + await test.step('Reject', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Reject' }).click(); + await utils.checkNotification(page, 'Emergency access rejected'); + await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected"); + }); + + await test.step('Remove', async () => { + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await utils.checkNotification(page, 'Removed user ' + users.user2.name); + await expect(page.getByText('You have not added any emergency contacts')).toBeVisible(); + }); +}); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index b3499a93..6f5c436b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -93,6 +93,15 @@ pub struct KDFData { kdf_parallelism: Option, } +impl KDFData { + pub(super) fn matches_user(&self, user: &User) -> bool { + self.kdf == user.client_kdf_type + && self.kdf_iterations == user.client_kdf_iter + && self.kdf_memory == user.client_kdf_memory + && self.kdf_parallelism == user.client_kdf_parallelism + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterData { @@ -717,21 +726,35 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct AuthenticationData { +pub(super) struct AuthenticationData { #[serde(alias = "Salt")] salt: String, #[serde(alias = "Kdf")] - kdf: KDFData, + pub(super) kdf: KDFData, #[serde(alias = "MasterPasswordAuthenticationHash")] - master_password_authentication_hash: String, + pub(super) master_password_authentication_hash: String, +} + +impl AuthenticationData { + pub(super) fn check(&self, user: &User, unlock: &UnlockData) -> EmptyResult { + if self.kdf != unlock.kdf { + err!("KDF settings must be equal for authentication and unlock") + } + + if self.salt != user.master_password_salt() || self.salt != unlock.salt { + err!("Invalid master password salt") + } + + Ok(()) + } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct UnlockData { +pub(super) struct UnlockData { salt: String, kdf: KDFData, - master_key_wrapped_user_key: String, + pub(super) master_key_wrapped_user_key: String, } #[derive(Deserialize)] @@ -750,13 +773,7 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid password") } - if data.authentication_data.kdf != data.unlock_data.kdf { - err!("KDF settings must be equal for authentication and unlock") - } - - if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt { - err!("Invalid master password salt") - } + data.authentication_data.check(&headers.user, &data.unlock_data)?; let mut user = headers.user; diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 6afc683c..5813ee69 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -6,7 +6,10 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, - core::{CipherSyncData, CipherSyncType}, + core::{ + CipherSyncData, CipherSyncType, + accounts::{AuthenticationData, UnlockData}, + }, }, auth::{Headers, decode_emergency_access_invite}, db::{ @@ -615,6 +618,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, "kdfMemory": grantor_user.client_kdf_memory, "kdfParallelism": grantor_user.client_kdf_parallelism, "keyEncrypted": &emergency_access.key_encrypted, + "salt": grantor_user.master_password_salt(), "object": "emergencyAccessTakeover", }); @@ -624,8 +628,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { - new_master_password_hash: String, - key: String, + // Legacy payload + new_master_password_hash: Option, + key: Option, + + // Current payload + authentication_data: Option, + unlock_data: Option, } #[post("/emergency-access//password", data = "")] @@ -638,8 +647,6 @@ async fn password_emergency_access( check_emergency_access_enabled()?; let data: EmergencyAccessPasswordData = data.into_inner(); - let new_master_password_hash = &data.new_master_password_hash; - //let key = &data.Key; let requesting_user = headers.user; let Some(emergency_access) = @@ -656,8 +663,23 @@ async fn password_emergency_access( err!("Grantor user not found.") }; + let (new_master_password_hash, new_key) = + if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) { + authentication_data.check(&grantor_user, &unlock_data)?; + + if !authentication_data.kdf.matches_user(&grantor_user) { + err!("KDF settings do not match the grantor account") + } + + (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) + } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { + (new_master_password_hash, new_key) + } else { + err!("Invalid request!") + }; + // change grantor_user password - grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; + grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; grantor_user.save(&conn).await?; // Disable TwoFactor providers since they will otherwise block logins diff --git a/src/db/models/user.rs b/src/db/models/user.rs index ead01373..6610c945 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -170,6 +170,10 @@ impl User { ) } + pub fn master_password_salt(&self) -> String { + self.email.trim().to_lowercase() + } + pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { if let Some(ref totp_recover) = self.totp_recover { crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) diff --git a/src/static/templates/email/send_emergency_access_invite.html.hbs b/src/static/templates/email/send_emergency_access_invite.html.hbs index 5318378c..b5ab3a07 100644 --- a/src/static/templates/email/send_emergency_access_invite.html.hbs +++ b/src/static/templates/email/send_emergency_access_invite.html.hbs @@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}} - Become emergency contact From b8089e31c2f35958021a32414411aecaddf3b24b Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:45:12 +0200 Subject: [PATCH 07/43] [Web 2026.9.0] Support new account recovery password payload (#7747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Support new account recovery password payload * Address account recovery review feedback * Check the account recovery password before sending the recovery email --------- Co-authored-by: Daniel García --- src/api/core/organizations.rs | 41 ++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 6464f774..e0124ef1 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -26,6 +26,8 @@ use crate::{ util::{NumberOrString, convert_json_key_lcase_first}, }; +use super::accounts::{AuthenticationData, UnlockData}; + pub fn routes() -> Vec { routes![ get_organization, @@ -2751,9 +2753,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct OrganizationUserRecoverAccountRequest { + // Legacy payload new_master_password_hash: Option, key: Option, + // Current payload + authentication_data: Option, + unlock_data: Option, + #[serde(default)] reset_master_password: bool, #[serde(default)] @@ -2838,6 +2845,29 @@ async fn recover_account( false }; + // Check the new password before the email below, so that a rejected request doesn't tell the user + // their password was reset + let new_password = if req.reset_master_password { + let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) = + (req.authentication_data, req.unlock_data) + { + authentication_data.check(&user, &unlock_data)?; + + if !authentication_data.kdf.matches_user(&user) { + err!("KDF settings do not match the user account") + } + + (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) + } else if let (Some(new_master_password_hash), Some(new_key)) = (req.new_master_password_hash, req.key) { + (new_master_password_hash, new_key) + } else { + err_code!("Unprocessable request", "Missing fields to reset password", Status::UnprocessableEntity.code); + }; + Some((new_master_password_hash, new_key)) + } else { + None + }; + // 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( @@ -2853,14 +2883,8 @@ async fn recover_account( err!(format!("Error sending user reset password email: {e:#?}")); } - 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 let Some((new_master_password_hash, new_key)) = new_password { + user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; } if req.reset_two_factor { @@ -2919,6 +2943,7 @@ async fn get_reset_password_details( "kdfIterations": user.client_kdf_iter, "kdfMemory": user.client_kdf_memory, "kdfParallelism": user.client_kdf_parallelism, + "masterPasswordSalt": user.master_password_salt(), "resetPasswordKey": member.reset_password_key, "encryptedPrivateKey": org.private_key, }))) From acbf49018f616e52e9310789cf861f6e3923c8be Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 6 Oct 2026 21:45:55 +0000 Subject: [PATCH 08/43] [web-v2026.7.1] 2FA userVerificationToken for Authenticator, Email and Duo (#7563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 2FA using userVerificationToken * Fix 2FA Yubikeys * TwoFactor.find_by_user_and_type take enum parameter not i32 * log_user_event take enum parameter not i32 * Fix 2fa webauthn * Prevent 2FA claims reuse * 2fa webauthn return keys when deleting * 2fa reset twofactor_remember * Reject 2FA verification tokens issued for another provider The Email and Duo claims only have optional fields, so a userVerificationToken minted for another provider deserialized into them and was accepted. Each provider's claims now reject unknown fields, with a test that every token only parses as its own provider. Also fix the email 2FA login in the Playwright user setup, which used an undefined `mailBuffer`. --------- Co-authored-by: Timshel Co-authored-by: Daniel García --- playwright/tests/login.spec.ts | 37 ++- playwright/tests/setups/2fa.ts | 19 +- playwright/tests/setups/user.ts | 18 +- src/api/core/accounts.rs | 12 +- src/api/core/events.rs | 4 +- src/api/core/organizations.rs | 2 +- src/api/core/two_factor/authenticator.rs | 68 ++--- src/api/core/two_factor/duo.rs | 125 ++++----- src/api/core/two_factor/email.rs | 102 +++---- src/api/core/two_factor/mod.rs | 59 +--- src/api/core/two_factor/protected_actions.rs | 5 +- src/api/core/two_factor/webauthn.rs | 183 +++++++----- src/api/core/two_factor/yubikey.rs | 98 ++++--- src/api/identity.rs | 11 +- src/api/mod.rs | 2 +- src/auth.rs | 3 + src/auth/two_factor.rs | 281 +++++++++++++++++++ src/db/models/two_factor.rs | 4 +- 18 files changed, 667 insertions(+), 366 deletions(-) create mode 100644 src/auth/two_factor.rs diff --git a/playwright/tests/login.spec.ts b/playwright/tests/login.spec.ts index 194976ea..bc229c43 100644 --- a/playwright/tests/login.spec.ts +++ b/playwright/tests/login.spec.ts @@ -3,7 +3,7 @@ import * as OTPAuth from "otpauth"; import * as utils from "../global-utils"; import { createAccount, logUser } from './setups/user'; -import { activateTOTP, disableTOTP } from './setups/2fa'; +import { activateTOTP, disableTOTP, recoveryCodes } from './setups/2fa'; let users = utils.loadEnv(); let totp; @@ -31,21 +31,42 @@ test('Authenticator 2fa', async ({ page }) => { await utils.logout(test, page, users.user1); - await test.step('login', async () => { - let timestamp = Date.now(); // Needed to use the next token - timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + await logUser(test, page, users.user1, { totp }); + await disableTOTP(test, page, users.user1); +}); + +test('Recovery codes', async ({ context, page }) => { + await logUser(test, page, users.user1); + + await activateTOTP(test, page, users.user1); + let recovery = await recoveryCodes(test, page, users.user1); + + await utils.logout(test, page, users.user1); + + await test.step('login', async () => { await page.getByLabel(/Email address/).fill(users.user1.email); await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - await page.getByLabel(/Verification code/).fill(totp.generate({timestamp})); - await page.getByRole('button', { name: 'Continue' }).click(); await expect(page).toHaveTitle(/Vaultwarden Web/); - }); - await disableTOTP(test, page, users.user1); + const newPagePromise = context.waitForEvent('page'); + await page.getByRole('button', { name: 'Use your recovery code' }).click(); + const newPage = await newPagePromise; + + const tabs = context.pages(); + await tabs[1].bringToFront(); + await expect(tabs[1].getByRole('heading', { name: 'Recover account two-step login' })).toBeVisible(); + await tabs[1].getByRole('textbox', { name: 'Email address * (required)' }).fill(users.user1.email); + await tabs[1].getByRole('textbox', { name: 'Master password * (required)' }).fill(users.user1.password); + await tabs[1].getByRole('textbox', { name: 'Recovery code * (required)' }).fill(recovery); + await tabs[1].getByRole('button', { name: 'Submit' }).click(); + + await expect(tabs[1]).toHaveTitle(/Two-step login/); + }); }); + diff --git a/playwright/tests/setups/2fa.ts b/playwright/tests/setups/2fa.ts index d430d053..0b1711a1 100644 --- a/playwright/tests/setups/2fa.ts +++ b/playwright/tests/setups/2fa.ts @@ -4,6 +4,24 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; +export async function recoveryCodes(test: Test, page: Page, user: { name: string, password: string }): string { + return await test.step('Recovery code', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Security' }).click(); + await page.getByRole('link', { name: 'Two-step login' }).click(); + + await page.getByRole('button', { name: 'View recovery code' }).click(); + await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Continue' }).click(); + + const recovery = await page.getByRole('code').innerText(); + await page.getByLabel('Close').click(); + + return recovery; + }) +} + export async function activateTOTP(test: Test, page: Page, user: { name: string, password: string }): OTPAuth.TOTP { return await test.step('Activate TOTP 2FA', async () => { await page.getByRole('button', { name: user.name }).click(); @@ -21,7 +39,6 @@ export async function activateTOTP(test: Test, page: Page, user: { name: string, await page.getByLabel(/Verification code/).fill(totp.generate()); await page.getByRole('button', { name: 'Turn on' }).click(); await page.getByRole('heading', { name: 'Turned on', exact: true }); - await page.getByLabel('Close').click(); return totp; }) diff --git a/playwright/tests/setups/user.ts b/playwright/tests/setups/user.ts index 3d3990e9..11b39763 100644 --- a/playwright/tests/setups/user.ts +++ b/playwright/tests/setups/user.ts @@ -2,6 +2,7 @@ import { expect, type Browser, Page } from '@playwright/test'; import { type MailBuffer } from 'maildev'; +import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { retrieveEmailCode } from './2fa'; @@ -43,6 +44,7 @@ export async function logUser( mailBuffer ?: MailBuffer, mail2fa?: boolean, notNewDevice?: boolean, + totp?: OTPAuth.TOTP, } = {} ) { await test.step(`Log user ${user.email}`, async () => { @@ -55,11 +57,23 @@ export async function logUser( await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(user.password); await page.getByRole('button', { name: 'Log in', exact: true }).click(); - if( options.mail2fa ){ + if( options.mail2fa || options.totp ){ + let code; + await test.step('2FA check', async () => { await expect(page.getByRole('heading', { name: 'Verify your Identity' })).toBeVisible(); - let code = await retrieveEmailCode(test, page, options.mailBuffer); + + if( options.totp ) { + const totp = options.totp; + let timestamp = Date.now(); // Needed to use the next token + timestamp = timestamp + (totp.period - (Math.floor(timestamp / 1000) % totp.period) + 1) * 1000; + code = totp.generate({timestamp}); + } else if( options.mail2fa ){ + code = await retrieveEmailCode(test, page, options.mailBuffer); + } + await page.getByLabel(/Verification code/).fill(code); + await page.getByRole('button', { name: 'Continue' }).click(); }); } diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 6f5c436b..dd2e5801 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -493,8 +493,7 @@ async fn post_set_password(data: Json, headers: Headers, conn: Membership::accept_user_invitations(&user.uuid, &conn).await?; } - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; user.save(&conn).await?; @@ -640,8 +639,7 @@ async fn post_password(data: Json, headers: Headers, conn: DbCon err!("Invalid password") } - log_user_event(EventType::UserChangedPassword as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn) - .await; + log_user_event(EventType::UserChangedPassword, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let (new_master_password_hash, new_key) = if let (Some(unlock_data), Some(authentication_data)) = (data.unlock_data, data.authentication_data) { @@ -1696,7 +1694,7 @@ async fn post_auth_request( nt.send_auth_request(&user.uuid, &auth_request.uuid, &device, &conn).await; log_user_event( - EventType::UserRequestedDeviceApproval as i32, + EventType::UserRequestedDeviceApproval, &user.uuid, client_headers.device_type, &client_headers.ip.ip, @@ -1790,7 +1788,7 @@ async fn put_auth_request( nt.send_auth_response(&auth_request.user_uuid, &auth_request.uuid, &headers.device, &conn).await; log_user_event( - EventType::OrganizationUserApprovedAuthRequest as i32, + EventType::OrganizationUserApprovedAuthRequest, &headers.user.uuid, headers.device.atype, &headers.ip.ip, @@ -1801,7 +1799,7 @@ async fn put_auth_request( // If denied, there's no reason to keep the request auth_request.delete(&conn).await?; log_user_event( - EventType::OrganizationUserRejectedAuthRequest as i32, + EventType::OrganizationUserRejectedAuthRequest, &headers.user.uuid, headers.device.atype, &headers.ip.ip, diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 80906e40..15ec11a0 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -242,11 +242,11 @@ async fn post_events_collect(data: Json>, headers: Headers, Ok(()) } -pub async fn log_user_event(event_type: i32, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { +pub async fn log_user_event(event_type: EventType, user_id: &UserId, device_type: i32, ip: &IpAddr, conn: &DbConn) { if !CONFIG.org_events_enabled() { return; } - log_user_event_impl(event_type, user_id, device_type, None, ip, conn).await; + log_user_event_impl(event_type as i32, user_id, device_type, None, ip, conn).await; } async fn log_user_event_impl( diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index e0124ef1..6fccc850 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2840,7 +2840,7 @@ async fn recover_account( let fallback_2fa_email = if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() { - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await.is_none() + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none() } else { false }; diff --git a/src/api/core/two_factor/authenticator.rs b/src/api/core/two_factor/authenticator.rs index 99b5c67c..55984443 100644 --- a/src/api/core/two_factor/authenticator.rs +++ b/src/api/core/two_factor/authenticator.rs @@ -3,11 +3,11 @@ use rocket::{Route, serde::json::Json}; use crate::{ api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, core::two_factor::generate_recover_code}, - auth::{ClientIp, Headers}, + auth::{ClientIp, Headers, two_factor}, crypto, db::{ DbConn, - models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType, UserId}, }, util::NumberOrString, }; @@ -20,27 +20,23 @@ pub fn routes() -> Vec { #[post("/two-factor/get-authenticator", data = "")] async fn generate_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, false, &conn).await?; - let type_ = TwoFactorType::Authenticator as i32; - let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await; + let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await; let (enabled, key) = match twofactor { Some(tf) => (true, tf.data), _ => (false, crypto::encode_random_bytes::<20>(&BASE32)), }; - // Upstream seems to also return `userVerificationToken`, but doesn't seem to be used at all. - // It should help prevent TOTP disclosure if someone keeps their vault unlocked. - // Since it doesn't seem to be used, and also does not cause any issues, lets leave it out of the response. - // See: https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Auth/Controllers/TwoFactorController.cs#L94 Ok(Json(json!({ - "enabled": enabled, - "key": key, - "object": "twoFactorAuthenticator" + "authenticator": json!({ + "enabled": enabled, + "key": key, + }), + "userVerificationToken": two_factor::authenticator_token(user.uuid, key, enabled), }))) } @@ -49,8 +45,7 @@ async fn generate_authenticator(data: Json, headers: Headers, struct EnableAuthenticatorData { key: String, token: NumberOrString, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[post("/two-factor/authenticator", data = "")] @@ -61,12 +56,7 @@ async fn activate_authenticator(data: Json, headers: He let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &key, false)?; // Validate key as base32 and 20 bytes length let decoded_key: Vec = if let Ok(decoded) = BASE32.decode(key.as_bytes()) { @@ -84,12 +74,13 @@ async fn activate_authenticator(data: Json, headers: He generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "enabled": true, - "key": key, - "object": "twoFactorAuthenticator" + "authenticator": json!({ + "enabled": true, + "key": key, + }), }))) } @@ -125,8 +116,7 @@ pub async fn validate_totp_code( err!("Invalid TOTP secret") }; - let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator as i32, conn).await - { + let mut twofactor = match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Authenticator, conn).await { Some(tf) => tf, _ => TwoFactor::new(user_id.clone(), TwoFactorType::Authenticator, secret.to_owned()), }; @@ -184,37 +174,25 @@ pub async fn validate_totp_code( #[serde(rename_all = "camelCase")] struct DisableAuthenticatorData { key: String, - master_password_hash: String, - r#type: NumberOrString, + user_verification_token: String, } #[delete("/two-factor/authenticator", data = "")] -async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> JsonResult { +async fn disable_authenticator(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; - let type_ = data.r#type.into_i32()?; - if !user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } + two_factor::validate_authenticator(&data.user_verification_token, &user.uuid, &data.key, true)?; - if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Authenticator, &conn).await { if twofactor.data == data.key { 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; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; } else { err!(format!("TOTP key for user {} does not match recorded value, cannot deactivate", &user.email)); } } - if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { - super::enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; - } + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; - Ok(Json(json!({ - "enabled": false, - "keys": type_, - "object": "twoFactorProvider" - }))) + Ok(()) } diff --git a/src/api/core/two_factor/duo.rs b/src/api/core/two_factor/duo.rs index 860979ff..23116b2c 100644 --- a/src/api/core/two_factor/duo.rs +++ b/src/api/core/two_factor/duo.rs @@ -2,12 +2,12 @@ use chrono::Utc; use rocket::{Route, serde::json::Json}; use crate::{ - CONFIG, api::{ - ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, core::log_user_event, - core::two_factor::generate_recover_code, + ApiResult, EmptyResult, JsonResult, PasswordOrOtpData, + core::log_user_event, + core::two_factor::{VerificationTokenData, generate_recover_code}, }, - auth::Headers, + auth::{Headers, two_factor, two_factor::DuoData}, crypto, db::{ DbConn, @@ -18,55 +18,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_duo, activate_duo, activate_duo_put,] -} - -#[derive(Serialize, Deserialize)] -struct DuoData { - host: String, // Duo API hostname - ik: String, // client id - sk: String, // client secret -} - -impl DuoData { - fn global() -> Option { - match (CONFIG._enable_duo(), CONFIG.duo_host()) { - (true, Some(host)) => Some(Self { - host, - ik: CONFIG.duo_ikey().unwrap(), - sk: CONFIG.duo_skey().unwrap(), - }), - _ => None, - } - } - fn msg(s: &str) -> Self { - Self { - host: s.into(), - ik: s.into(), - sk: s.into(), - } - } - fn secret() -> Self { - Self::msg("") - } - fn obscure(self) -> Self { - let mut host = self.host; - let mut ik = self.ik; - let mut sk = self.sk; - - let digits = 4; - let replaced = "************"; - - host.replace_range(digits.., replaced); - ik.replace_range(digits.., replaced); - sk.replace_range(digits.., replaced); - - Self { - host, - ik, - sk, - } - } + routes![get_duo, activate_duo, activate_duo_put, disable_duo,] } enum DuoStatus { @@ -95,22 +47,19 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) data.validate(&user, false, &conn).await?; - let data = get_user_duo_data(&user.uuid, &conn).await; - - let (enabled, data) = match data { + let (enabled, duo) = match get_user_duo_data(&user.uuid, &conn).await { DuoStatus::Global(_) => (true, Some(DuoData::secret())), DuoStatus::User(data) => (true, Some(data.obscure())), DuoStatus::Disabled(true) => (false, Some(DuoData::msg(DISABLED_MESSAGE_DEFAULT))), DuoStatus::Disabled(false) => (false, None), }; - let json = if let Some(data) = data { + let duo_json = if let Some(data) = duo.as_ref() { json!({ "enabled": enabled, "host": data.host, "clientSecret": data.sk, "clientId": data.ik, - "object": "twoFactorDuo" }) } else { json!({ @@ -118,11 +67,13 @@ async fn get_duo(data: Json, headers: Headers, conn: DbConn) "host": null, "clientSecret": null, "clientId": null, - "object": "twoFactorDuo" }) }; - Ok(Json(json)) + Ok(Json(rocket::serde::json::json!({ + "duo": duo_json, + "userVerificationToken": two_factor::duo_token(user.uuid, duo, enabled), + }))) } #[derive(Deserialize)] @@ -131,8 +82,7 @@ struct EnableDuoData { host: String, client_secret: String, client_id: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } impl From for DuoData { @@ -159,12 +109,7 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) let data: EnableDuoData = data.into_inner(); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash.clone(), - otp: data.otp.clone(), - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_duo(&data.user_verification_token, &user.uuid, None, false)?; let (data, data_str) = if check_duo_fields_custom(&data) { let data_req: DuoData = data.into(); @@ -181,14 +126,15 @@ async fn activate_duo(data: Json, headers: Headers, conn: DbConn) generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; Ok(Json(json!({ - "enabled": true, - "host": data.host, - "clientSecret": data.sk, - "clientId": data.ik, - "object": "twoFactorDuo" + "duo": json!({ + "enabled": true, + "host": data.host, + "clientSecret": data.sk, + "clientId": data.ik, + }), }))) } @@ -197,6 +143,29 @@ async fn activate_duo_put(data: Json, headers: Headers, conn: DbC activate_duo(data, headers, conn).await } +#[delete("/two-factor/duo", data = "")] +async fn disable_duo(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Duo, &conn).await { + // Apply the same transformation than in `get_duo` to check we are disabling the correct one + let duo = match to_user_duo_data(&twofactor) { + DuoStatus::Global(_) => Some(DuoData::secret()), + DuoStatus::User(data) => Some(data.obscure()), + DuoStatus::Disabled(_) => None, + }; + + two_factor::validate_duo(&data.user_verification_token, &user.uuid, duo.as_ref(), true)?; + + twofactor.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + } + + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + + Ok(()) +} + async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) -> EmptyResult { use reqwest::{Method, header}; use std::str::FromStr; @@ -222,13 +191,15 @@ async fn duo_api_request(method: &str, path: &str, params: &str, data: &DuoData) } async fn get_user_duo_data(user_id: &UserId, conn: &DbConn) -> DuoStatus { - let type_ = TwoFactorType::Duo as i32; - // If the user doesn't have an entry, disabled - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Duo, conn).await else { return DuoStatus::Disabled(DuoData::global().is_some()); }; + to_user_duo_data(&twofactor) +} + +fn to_user_duo_data(twofactor: &TwoFactor) -> DuoStatus { // If the user has the required values, we use those if let Ok(data) = serde_json::from_str(&twofactor.data) { return DuoStatus::User(data); diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 836d1c47..2b0ee785 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -5,9 +5,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::{ClientHeaders, Headers}, + auth::{ClientHeaders, Headers, two_factor}, crypto, db::{ DbConn, @@ -18,7 +21,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![get_email, send_email_login, send_email, email,] + routes![get_email, send_email_login, send_email, email, disable_email] } #[derive(Deserialize)] @@ -116,8 +119,8 @@ async fn send_email_login(data: Json, client_headers: Client /// Generate the token, save the data for later verification and send email to user pub async fn send_token(user_id: &UserId, conn: &DbConn) -> EmptyResult { - let type_ = TwoFactorType::Email as i32; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, type_, conn).await.map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; let generated_token = crypto::generate_email_token(CONFIG.email_token_size()); @@ -140,18 +143,19 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn data.validate(&user, false, &conn).await?; let (enabled, mfa_email) = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email as i32, &conn).await { - Some(x) => { - let twofactor_data = EmailTokenData::from_json(&x.data)?; - (true, json!(twofactor_data.email)) - } - _ => (false, serde_json::value::Value::Null), + if let Some(x) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { + let twofactor_data = EmailTokenData::from_json(&x.data)?; + (true, Some(twofactor_data.email)) + } else { + (false, None) }; - Ok(Json(json!({ - "email": mfa_email, - "enabled": enabled, - "object": "twoFactorEmail" + Ok(Json(rocket::serde::json::json!({ + "email": rocket::serde::json::json!({ + "enabled": enabled, + "email": mfa_email, + }), + "userVerificationToken": two_factor::email_token(user.uuid, mfa_email, enabled), }))) } @@ -160,30 +164,22 @@ async fn get_email(data: Json, headers: Headers, conn: DbConn struct SendEmailData { /// Email where 2FA codes will be sent to, can be different than user email account. email: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } /// Send a verification email to the specified email address to check whether it exists/belongs to user. #[post("/two-factor/send-email", data = "")] -async fn send_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn send_email(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: SendEmailData = data.into_inner(); let user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, false, &conn) - .await?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email.clone(), false)?; if !CONFIG._enable_email_2fa() { err!("Email 2FA is disabled") } - let type_ = TwoFactorType::Email as i32; - - if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { tf.delete(&conn).await?; } @@ -191,12 +187,13 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - let twofactor_data = EmailTokenData::new(data.email, Some(generated_token)); // Uses EmailVerificationChallenge as type to show that it's not verified yet. - let twofactor = TwoFactor::new(user.uuid, TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); + let twofactor = + TwoFactor::new(user.uuid.clone(), TwoFactorType::EmailVerificationChallenge, twofactor_data.to_json()); twofactor.save(&conn).await?; mail::send_token(&twofactor_data.email, &twofactor_data.last_token.map_res("Token is empty")?).await?; - Ok(()) + Ok(Json(json!({}))) } #[derive(Deserialize, Serialize)] @@ -204,8 +201,7 @@ async fn send_email(data: Json, headers: Headers, conn: DbConn) - struct EmailData { email: String, token: String, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } /// Verify email belongs to user and can be used for 2FA email codes. @@ -214,17 +210,11 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes let data: EmailData = data.into_inner(); let mut user = headers.user; - // This is the last step in the verification process, delete the otp directly afterwards - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, data.email, false)?; - let type_ = TwoFactorType::EmailVerificationChallenge as i32; - let mut twofactor = - TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await.map_res("Two factor not found")?; + let mut twofactor = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::EmailVerificationChallenge, &conn) + .await + .map_res("Two factor not found")?; let mut email_data = EmailTokenData::from_json(&twofactor.data)?; @@ -243,13 +233,26 @@ async fn email(data: Json, headers: Headers, conn: DbConn) -> JsonRes generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - Ok(Json(json!({ - "email": email_data.email, - "enabled": "true", - "object": "twoFactorEmail" - }))) + Ok(Json(json!({}))) +} + +#[delete("/two-factor/email", data = "")] +async fn disable_email(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await { + let twofactor_data = EmailTokenData::from_json(&twofactor.data)?; + two_factor::validate_email(&data.user_verification_token, &user.uuid, twofactor_data.email, true)?; + + twofactor.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + } + + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + + Ok(()) } /// Validate the email code when used as TwoFactor token mechanism @@ -261,9 +264,8 @@ pub async fn validate_email_code_str( conn: &DbConn, ) -> EmptyResult { let mut email_data = EmailTokenData::from_json(data)?; - let mut twofactor = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email as i32, conn) - .await - .map_res("Two factor not found")?; + let mut twofactor = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Email, conn).await.map_res("Two factor not found")?; let Some(issued_token) = &email_data.last_token else { err!( format!("No token available! IP: {ip}"), diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 14bdafd5..61cb05df 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -7,10 +7,7 @@ use serde_json::Value; use crate::{ CONFIG, - api::{ - EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_event, log_user_event}, - }, + api::{EmptyResult, JsonResult, PasswordOrOtpData, core::log_event}, auth::Headers, crypto, db::{ @@ -21,7 +18,6 @@ use crate::{ }, }, mail, - util::NumberOrString, }; pub mod authenticator; @@ -69,7 +65,7 @@ pub fn is_twofactor_provider_usable(provider_type: &TwoFactorType, provider_data } pub fn routes() -> Vec { - let mut routes = routes![get_twofactor, get_recover, disable_twofactor, get_device_verification_settings]; + let mut routes = routes![get_twofactor, get_recover, get_device_verification_settings]; routes.append(&mut authenticator::routes()); routes.append(&mut duo::routes()); @@ -81,6 +77,12 @@ pub fn routes() -> Vec { routes } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct VerificationTokenData { + user_verification_token: String, +} + #[get("/two-factor")] async fn get_twofactor(headers: Headers, conn: DbConn) -> Json { let twofactors = TwoFactor::find_by_user(&headers.user.uuid, &conn).await; @@ -120,45 +122,14 @@ async fn generate_recover_code(user: &mut User, conn: &DbConn) { } } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DisableTwoFactorData { - master_password_hash: Option, - otp: Option, - r#type: NumberOrString, -} - -#[put("/two-factor/disable", data = "")] -async fn disable_twofactor(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: DisableTwoFactorData = data.into_inner(); - let user = headers.user; - - // Delete directly after a valid token has been provided - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, +/// Call after a 2FA provider, or one of its keys, was removed. No remembered device may keep skipping +/// the providers that are left, and once every provider is gone the 2FA policy applies. +pub async fn check_2fa_state(user: &User, device_type: i32, ip: &std::net::IpAddr, conn: &DbConn) -> EmptyResult { + Device::clear_twofactor_remember_by_user(&user.uuid, conn).await?; + if TwoFactor::find_by_user(&user.uuid, conn).await.is_empty() { + enforce_2fa_policy(user, &user.uuid, device_type, ip, conn).await?; } - .validate(&user, true, &conn) - .await?; - - let type_ = data.r#type.into_i32()?; - - 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; - } - - if TwoFactor::find_by_user(&user.uuid, &conn).await.is_empty() { - enforce_2fa_policy(&user, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await?; - } - - Ok(Json(json!({ - "enabled": false, - "type": type_, - "object": "twoFactorProvider" - }))) + Ok(()) } pub async fn enforce_2fa_policy( diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index c0c1b5e8..1cafede0 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -72,8 +72,7 @@ async fn request_otp(headers: Headers, conn: DbConn) -> EmptyResult { let user = headers.user; // Only one Protected Action per user is allowed to take place, delete the previous one - if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions as i32, &conn).await - { + if let Some(pa) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::ProtectedActions, &conn).await { let pa_data = ProtectedActionData::from_json(&pa.data)?; let elapsed = pa_data.time_since_sent().num_seconds(); let delay = 30; @@ -125,7 +124,7 @@ pub async fn validate_protected_action_otp( delete_if_valid: bool, conn: &DbConn, ) -> EmptyResult { - let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions as i32, conn) + let mut pa = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::ProtectedActions, conn) .await .map_res("Protected action token not found, try sending the code again or restart the process")?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?; diff --git a/src/api/core/two_factor/webauthn.rs b/src/api/core/two_factor/webauthn.rs index a3a9b21b..7eab5546 100644 --- a/src/api/core/two_factor/webauthn.rs +++ b/src/api/core/two_factor/webauthn.rs @@ -1,4 +1,4 @@ -use std::{str::FromStr, sync::LazyLock, time::Duration}; +use std::{collections::HashSet, str::FromStr, sync::LazyLock, time::Duration}; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -18,16 +18,18 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::Headers, + auth::{Headers, two_factor}, crypto::ct_eq, db::{ DbConn, - models::{Device, EventType, TwoFactor, TwoFactorType, UserId}, + models::{EventType, TwoFactor, TwoFactorType, UserId}, }, error::Error, - util::NumberOrString, }; static WEBAUTHN: LazyLock = LazyLock::new(|| { @@ -45,7 +47,14 @@ static WEBAUTHN: LazyLock = LazyLock::new(|| { }); pub fn routes() -> Vec { - routes![get_webauthn, generate_webauthn_challenge, activate_webauthn, activate_webauthn_put, delete_webauthn,] + routes![ + get_webauthn, + generate_webauthn_challenge, + activate_webauthn, + activate_webauthn_put, + delete_webauthn, + delete_webauthns + ] } // Some old u2f structs still needed for migrating from u2f to WebAuthn @@ -119,34 +128,36 @@ async fn get_webauthn(data: Json, headers: Headers, conn: DbC data.validate(&user, false, &conn).await?; let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); let registrations_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "enabled": enabled, - "keys": registrations_json, - "object": "twoFactorWebAuthn" + "webAuthn": json!({ + "enabled": enabled, + "keys": registrations_json, + }), + "userVerificationToken": two_factor::webauthn_token(user.uuid, keys, enabled), }))) } #[post("/two-factor/get-webauthn-challenge", data = "")] -async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let data: PasswordOrOtpData = data.into_inner(); +async fn generate_webauthn_challenge(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let user = headers.user; - data.validate(&user, false, &conn).await?; - - let registrations = get_webauthn_registrations(&user.uuid, &conn) - .await? - .1 + let (enabled, registrations) = get_webauthn_registrations(&user.uuid, &conn).await?; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); + let creds = registrations .into_iter() .map(|r| r.credential.cred_id().to_owned()) // We return the credentialIds to the clients to avoid double registering .collect(); + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, enabled)?; + let (mut challenge, state) = WEBAUTHN.start_passkey_registration( Uuid::from_str(&user.uuid).expect("Failed to parse UUID"), // Should never fail &user.email, user.display_name(), - Some(registrations), + Some(creds), )?; let mut state = serde_json::to_value(&state)?; @@ -166,17 +177,19 @@ async fn generate_webauthn_challenge(data: Json, headers: Hea let mut challenge_value = serde_json::to_value(challenge.public_key)?; challenge_value["status"] = "ok".into(); challenge_value["errorMessage"] = "".into(); - Ok(Json(challenge_value)) + + Ok(Json(json!({ + "options": challenge_value + }))) } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct EnableWebauthnData { - id: NumberOrString, // 1..5 + id: i32, name: String, device_response: RegisterPublicKeyCredentialCopy, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[derive(Debug, Deserialize)] @@ -257,16 +270,14 @@ async fn activate_webauthn(data: Json, headers: Headers, con let data: EnableWebauthnData = data.into_inner(); let mut user = headers.user; - PasswordOrOtpData { - master_password_hash: data.master_password_hash, - otp: data.otp, - } - .validate(&user, true, &conn) - .await?; + let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; + let keys: Vec = registrations.iter().map(|r| r.id).collect(); + two_factor::validate_webauthn(&data.user_verification_token, &user.uuid, &keys, !keys.is_empty())?; // Retrieve and delete the saved challenge state - let type_ = TwoFactorType::WebauthnRegisterChallenge as i32; - let state = if let Some(tf) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn).await { + let state = if let Some(tf) = + TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::WebauthnRegisterChallenge, &conn).await + { let state: PasskeyRegistration = serde_json::from_str(&tf.data)?; tf.delete(&conn).await?; state @@ -277,10 +288,9 @@ async fn activate_webauthn(data: Json, headers: Headers, con // Verify the credentials with the saved state let credential = WEBAUTHN.finish_passkey_registration(&data.device_response.into(), &state)?; - let mut registrations: Vec<_> = get_webauthn_registrations(&user.uuid, &conn).await?.1; // TODO: Check for repeated ID's registrations.push(WebauthnRegistration { - id: data.id.into_i32()?, + id: data.id, name: data.name, migrated: false, @@ -293,13 +303,13 @@ async fn activate_webauthn(data: Json, headers: Headers, con .await?; generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; - let keys_json: Vec = registrations.iter().map(WebauthnRegistration::to_json).collect(); Ok(Json(json!({ - "enabled": true, - "keys": keys_json, - "object": "twoFactorU2f" + "webAuthn": json!({ + "enabled": true, + "keys": registrations.iter().map(WebauthnRegistration::to_json).collect::>(), + }), }))) } @@ -310,58 +320,87 @@ async fn activate_webauthn_put(data: Json, headers: Headers, #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -struct DeleteU2FData { - id: NumberOrString, - master_password_hash: String, +struct DeleteWebauthnData { + id: i32, + user_verification_token: String, } #[delete("/two-factor/webauthn", data = "")] -async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { - let id = data.id.into_i32()?; - if !headers.user.check_valid_password(&data.master_password_hash) { - err!("Invalid password"); - } +async fn delete_webauthn(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + inner_delete_webauthns(&data.user_verification_token, |key| key.id != data.id, headers, &conn).await +} - let Some(mut tf) = - TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::Webauthn as i32, &conn).await - else { +#[delete("/two-factor/webauthn/all", data = "")] +async fn delete_webauthns(data: Json, headers: Headers, conn: DbConn) -> JsonResult { + inner_delete_webauthns(&data.user_verification_token, |_| false, headers, &conn).await +} + +async fn inner_delete_webauthns( + token: &str, + retain: impl Fn(&WebauthnRegistration) -> bool, + headers: Headers, + conn: &DbConn, +) -> JsonResult { + let user = headers.user; + + let Some(mut tf) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Webauthn, conn).await else { err!("Webauthn data not found!") }; - let mut data: Vec = serde_json::from_str(&tf.data)?; + let mut keys: Vec = serde_json::from_str(&tf.data)?; + let keys_id: Vec = keys.iter().map(|r| r.id).collect(); - let Some(item_pos) = data.iter().position(|r| r.id == id) else { + two_factor::validate_webauthn(token, &user.uuid, &keys_id, true)?; + + let mut removed: HashSet> = HashSet::new(); + let mut migrated = false; + + keys.retain(|key| { + let retained = retain(key); + if !retained { + removed.insert(key.credential.cred_id().to_vec()); + migrated = migrated || key.migrated; + } + retained + }); + + if removed.is_empty() { err!("Webauthn entry not found") - }; + } - let removed_item = data.remove(item_pos); - tf.data = serde_json::to_string(&data)?; - tf.save(&conn).await?; - drop(tf); - Device::clear_twofactor_remember_by_user(&headers.user.uuid, &conn).await?; + if keys.is_empty() { + tf.delete(conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, conn).await; + } else { + tf.data = serde_json::to_string(&keys)?; + tf.save(conn).await?; + drop(tf); + } // If entry is migrated from u2f, delete the u2f entry as well - if let Some(mut u2f) = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2f as i32, &conn).await - { - let mut data: Vec = if let Ok(d) = serde_json::from_str(&u2f.data) { - d - } else { + if migrated && let Some(mut u2f) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::U2f, conn).await { + let Ok(mut data) = serde_json::from_str::>(&u2f.data) else { err!("Error parsing U2F data") }; - data.retain(|r| r.reg.key_handle != removed_item.credential.cred_id().as_slice()); - let new_data_str = serde_json::to_string(&data)?; + data.retain(|old| !removed.contains(&old.reg.key_handle)); - u2f.data = new_data_str; - u2f.save(&conn).await?; + if data.is_empty() { + u2f.delete(conn).await?; + } else { + let new_data_str = serde_json::to_string(&data)?; + u2f.data = new_data_str; + u2f.save(conn).await?; + } } - let keys_json: Vec = data.iter().map(WebauthnRegistration::to_json).collect(); + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, conn).await?; Ok(Json(json!({ - "enabled": true, - "keys": keys_json, - "object": "twoFactorU2f" + "webAuthn": json!({ + "enabled": !keys.is_empty(), + "keys": keys.iter().map(WebauthnRegistration::to_json).collect::>(), + }), }))) } @@ -369,8 +408,7 @@ pub async fn get_webauthn_registrations( user_id: &UserId, conn: &DbConn, ) -> Result<(bool, Vec), Error> { - let type_ = TwoFactorType::Webauthn as i32; - match TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + match TwoFactor::find_by_user_and_type(user_id, TwoFactorType::Webauthn, conn).await { Some(tf) => Ok((tf.enabled, serde_json::from_str(&tf.data)?)), None => Ok((false, Vec::new())), // If no data, return empty list } @@ -417,8 +455,9 @@ pub async fn generate_webauthn_login(user_id: &UserId, conn: &DbConn) -> JsonRes } pub async fn validate_webauthn_login(user_id: &UserId, response: &str, conn: &DbConn) -> EmptyResult { - let type_ = TwoFactorType::WebauthnLoginChallenge as i32; - let mut state = if let Some(tf) = TwoFactor::find_by_user_and_type(user_id, type_, conn).await { + let mut state = if let Some(tf) = + TwoFactor::find_by_user_and_type(user_id, TwoFactorType::WebauthnLoginChallenge, conn).await + { let state: PasskeyAuthentication = serde_json::from_str(&tf.data)?; tf.delete(conn).await?; state diff --git a/src/api/core/two_factor/yubikey.rs b/src/api/core/two_factor/yubikey.rs index 54f0adb5..17c4e9a0 100644 --- a/src/api/core/two_factor/yubikey.rs +++ b/src/api/core/two_factor/yubikey.rs @@ -10,9 +10,12 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, PasswordOrOtpData, - core::{log_user_event, two_factor::generate_recover_code}, + core::{ + log_user_event, + two_factor::{VerificationTokenData, generate_recover_code}, + }, }, - auth::Headers, + auth::{Headers, two_factor}, db::{ DbConn, models::{EventType, TwoFactor, TwoFactorType}, @@ -22,7 +25,7 @@ use crate::{ }; pub fn routes() -> Vec { - routes![generate_yubikey, activate_yubikey, activate_yubikey_put,] + routes![generate_yubikey, activate_yubikey, activate_yubikey_put, delete_yubikeys,] } struct HttpClientTransport { @@ -60,8 +63,7 @@ struct EnableYubikeyData { key4: Option, key5: Option, nfc: bool, - master_password_hash: Option, - otp: Option, + user_verification_token: String, } #[derive(Deserialize, Serialize, Debug)] @@ -125,48 +127,29 @@ async fn generate_yubikey(data: Json, headers: Headers, conn: data.validate(&user, false, &conn).await?; let user_id = &user.uuid; - let yubikey_type = TwoFactorType::YubiKey as i32; - let r = TwoFactor::find_by_user_and_type(user_id, yubikey_type, &conn).await; - - if let Some(r) = r { - let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; - - let mut result = jsonify_yubikeys(yubikey_metadata.keys); - - result["enabled"] = Value::Bool(true); - result["nfc"] = Value::Bool(yubikey_metadata.nfc); - result["object"] = Value::String("twoFactorU2f".to_owned()); - - Ok(Json(result)) - } else { - Ok(Json(json!({ - "enabled": false, - "object": "twoFactorU2f", - }))) - } + let (enabled, keys, yubikey_json) = + if let Some(r) = TwoFactor::find_by_user_and_type(user_id, TwoFactorType::YubiKey, &conn).await { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + let enabled = !yubikey_metadata.keys.is_empty(); + let mut result = jsonify_yubikeys(yubikey_metadata.keys.clone()); + result["enabled"] = Value::Bool(enabled); + result["nfc"] = Value::Bool(yubikey_metadata.nfc); + (enabled, yubikey_metadata.keys, result) + } else { + (false, Vec::new(), json!({"enabled": false})) + }; + Ok(Json(json!({ + "yubiKey": yubikey_json, + "userVerificationToken": two_factor::yubikey_token(user.uuid, keys, enabled), + }))) } #[post("/two-factor/yubikey", data = "")] async fn activate_yubikey(data: Json, headers: Headers, conn: DbConn) -> JsonResult { let data: EnableYubikeyData = data.into_inner(); - let mut user = headers.user; - - PasswordOrOtpData { - master_password_hash: data.master_password_hash.clone(), - otp: data.otp.clone(), - } - .validate(&user, true, &conn) - .await?; - - // Check if we already have some data - let mut yubikey_data = - match TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn).await { - Some(data) => data, - None => TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()), - }; - let yubikeys = parse_yubikeys(&data); + let mut user = headers.user; if yubikeys.is_empty() { // Return an error to prevent saving empty keys which would cause users not being able to login anymore. @@ -174,6 +157,17 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: err!("A key is required."); } + // Check if we already have some data + let mut yubikey_data = + if let Some(yd) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { + let ym: YubikeyMetadata = serde_json::from_str(&yd.data)?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &ym.keys, !ym.keys.is_empty())?; + yd + } else { + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &Vec::new(), false)?; + TwoFactor::new(user.uuid.clone(), TwoFactorType::YubiKey, String::new()) + }; + // Ensure they are valid OTPs for yubikey in &yubikeys { if yubikey.is_empty() || yubikey.len() == 12 { @@ -195,15 +189,12 @@ async fn activate_yubikey(data: Json, headers: Headers, conn: generate_recover_code(&mut user, &conn).await; - log_user_event(EventType::UserUpdated2fa as i32, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + log_user_event(EventType::UserUpdated2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; let mut result = jsonify_yubikeys(yubikey_metadata.keys); - result["enabled"] = Value::Bool(true); result["nfc"] = Value::Bool(yubikey_metadata.nfc); - result["object"] = Value::String("twoFactorU2f".to_owned()); - - Ok(Json(result)) + Ok(Json(json!({"yubiKey": result}))) } #[put("/two-factor/yubikey", data = "")] @@ -211,6 +202,23 @@ async fn activate_yubikey_put(data: Json, headers: Headers, c activate_yubikey(data, headers, conn).await } +#[delete("/two-factor/yubikey", data = "")] +async fn delete_yubikeys(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + let user = headers.user; + + if let Some(r) = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey, &conn).await { + let yubikey_metadata: YubikeyMetadata = serde_json::from_str(&r.data)?; + two_factor::validate_yubikey(&data.user_verification_token, &user.uuid, &yubikey_metadata.keys, true)?; + + r.delete(&conn).await?; + log_user_event(EventType::UserDisabled2fa, &user.uuid, headers.device.atype, &headers.ip.ip, &conn).await; + } + + super::check_2fa_state(&user, headers.device.atype, &headers.ip.ip, &conn).await?; + + Ok(()) +} + pub async fn validate_yubikey_login(response: &str, twofactor_data: &str) -> EmptyResult { if response.len() != 44 { err!("Invalid Yubikey OTP length"); diff --git a/src/api/identity.rs b/src/api/identity.rs index ac05386a..b61258cf 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -118,7 +118,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo match &login_result { Ok(_) => { log_user_event( - EventType::UserLoggedIn as i32, + EventType::UserLoggedIn, &user_id, client_header.device_type, &client_header.ip.ip, @@ -128,8 +128,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo } Err(e) => { if let Some(ev) = e.get_event() { - log_user_event(ev.event as i32, &user_id, client_header.device_type, &client_header.ip.ip, &conn) - .await; + log_user_event(ev.event, &user_id, client_header.device_type, &client_header.ip.ip, &conn).await; } } } @@ -904,7 +903,7 @@ async fn twofactor_auth( 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; + log_user_event(EventType::UserRecovered2fa, &user.uuid, device.atype, &ip.ip, conn).await; if CONFIG.mail_enabled() && let Err(e) = @@ -974,7 +973,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, } Some(tf_type @ TwoFactorType::YubiKey) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { err!("No YubiKey devices registered") }; @@ -986,7 +985,7 @@ async fn json_err_twofactor(providers: &[i32], user: &User, data: &ConnectData, } Some(tf_type @ TwoFactorType::Email) => { - let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type as i32, conn).await else { + let Some(twofactor) = TwoFactor::find_by_user_and_type(user_id, tf_type, conn).await else { err!("No twofactor email registered") }; diff --git a/src/api/mod.rs b/src/api/mod.rs index 1b3b5a7b..8baa221d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -46,7 +46,7 @@ pub type JsonResult = ApiResult>; pub type EmptyResult = ApiResult<()>; // Common structs representing JSON data received -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct PasswordOrOtpData { #[serde(alias = "MasterPasswordHash")] diff --git a/src/auth.rs b/src/auth.rs index cad9b298..57720382 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,6 @@ +#[path = "auth/two_factor.rs"] +pub mod two_factor; + #[path = "auth/send.rs"] pub mod send; pub type SendTokens = send::SendTokens; diff --git a/src/auth/two_factor.rs b/src/auth/two_factor.rs new file mode 100644 index 00000000..533c0cf4 --- /dev/null +++ b/src/auth/two_factor.rs @@ -0,0 +1,281 @@ +use chrono::{TimeDelta, Utc}; +use serde::{de::DeserializeOwned, ser::Serialize}; +use std::sync::LazyLock; + +use crate::{ + CONFIG, + api::{ApiResult, EmptyResult}, + auth::{decode_jwt, encode_jwt}, + db::models::UserId, +}; + +static JWT_2FA_AUTH_ISSUER: LazyLock = LazyLock::new(|| format!("{}|api.2fa", CONFIG.domain_origin())); + +#[derive(Serialize, Deserialize)] +pub struct TwoFactorClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: UserId, + + pub enabled: bool, + + pub claims: T, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthenticatorClaims { + #[serde(rename = "authenticator_key")] + pub key: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DuoClaims { + #[serde(rename = "duo_data")] + pub data: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WebauthnClaims { + #[serde(rename = "webauthn_keys")] + pub keys: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct YubikeyClaims { + #[serde(rename = "yubi_keys")] + pub keys: Vec, +} + +#[derive(Serialize, Deserialize, PartialEq)] +pub struct DuoData { + pub host: String, // Duo API hostname + pub ik: String, // client id + pub sk: String, // client secret +} + +impl DuoData { + pub fn global() -> Option { + match (CONFIG._enable_duo(), CONFIG.duo_host()) { + (true, Some(host)) => Some(Self { + host, + ik: CONFIG.duo_ikey().unwrap(), + sk: CONFIG.duo_skey().unwrap(), + }), + _ => None, + } + } + pub fn msg(s: &str) -> Self { + Self { + host: s.into(), + ik: s.into(), + sk: s.into(), + } + } + pub fn secret() -> Self { + Self::msg("") + } + pub fn obscure(self) -> Self { + let mut host = self.host; + let mut ik = self.ik; + let mut sk = self.sk; + + let digits = 4; + let replaced = "************"; + + host.replace_range(digits.., replaced); + ik.replace_range(digits.., replaced); + sk.replace_range(digits.., replaced); + + Self { + host, + ik, + sk, + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EmailClaims { + pub email: Option, +} + +fn token(user_id: UserId, enabled: bool, claims: T) -> String { + let time_now = Utc::now(); + let claims = TwoFactorClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(5).unwrap()).timestamp(), + iss: JWT_2FA_AUTH_ISSUER.to_string(), + sub: user_id, + enabled, + claims, + }; + encode_jwt(&claims) +} + +fn validate(token: &str, user_id: &UserId, enabled: bool) -> ApiResult { + match decode_jwt::>(token, JWT_2FA_AUTH_ISSUER.to_string()) { + Ok(claims) => { + if claims.sub != *user_id { + err!("Invalid verification token: Invalid user"); + } + if claims.enabled != enabled { + err!("Invalid verification token: Invalid state"); + } + Ok(claims.claims) + } + Err(err) => err!(format!("Failed to decode verification token: {err}")), + } +} + +pub fn authenticator_token(user_id: UserId, key: String, enabled: bool) -> String { + token( + user_id, + enabled, + AuthenticatorClaims { + key, + }, + ) +} + +pub fn validate_authenticator(token: &str, user_id: &UserId, key: &str, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if claims.key != key { + err!("Invalid verification token: Invalid key"); + } + Ok(()) +} + +pub fn duo_token(user_id: UserId, data: Option, enabled: bool) -> String { + token( + user_id, + enabled, + DuoClaims { + data, + }, + ) +} + +// When disabling we check that it's the correct data +pub fn validate_duo(token: &str, user_id: &UserId, data: Option<&DuoData>, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if enabled && claims.data.as_ref() != data { + err!("Invalid verification token: Invalid duo data"); + } + Ok(()) +} + +pub fn email_token(user_id: UserId, email: Option, enabled: bool) -> String { + token( + user_id, + enabled, + EmailClaims { + email, + }, + ) +} + +// When disabling we check that it's the correct `email` +pub fn validate_email(token: &str, user_id: &UserId, email: String, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if enabled && claims.email != Some(email) { + err!("Invalid verification token: Invalid email"); + } + Ok(()) +} + +pub fn webauthn_token(user_id: UserId, keys: Vec, enabled: bool) -> String { + token( + user_id, + enabled, + WebauthnClaims { + keys, + }, + ) +} + +pub fn validate_webauthn(token: &str, user_id: &UserId, keys: &[i32], enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if keys != claims.keys { + err!("Invalid verification token: Invalid keys"); + } + Ok(()) +} + +pub fn yubikey_token(user_id: UserId, keys: Vec, enabled: bool) -> String { + token( + user_id, + enabled, + YubikeyClaims { + keys, + }, + ) +} + +pub fn validate_yubikey(token: &str, user_id: &UserId, keys: &Vec, enabled: bool) -> EmptyResult { + let claims = validate::(token, user_id, enabled)?; + if *keys != claims.keys { + err!("Invalid verification token: Invalid keys"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, from_value, to_value}; + + fn token(claims: T) -> Value { + to_value(TwoFactorClaims { + nbf: 0, + exp: 0, + iss: String::new(), + sub: UserId::from(String::from("4ff0f0a4-0aa4-4c1d-9d43-1f2bd4d5e8b1")), + enabled: false, + claims, + }) + .unwrap() + } + + #[test] + fn claims_only_parse_as_their_own_provider() { + let tokens = [ + token(AuthenticatorClaims { + key: String::from("JBSWY3DPEHPK3PXP"), + }), + token(DuoClaims { + data: None, + }), + token(WebauthnClaims { + keys: vec![1], + }), + token(YubikeyClaims { + keys: Vec::new(), + }), + token(EmailClaims { + email: None, + }), + ]; + for (issued, token) in tokens.iter().enumerate() { + let parsed = [ + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + from_value::>(token.clone()).is_ok(), + ]; + for (checked, ok) in parsed.into_iter().enumerate() { + assert_eq!(ok, issued == checked, "token {issued} parsed as {checked}"); + } + } + } +} diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs index 5f57635e..051ea4df 100644 --- a/src/db/models/two_factor.rs +++ b/src/db/models/two_factor.rs @@ -137,11 +137,11 @@ impl TwoFactor { .await } - pub async fn find_by_user_and_type(user_uuid: &UserId, atype: i32, conn: &DbConn) -> Option { + pub async fn find_by_user_and_type(user_uuid: &UserId, atype: TwoFactorType, conn: &DbConn) -> Option { conn.run(move |conn| { twofactor::table .filter(twofactor::user_uuid.eq(user_uuid)) - .filter(twofactor::atype.eq(atype)) + .filter(twofactor::atype.eq(atype as i32)) .first::(conn) .ok() }) From a2efadc650676e98cc1a6152141b6f930f496109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 7 Oct 2026 00:16:01 +0200 Subject: [PATCH 09/43] Fix email 2FA for SSO logins on iOS and Android (#7827) * Allow email-only /api/two-factor/send-email-login for mobile clients Mobile clients (iOS) may call /api/two-factor/send-email-login with only the user's email and without a MasterPasswordHash or an AuthRequest. Permit email-only requests so the server will send the email 2FA token in that flow.\n\nModified: src/api/core/two_factor/email.rs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(email-2fa): use device-identifier fallback when no password hash submitted iOS clients call /api/two-factor/send-email-login with an email and DeviceIdentifier but without masterPasswordHash or authRequestId after receiving a 2FA-required response from the token endpoint. The previous empty else-block allowed any caller to trigger a 2FA email for any account knowing only the email address. Replace the empty block with a device-identifier-based fallback: - Look up the most-recently-active device via find_by_device_for_email2fa. - Verify the device's associated user email matches the submitted email. - Log (debug) when the fallback path is exercised for operator visibility. - Reject with the original error when no device identifier is provided or when the device maps to a different account. Fixes #7568 * Check the pending 2FA login for this user and device Look up the pending 2FA login for the user and device instead of the latest one on the device, answer a failed check with the same error and IP/username log detail as a wrong password, and escape the device id in the logs. --------- Co-authored-by: Arunabha-Mukhopadhyay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/api/core/two_factor/email.rs | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 2b0ee785..531eea58 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -14,7 +14,10 @@ use crate::{ crypto, db::{ DbConn, - models::{AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorType, User, UserId}, + models::{ + AuthRequest, AuthRequestId, DeviceId, EventType, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, + UserId, + }, }, error::{Error, MapResult}, mail, @@ -94,6 +97,20 @@ async fn send_email_login(data: Json, client_headers: Client { err!("AuthRequest doesn't exist", "Invalid device, IP or code") } + } else if let Some(device_identifier) = &data.device_identifier { + // iOS/Android SSO logins send the email and device id but no password hash, + // so accept a device that has a pending 2FA login for this user + if TwoFactorIncomplete::find_by_user_and_device(&user.uuid, device_identifier, &conn).await.is_none() { + err!( + "Username or password is incorrect. Try again", + format!("IP: {}. Username: {}.", client_headers.ip.ip, email.escape_debug()) + ) + } + debug!( + "Email 2FA fallback: pending login. Username: {}. Device: {}.", + user.email, + device_identifier.to_string().escape_debug() + ); } else { err!("No password hash has been submitted.") } @@ -107,7 +124,7 @@ async fn send_email_login(data: Json, client_headers: Client let Some(user) = User::find_by_device_for_email2fa(device_identifier, &conn).await else { err!( "Username or password is incorrect. Try again", - format!("IP: {}. Device: {device_identifier}.", client_headers.ip.ip) + format!("IP: {}. Device: {}.", client_headers.ip.ip, device_identifier.to_string().escape_debug()) ) }; From 1f802f8e6ae9b788e1e9cc5dffb7e83780417332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 7 Oct 2026 00:56:01 +0200 Subject: [PATCH 10/43] Update web-vault to v2026.8.0 (#7828) --- docker/DockerSettings.yaml | 4 ++-- docker/Dockerfile.alpine | 12 ++++++------ docker/Dockerfile.debian | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index 6ae6e9a8..dbe0ac92 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,6 +1,6 @@ --- -vault_version: "v2026.7.0" -vault_image_digest: "sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c" +vault_version: "v2026.8.0" +vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a" # Cross Compile Docker Helper Scripts v1.9.0 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index 6e6a79a3..be267da5 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 -# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 +# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c -# [docker.io/vaultwarden/web-vault:v2026.7.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a +# [docker.io/vaultwarden/web-vault:v2026.8.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 44d068ef..ac82c24b 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.7.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.7.0 -# [docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 +# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c -# [docker.io/vaultwarden/web-vault:v2026.7.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a +# [docker.io/vaultwarden/web-vault:v2026.8.0] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:ba8bab66d4330ab9dbafa8f245bcbe99cf6ee3f2c8ce9b5fbb10e9c49658451c AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts From d1cbd027cdb601cf32bd95ce3c8487ded54dd7fd Mon Sep 17 00:00:00 2001 From: will-lottkowitz-prism Date: Thu, 8 Oct 2026 03:19:26 +1000 Subject: [PATCH 11/43] Add revision_date to org policies (#7571) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add revision_date to org policies Org policy JSON responses never included a revisionDate field, unlike every other synced entity. The official Bitwarden server always sets it, and current official clients (recently migrated to the WASM SDK's typed PolicyView) now assume it is always a valid date string. Its absence deserializes to undefined client-side, which then throws (RangeError: Invalid time value) when the client formats it, breaking the whole policy sync pipeline and silently emptying the vault view in the browser and desktop apps. Adds a revision_date column (mirroring the existing pattern used by Send), stamped on creation and bumped on every save. * Fix migration correctness for org policy revision_date Caught by review: SQLite rejects non-constant defaults in ALTER TABLE ADD COLUMN, so the sqlite migration would fail outright on every existing installation (vaultwarden's default backend) rather than the postgresql setup this was developed against. - sqlite/mysql: add the column with a constant placeholder default, then backfill via UPDATE, since neither allows a volatile default in ALTER TABLE ADD COLUMN (mysql does allow it, but a constant keeps the three backends' migrations symmetric). - mysql: use DATETIME instead of TIMESTAMP, matching this repo's existing revision_date columns, and backfill with UTC_TIMESTAMP() instead of a session-timezone-dependent CURRENT_TIMESTAMP. - postgresql: backfill with `now() AT TIME ZONE 'utc'` instead of a DEFAULT now(), since assigning timestamptz now() into this naive TIMESTAMP column would otherwise cast through the server's TimeZone GUC and store local wall-clock instead of UTC on non-UTC servers. - mysql/postgresql: add a real down.sql (DROP COLUMN) instead of leaving it empty. * Move org policy revision_date migration after the latest one --------- Co-authored-by: Daniel García --- .../down.sql | 1 + .../up.sql | 7 +++++++ .../down.sql | 1 + .../up.sql | 9 +++++++++ .../down.sql | 0 .../up.sql | 6 ++++++ src/db/models/org_policy.rs | 18 ++++++++++++------ src/db/schema.rs | 1 + 8 files changed, 37 insertions(+), 6 deletions(-) create mode 100644 migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql create mode 100644 migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql create mode 100644 migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql create mode 100644 migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql create mode 100644 migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql create mode 100644 migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql diff --git a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql new file mode 100644 index 00000000..27c33623 --- /dev/null +++ b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/down.sql @@ -0,0 +1 @@ +ALTER TABLE org_policies DROP COLUMN revision_date; diff --git a/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql new file mode 100644 index 00000000..97a84117 --- /dev/null +++ b/migrations/mysql/2026-10-07-120000_add_org_policy_revision_date/up.sql @@ -0,0 +1,7 @@ +-- DATETIME (not TIMESTAMP) to match this repo's convention for revision_date +-- columns elsewhere, and to avoid MySQL's implicit session-timezone +-- conversion and 2038 range limit on TIMESTAMP. +ALTER TABLE org_policies +ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; + +UPDATE org_policies SET revision_date = UTC_TIMESTAMP(); diff --git a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql new file mode 100644 index 00000000..27c33623 --- /dev/null +++ b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/down.sql @@ -0,0 +1 @@ +ALTER TABLE org_policies DROP COLUMN revision_date; diff --git a/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql new file mode 100644 index 00000000..44229de2 --- /dev/null +++ b/migrations/postgresql/2026-10-07-120000_add_org_policy_revision_date/up.sql @@ -0,0 +1,9 @@ +-- Backfill via `now() AT TIME ZONE 'utc'` rather than a DEFAULT of now(): +-- assigning timestamptz now() into a naive TIMESTAMP column casts through +-- the server's TimeZone GUC, so a DEFAULT now() would store local wall-clock +-- instead of UTC on non-UTC servers, unlike every other naive-UTC timestamp +-- column in this schema. +ALTER TABLE org_policies +ADD COLUMN revision_date TIMESTAMP NOT NULL DEFAULT '1970-01-01 00:00:00'; + +UPDATE org_policies SET revision_date = (now() AT TIME ZONE 'utc'); diff --git a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql new file mode 100644 index 00000000..86595711 --- /dev/null +++ b/migrations/sqlite/2026-10-07-120000_add_org_policy_revision_date/up.sql @@ -0,0 +1,6 @@ +-- SQLite forbids non-constant defaults in ALTER TABLE ... ADD COLUMN, so add +-- the column with a constant placeholder and backfill separately. +ALTER TABLE org_policies +ADD COLUMN revision_date DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00'; + +UPDATE org_policies SET revision_date = CURRENT_TIMESTAMP; diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 94e53cbd..266cbff4 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -1,3 +1,4 @@ +use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, From}; use diesel::prelude::*; use serde::Deserialize; @@ -11,6 +12,7 @@ use crate::{ schema::{org_policies, users_organizations}, }, error::MapResult, + util::format_date, }; use super::{Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, TwoFactor, UserId}; @@ -24,6 +26,7 @@ pub struct OrgPolicy { pub atype: i32, pub enabled: bool, pub data: String, + pub revision_date: NaiveDateTime, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Enums/PolicyType.cs @@ -77,6 +80,7 @@ impl OrgPolicy { atype: atype as i32, enabled, data, + revision_date: Utc::now().naive_utc(), } } @@ -92,7 +96,7 @@ impl OrgPolicy { "type": self.atype, "data": data_json, "enabled": self.enabled, - "revisionDate": null, + "revisionDate": format_date(&self.revision_date), "object": "policy", }); @@ -110,11 +114,13 @@ impl OrgPolicy { /// Database methods impl OrgPolicy { - pub async fn save(&self, conn: &DbConn) -> EmptyResult { + pub async fn save(&mut self, conn: &DbConn) -> EmptyResult { + self.revision_date = Utc::now().naive_utc(); + db_run! { conn: sqlite, mysql { match diesel::replace_into(org_policies::table) - .values(self) + .values(&*self) .execute(conn) { Ok(_) => Ok(()), @@ -122,7 +128,7 @@ impl OrgPolicy { Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => { diesel::update(org_policies::table) .filter(org_policies::uuid.eq(&self.uuid)) - .set(self) + .set(&*self) .execute(conn) .map_res("Error saving org_policy") } @@ -142,10 +148,10 @@ impl OrgPolicy { .map_res("Error deleting org_policy for insert")?; diesel::insert_into(org_policies::table) - .values(self) + .values(&*self) .on_conflict(org_policies::uuid) .do_update() - .set(self) + .set(&*self) .execute(conn) .map_res("Error saving org_policy") } diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..ae1e3216 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -116,6 +116,7 @@ table! { atype -> Integer, enabled -> Bool, data -> Text, + revision_date -> Timestamp, } } From 0e93d15b73fb31b91615a086784fdaec9d0493b2 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:20:04 +0200 Subject: [PATCH 12/43] Add Windows desktop autotype support (#7808) --- .env.template | 3 +++ src/config.rs | 2 ++ src/db/models/org_policy.rs | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index f34ea249..fcfa14d6 100644 --- a/.env.template +++ b/.env.template @@ -394,6 +394,9 @@ ## - "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-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) +## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0) +## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1) +## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled. ## - "pm-30529-webauthn-related-origins": ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) diff --git a/src/config.rs b/src/config.rs index bf6ea724..d3e3fb28 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1430,6 +1430,8 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "undetermined-cipher-scenario-logic", "enable-basic-auth-response", "ssh-agent-v2", + "windows-desktop-autotype", + "windows-desktop-autotype-ga", // Key Management Team "windows-native-credential-sync", // Mobile Team diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 266cbff4..31938f76 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -49,7 +49,7 @@ pub enum OrgPolicyType { RemoveUnlockWithPin = 14, RestrictedItemTypes = 15, UriMatchDefaults = 16, - // AutotypeDefaultSetting = 17, // Not supported yet + AutotypeDefaultSetting = 17, // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) OrganizationUserNotification = 20, From df2cd3c8693782eeedfd1f70318f6f14033213a8 Mon Sep 17 00:00:00 2001 From: Pier Carlo Cadoppi Date: Wed, 7 Oct 2026 18:20:23 +0100 Subject: [PATCH 13/43] Add `biometrics-sdk-ipc` feature flag (#7813) --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index fcfa14d6..272c7738 100644 --- a/.env.template +++ b/.env.template @@ -402,6 +402,7 @@ ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) ## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0) +## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. diff --git a/src/config.rs b/src/config.rs index d3e3fb28..a56748f4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1433,6 +1433,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "windows-desktop-autotype", "windows-desktop-autotype-ga", // Key Management Team + "biometrics-sdk-ipc", "windows-native-credential-sync", // Mobile Team "pm-34171-card-scanner", From 64895524e481492130061b95aa4433118f6b1c59 Mon Sep 17 00:00:00 2001 From: Cassian433 Date: Sat, 10 Oct 2026 23:28:40 +0530 Subject: [PATCH 14/43] Delete auth requests when deleting a user (#7837) --- src/db/models/auth_request.rs | 9 +++++++++ src/db/models/user.rs | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index b06c9e56..b28c69f7 100644 --- a/src/db/models/auth_request.rs +++ b/src/db/models/auth_request.rs @@ -166,6 +166,15 @@ impl AuthRequest { .await } + pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + conn.run(move |conn| { + diesel::delete(auth_requests::table.filter(auth_requests::user_uuid.eq(user_uuid))) + .execute(conn) + .map_res("Error deleting auth requests for user") + }) + .await + } + /// Marks the request as used to log in. Returns false when it already was, so it only works once. pub async fn set_authentication_date(uuid: &AuthRequestId, conn: &DbConn) -> ApiResult { let now = Utc::now().naive_utc(); diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 6610c945..2f3afc02 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -19,7 +19,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, TwoFactorIncomplete, + AuthRequest, Cipher, Device, EmergencyAccess, Favorite, Folder, Membership, MembershipType, TwoFactor, + TwoFactorIncomplete, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset, Selectable)] @@ -377,6 +378,7 @@ impl User { Cipher::delete_all_by_user(&self.uuid, conn).await?; Favorite::delete_all_by_user(&self.uuid, conn).await?; Folder::delete_all_by_user(&self.uuid, conn).await?; + AuthRequest::delete_all_by_user(&self.uuid, conn).await?; Device::delete_all_by_user(&self.uuid, conn).await?; TwoFactor::delete_all_by_user(&self.uuid, conn).await?; TwoFactorIncomplete::delete_all_by_user(&self.uuid, conn).await?; From 58a980f850464f7d9cdc99a765df36f0a3308b91 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Fri, 16 Oct 2026 11:51:32 +0200 Subject: [PATCH 15/43] Add unsupported riscv64 architecture (#7859) This PR makes it easy for users to build a Debian container images which is able to run on a riscv64 architecture using a Debian Container, or if needed, you can extract the binary from this container, but the user needs to be sure the libraries are available and supported if using Docker is not an option. Signed-off-by: BlackDex --- docker/README.md | 1 + docker/docker-bake.hcl | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/docker/README.md b/docker/README.md index d64d5789..3bbc31e1 100644 --- a/docker/README.md +++ b/docker/README.md @@ -15,6 +15,7 @@ Some unsupported platforms for Debian based images. These are not built and test - 386 (linux/386) - ppc64le (linux/ppc64le) - s390x (linux/s390x) +- riscv64 (linux/riscv64) To build these containers you need to enable QEMU binfmt support to be able to run/emulate architectures which are different then your host.
This ensures the container build process can run binaries from other architectures.
diff --git a/docker/docker-bake.hcl b/docker/docker-bake.hcl index c27c39a6..30902bba 100644 --- a/docker/docker-bake.hcl +++ b/docker/docker-bake.hcl @@ -148,6 +148,12 @@ target "debian-s390x" { platforms = ["linux/s390x"] tags = generate_tags("", "-s390x") } + +target "debian-riscv64" { + inherits = ["debian"] + platforms = ["linux/riscv64"] + tags = generate_tags("", "-riscv64") +} // ==== End of unsupported Debian architecture targets === // A Group to build all platforms individually for local testing From 970cc491821c83485bf81f655d199950178f0207 Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:00 +0800 Subject: [PATCH 16/43] api/icons: fix typos in the minimum PNG size comment (#7867) --- src/api/icons.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/icons.rs b/src/api/icons.rs index 05027959..39a259a9 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -524,7 +524,7 @@ async fn download_icon(domain: &str) -> Result<(Bytes, Option<&str>), Error> { Ok(()) }) { Ok(_) => { - // Also check if the size is atleast 67 bytes, which seems to be the smallest png i could create + // Also check if the size is at least 67 bytes, which seems to be the smallest png I could create if body.len() >= 67 { // Check if the icon type is allowed, else try an icon from the list. icon_type = get_icon_type(&body); From 296599003c8c08f3afff530d6f265fb86446fb4a Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:46:26 +0800 Subject: [PATCH 17/43] docs: fix connect-src description grammar in config.rs and .env.template (#7866) --- .env.template | 2 +- src/config.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.template b/.env.template index 272c7738..f729c92c 100644 --- a/.env.template +++ b/.env.template @@ -463,7 +463,7 @@ ## Allowed connect-src (Know the risks!) ## https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/connect-src -## Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature +## Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature ## This adds the configured value to the 'Content-Security-Policy' headers 'connect-src' value. ## Multiple values must be separated with a whitespace. And only HTTPS values are allowed. ## Example: "https://my-addy-io.domain.tld https://my-simplelogin.domain.tld" diff --git a/src/config.rs b/src/config.rs index a56748f4..9a48f4d3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -772,7 +772,7 @@ make_config! { /// Allowed iframe ancestors (Know the risks!) |> Allows other domains to embed the web vault into an iframe, useful for embedding into secure intranets allowed_iframe_ancestors: String, true, def, String::new(); - /// Allowed connect-src (Know the risks!) |> Allows other domains to URLs which can be loaded using script interfaces like the Forwarded email alias feature + /// Allowed connect-src (Know the risks!) |> Allows other domains for URLs which can be loaded using script interfaces like the Forwarded email alias feature allowed_connect_src: String, true, def, String::new(); /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in From f4696877b91d4eb6cbeb2c6a4c86a3d19033ccb4 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Thu, 22 Oct 2026 12:47:30 +0200 Subject: [PATCH 18/43] Update dependencies (#7882) - Updated all the crates - Updated JavaScript libraries - Updated xx to v1.10.0 - Updated Rust to v1.99.1 - Updated GHA & Pre-Commit Resolves #7877 Signed-off-by: BlackDex --- .github/workflows/build.yml | 2 +- .github/workflows/release.yml | 8 +- .github/workflows/trivy.yml | 2 +- .github/workflows/typos.yml | 2 +- .github/workflows/zizmor.yml | 2 +- .pre-commit-config.yaml | 2 +- Cargo.lock | 485 +++++++++++++++--------------- Cargo.toml | 24 +- docker/DockerSettings.yaml | 4 +- docker/Dockerfile.alpine | 8 +- docker/Dockerfile.debian | 4 +- macros/Cargo.toml | 2 +- rust-toolchain.toml | 2 +- src/static/scripts/datatables.css | 4 +- src/static/scripts/datatables.js | 20 +- 15 files changed, 293 insertions(+), 278 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f52bd8e4..673137cf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -113,7 +113,7 @@ jobs: # Enable Rust Caching - name: Rust Caching - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 with: # Use a custom prefix-key to force a fresh start. This is sometimes needed with bigger changes. # Like changing the build host from Ubuntu 20.04 to 22.04 for example. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 903a5e22..53cf0923 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -185,7 +185,7 @@ jobs: - name: Bake ${{ matrix.base_image }} containers id: bake_vw - uses: docker/bake-action@018cb6412ab401ebaa809aa5f85966b74628600f # v7.4.0 + uses: docker/bake-action@59cb1203cd42bcefd40a1b6d73b589e48c86040e # v7.5.0 env: BASE_TAGS: "${{ steps.determine-version.outputs.BASE_TAGS }}" SOURCE_COMMIT: "${{ env.SOURCE_COMMIT }}" @@ -222,7 +222,7 @@ jobs: touch "${RUNNER_TEMP}/digests/${digest#sha256:}" - name: Upload digest - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: ${{ runner.temp }}/digests/* @@ -242,7 +242,7 @@ jobs: subject-path: vaultwarden-${{ env.NORMALIZED_ARCH }} - name: Upload binaries as artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: vaultwarden-${{ env.SOURCE_VERSION }}-linux-${{ env.NORMALIZED_ARCH }}-${{ matrix.base_image }} path: vaultwarden-${{ env.NORMALIZED_ARCH }} @@ -264,7 +264,7 @@ jobs: steps: - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: ${{ runner.temp }}/digests pattern: digests-*-${{ matrix.base_image }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 81c51847..7ffbe632 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,6 +50,6 @@ jobs: severity: CRITICAL,HIGH - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: 'trivy-results.sarif' diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index 45f90bb9..f7d1f92c 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -23,4 +23,4 @@ jobs: # When this version is updated, do not forget to update this in `.pre-commit-config.yaml` too - name: Spell Check Repo - uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 + uses: crate-ci/typos@7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1 diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 31153b07..c08b9213 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1 with: # intentionally not scanning the entire repository, # since it contains integration tests. diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab96a84f..b6352bbb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,7 @@ repos: # When this version is updated, do not forget to update this in `.github/workflows/typos.yaml` too - repo: https://github.com/crate-ci/typos - rev: 00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 + rev: 7f9d0819ca1c008ade73f4b41fa9d258d48f605b # v1.51.1 hooks: - id: typos always_run: true diff --git a/Cargo.lock b/Cargo.lock index 6c9d9237..9568e5c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,9 +68,9 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" -version = "1.9.2" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" dependencies = [ "rustversion", ] @@ -151,9 +151,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.50" +version = "0.4.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970" dependencies = [ "compression-codecs", "compression-core", @@ -318,7 +318,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -356,9 +356,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.12.0" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8d7b388a9fc3a6db15a5ec778c38b354eff1364882c94d08e0252f7a47dcaa4" +checksum = "939e256cfe0ed68e96d32f194339d88210d5c10685cff0787400158f852c6819" dependencies = [ "aws-credential-types", "aws-runtime", @@ -387,9 +387,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -399,9 +399,9 @@ dependencies = [ [[package]] name = "aws-runtime" -version = "1.10.0" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8a9911551b4ea6ca13805ef52ed96f7d2bbb43cc3b4a14cb0776a71f33cfaa" +checksum = "9a0cc2144962cc5e358467b16e3d82981b1d424a7c3d34602be0036103814a1a" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -424,9 +424,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.114.0" +version = "1.116.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12be2f9c8eef7f5fc919c96d538e629698469a02b4cb75408b26b1bd984ebe79" +checksum = "f26d2c93a4e6e7c133dc4090dc2273cfd92894fee8b883256427b57cacc3a384" dependencies = [ "arc-swap", "aws-credential-types", @@ -449,9 +449,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.116.0" +version = "1.118.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7645db8724ea3b82fdccfb67e1b0f637c9d8ab0e7ef29d84884d8d8d73f805d" +checksum = "d6fa86283d1c6d5c82f1fb2d82f7704d8f7a7bf5f0591813114240d296f6a11c" dependencies = [ "arc-swap", "aws-credential-types", @@ -474,9 +474,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.119.0" +version = "1.121.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e490aa904849b38e770922faac779dd245f5cf8be81f19065acf18d276e4ad" +checksum = "97ff04fe350fd756c1aa45544128e88f72ffb6281cad628e5a4add464cbbab8b" dependencies = [ "arc-swap", "aws-credential-types", @@ -500,9 +500,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.6.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2312577f088c9fbf4206dfdb884cf1de9407b43e1a923cbed5237775116fc24b" +checksum = "0a3ccec0607ae3839cd76830df5ed62468ac1330acdb3dc4ec0502404113d2b6" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -514,16 +514,16 @@ dependencies = [ "hmac 0.13.0", "http 1.5.0", "percent-encoding", - "sha2 0.11.0", + "sha2 0.11.1", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.3.0" +version = "1.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" dependencies = [ "futures-util", "pin-project-lite", @@ -532,9 +532,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.64.1" +version = "0.64.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "639b4d8f8555f24a9be649811c3eb0b4d4616f4d61daf0c32e28873bc1ea9af1" +checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -547,15 +547,14 @@ dependencies = [ "http-body-util", "percent-encoding", "pin-project-lite", - "pin-utils", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.63.1" +version = "0.65.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3385d469edbe8b60cc72002784652b5efca39178192aa9cc4b44c9875c6bdc18" +checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -573,9 +572,9 @@ dependencies = [ [[package]] name = "aws-smithy-query" -version = "0.62.1" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d1d71f6562be974caa85442ecd90194c40fdb5df045f182a6c2e872ce95056" +checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -586,9 +585,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.16.0" +version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6e302ac1d88b99652489df31abdec6ac42a2ab2ac3982ad0ac49f64dfaf28ba" +checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -602,16 +601,15 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "pin-project-lite", - "pin-utils", "tokio", "tracing", ] [[package]] name = "aws-smithy-runtime-api" -version = "1.19.0" +version = "1.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0730c16f91124c6a2abb4932c77e299288b3dd9f967ea2e9ec48cc6731e87a4" +checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -637,9 +635,9 @@ dependencies = [ [[package]] name = "aws-smithy-schema" -version = "0.2.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8f395d93304280b64b7632fea798d177e74897fe7f063416ce627cd6fa24829" +checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -648,9 +646,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69bb407740a197147da48238ecc94498493c9e85445732360cec180296ca45f1" +checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822" dependencies = [ "base64-simd", "bytes", @@ -661,7 +659,6 @@ dependencies = [ "itoa", "num-integer", "pin-project-lite", - "pin-utils", "ryu", "serde", "time", @@ -669,9 +666,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.62.1" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b932c8d6dc127fc980eecd78f8694ae9b9551b69a93a7def2a199c1c0033daf" +checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -681,9 +678,9 @@ dependencies = [ [[package]] name = "aws-types" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209f3a6d82a6e9e5f94abbed94c7a26e1c052341002bf57a5fb5481f625896fc" +checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -882,9 +879,9 @@ dependencies = [ [[package]] name = "cached" -version = "4.0.1" +version = "4.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "292f39b5c459538e148825347c400cb2005a618b897d0e6be183bad677d460b7" +checksum = "85a0dbacacc2069fc95062beec3f79a28f0f54ef2f72d7fe69000d1557f7c3d0" dependencies = [ "ahash", "async-lock", @@ -917,9 +914,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159" [[package]] name = "cc" -version = "1.6.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -988,9 +985,9 @@ checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -998,9 +995,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe" dependencies = [ "brotli", "compression-core", @@ -1012,9 +1009,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" [[package]] name = "concurrent-queue" @@ -1157,9 +1154,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -1245,9 +1242,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] @@ -1347,7 +1344,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1380,7 +1377,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1411,9 +1408,9 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "defmt" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -1421,21 +1418,21 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ "thiserror 2.0.21", ] @@ -1467,9 +1464,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -1563,9 +1560,9 @@ dependencies = [ [[package]] name = "diesel" -version = "2.3.13" +version = "2.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b934ddbdcb2abb9f9fc9c30bd47bcc5618b615eea1d334cda5fdf8ff9b072a" +checksum = "ebe9f7eaef33febd60290c5a9f3b0571d03c4c04a24739cd64808ce30e65a5d3" dependencies = [ "bigdecimal", "bitflags 2.13.2", @@ -1663,7 +1660,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1756,9 +1753,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "elliptic-curve" @@ -1816,9 +1813,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1906,9 +1903,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" @@ -2026,7 +2023,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2194,9 +2191,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -2513,9 +2510,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -2539,9 +2536,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", - "hyper 1.11.1", + "hyper 1.12.0", "hyper-util", - "rustls 0.23.45", + "rustls 0.23.46", "tokio", "tokio-rustls 0.26.6", "tower-service", @@ -2560,7 +2557,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "httparse", - "hyper 1.11.1", + "hyper 1.12.0", "ipnet", "libc", "percent-encoding", @@ -2599,9 +2596,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" dependencies = [ "displaydoc", "potential_utf", @@ -2613,9 +2610,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" dependencies = [ "displaydoc", "litemap", @@ -2626,9 +2623,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2640,15 +2637,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" [[package]] name = "icu_properties" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" dependencies = [ "displaydoc", "icu_collections", @@ -2661,15 +2658,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" [[package]] name = "icu_provider" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" dependencies = [ "displaydoc", "icu_locale_core", @@ -2792,9 +2789,9 @@ checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" [[package]] name = "jiff" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ "defmt", "jiff-core", @@ -2821,9 +2818,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.37" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -2833,9 +2830,9 @@ dependencies = [ [[package]] name = "jiff-tzdb" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" [[package]] name = "jiff-tzdb-platform" @@ -2999,7 +2996,7 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls 0.23.45", + "rustls 0.23.46", "rustls-native-certs", "serde", "socket2 0.6.5", @@ -3011,9 +3008,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -3055,9 +3052,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -3097,7 +3094,7 @@ name = "macros" version = "0.1.0" dependencies = [ "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3137,13 +3134,14 @@ dependencies = [ [[package]] name = "migrations_macros" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +checksum = "9423d6affd681fb4d25d35885d0d0262c52f8ade16e96f87c064c442e5aa5d47" dependencies = [ "migrations_internals", "proc-macro2", "quote", + "syn 3.0.9", ] [[package]] @@ -3336,7 +3334,7 @@ checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3360,9 +3358,9 @@ dependencies = [ [[package]] name = "num-modular" -version = "0.6.6" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26ac76200f74e658124f95fa63e1a82b2fd2181c5b2fdde80b3d89d2d3f905e7" +checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" [[package]] name = "num-order" @@ -3395,9 +3393,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] @@ -3442,9 +3440,9 @@ dependencies = [ [[package]] name = "opendal" -version = "0.59.4" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbd193aec6d62efaff87611dfae52c132aed650bb3cb0c684c0a9394f09a42a" +checksum = "46081824c74bac9f68f0bc4408077f77b0e315c006514719c97af1c2227ac2d2" dependencies = [ "opendal-core", "opendal-service-fs", @@ -3453,9 +3451,9 @@ dependencies = [ [[package]] name = "opendal-core" -version = "0.59.4" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b5555d753dd6a6383dc0891254b72f3cdc40c011ba0372b560e0ae5aa29f5c" +checksum = "33d5a635dfea36a7a0c3bbb2ca34c97af17f98dd69b0cdf31a1d436a346e0a12" dependencies = [ "anyhow", "asyncband", @@ -3467,7 +3465,7 @@ dependencies = [ "log", "md-5", "percent-encoding", - "quick-xml 0.41.0", + "quick-xml", "reqsign-core", "serde", "serde_json", @@ -3479,9 +3477,9 @@ dependencies = [ [[package]] name = "opendal-http-transport-reqwest" -version = "0.59.4" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6588678f3040a3eab1819364dcbc6a746a8286d1ceb23a70a6614faa01dc6c" +checksum = "fc025e163c43993e30cf1829a30b791934e5b0f7cd4d7e03acc902a9efb7e8f5" dependencies = [ "bytes", "futures", @@ -3493,9 +3491,9 @@ dependencies = [ [[package]] name = "opendal-service-fs" -version = "0.59.4" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5c16fe91b77c0364f66546a1c32b2999e9c3c3c75c2e908a282268e0890bac" +checksum = "70768b8f5af6996a494e784a1dc2a76b5cf890c422aff31ad094319bde98feb7" dependencies = [ "bytes", "log", @@ -3507,9 +3505,9 @@ dependencies = [ [[package]] name = "opendal-service-s3" -version = "0.59.4" +version = "0.59.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d83e716c25a81fe2ca701894b8e14b9966acca3812af250e50132accb19da02" +checksum = "0932321e27c712a30ef72db56b12f31e41e9f0d2d3841d63a91bc8075cd8f0ef" dependencies = [ "base64 0.23.1", "bytes", @@ -3518,7 +3516,7 @@ dependencies = [ "log", "md-5", "opendal-core", - "quick-xml 0.41.0", + "quick-xml", "reqsign-aws-v4", "reqsign-core", "reqsign-file-read-tokio", @@ -3760,19 +3758,21 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.2" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23" dependencies = [ "memchr", + "psm", + "stacker", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.2" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44" dependencies = [ "pest", "pest_generator", @@ -3780,22 +3780,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.2" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "pest_meta" -version = "2.9.2" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710" dependencies = [ "pest", ] @@ -4017,7 +4017,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.15+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -4048,6 +4048,15 @@ version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" +[[package]] +name = "psm" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200b9ff220857e53e184257720a14553b2f4aa02577d2ed9842d45d4b9654810" +dependencies = [ + "cc", +] + [[package]] name = "publicsuffix" version = "2.3.0" @@ -4079,16 +4088,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.42.0" @@ -4248,7 +4247,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4309,7 +4308,7 @@ dependencies = [ "http 1.5.0", "log", "percent-encoding", - "quick-xml 0.42.0", + "quick-xml", "reqsign-core", "rust-ini", "serde", @@ -4327,7 +4326,7 @@ dependencies = [ "bytes", "http 1.5.0", "log", - "quick-xml 0.42.0", + "quick-xml", "reqsign-aws-core", "reqsign-core", "serde", @@ -4350,7 +4349,7 @@ dependencies = [ "log", "percent-encoding", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "windows-sys 0.61.2", ] @@ -4382,7 +4381,7 @@ dependencies = [ "http 1.5.0", "http-body 1.1.0", "http-body-util", - "hyper 1.11.1", + "hyper 1.12.0", "hyper-rustls", "hyper-util", "js-sys", @@ -4390,7 +4389,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls 0.23.45", + "rustls 0.23.46", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -4586,11 +4585,10 @@ dependencies = [ [[package]] name = "rsqlite-vfs" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +checksum = "353b74f9810d5b085b6c42c9d86a5f0fdb795553235cdd48753b32e3809d18a7" dependencies = [ - "hashbrown 0.16.1", "thiserror 2.0.21", ] @@ -4659,24 +4657,24 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.45" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4713,10 +4711,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.45", + "rustls 0.23.46", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.15", + "rustls-webpki 0.103.16", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -4741,9 +4739,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.15" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "ring", "rustls-pki-types", @@ -4773,9 +4771,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -4803,9 +4801,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -4925,14 +4923,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4972,9 +4970,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -5005,7 +5003,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -5021,7 +5019,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5059,9 +5057,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -5161,9 +5159,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -5218,14 +5216,12 @@ dependencies = [ [[package]] name = "sqlite-wasm-rs" -version = "0.5.5" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +checksum = "36bd8e29bf08189a005b2df1df99823aa0861cabe9044e5f31ed54ca292ee618" dependencies = [ "cc", - "js-sys", "rsqlite-vfs", - "wasm-bindgen", ] [[package]] @@ -5243,6 +5239,19 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + [[package]] name = "state" version = "0.6.0" @@ -5290,9 +5299,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -5327,7 +5336,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5419,7 +5428,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5474,9 +5483,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", "zerovec", @@ -5490,9 +5499,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.2" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -5513,7 +5522,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5532,7 +5541,7 @@ version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ - "rustls 0.23.45", + "rustls 0.23.46", "tokio", ] @@ -5561,9 +5570,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -5593,7 +5602,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "serde_core", - "serde_spanned 1.1.1", + "serde_spanned 1.1.2", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.15", @@ -5619,9 +5628,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -5642,21 +5651,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap 2.14.2", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] @@ -5849,9 +5858,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-segmentation" @@ -5904,9 +5913,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5987,7 +5996,7 @@ dependencies = [ "rocket", "rocket_ws", "rpassword", - "rustls 0.23.45", + "rustls 0.23.46", "semver", "serde", "serde_json", @@ -6038,9 +6047,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -6103,7 +6112,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -6219,9 +6228,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" dependencies = [ "rustls-pki-types", ] @@ -6570,9 +6579,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -6587,7 +6596,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -6606,18 +6615,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6641,15 +6650,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -6678,9 +6687,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ "yoke", "zerofrom", @@ -6689,13 +6698,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6712,27 +6721,27 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" -version = "0.14.0" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "8.0.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 764fbd53..ab30358f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,7 @@ dashmap = "6.2.1" # Async futures futures = "0.3.34" -tokio = { version = "1.53.2", features = [ +tokio = { version = "1.54.1", features = [ "fs", "io-util", "net", @@ -100,14 +100,14 @@ tokio = { version = "1.53.2", features = [ "signal", "time", ] } -tokio-util = { version = "0.7.19", features = ["compat"] } +tokio-util = { version = "0.7.20", features = ["compat"] } # A generic serialization/deserialization framework serde = { version = "1.0.229", features = ["derive"] } -serde_json = "1.0.151" +serde_json = "1.0.154" # A safe, extensible ORM and Query builder -diesel = { version = "2.3.13", features = ["chrono", "r2d2", "numeric"] } +diesel = { version = "2.3.14", features = ["chrono", "r2d2", "numeric"] } diesel_migrations = "2.3.2" derive_more = { version = "2.1.1", features = [ @@ -125,11 +125,11 @@ libsqlite3-sys = { version = "0.38.2", optional = true } # Crypto-related libraries rand = "0.10.3" ring = "0.17.14" -rustls = { version = "0.23.45", features = ["ring", "std"], default-features = false } +rustls = { version = "0.23.46", features = ["ring", "std"], default-features = false } subtle = "2.6.1" # UUID generation -uuid = { version = "1.27.0", features = ["v4"] } +uuid = { version = "1.28.0", features = ["v4"] } # Date and time libraries chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } @@ -215,7 +215,7 @@ bytes = "1.12.1" svg-hush = "0.9.7" # Cache function results (Used for version check and favicon fetching) -cached = { version = "4.0.1", features = ["async"] } +cached = { version = "4.0.3", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction cookie = "0.18.2" @@ -257,18 +257,18 @@ rpassword = "7.5.4" grass_compiler = { version = "0.13.4", default-features = false } # File are accessed through Apache OpenDAL -opendal = { version = "0.59.4", default-features = false, features = ["services-fs"] } -opendal-http-transport-reqwest = { version = "0.59.4", default-features = false, features = ["rustls-no-provider"], optional = true } +opendal = { version = "0.59.5", default-features = false, features = ["services-fs"] } +opendal-http-transport-reqwest = { version = "0.59.5", default-features = false, features = ["rustls-no-provider"], optional = true } # For retrieving AWS credentials, including temporary SSO credentials -aws-config = { version = "1.12.0", optional = true, default-features = false, features = [ +aws-config = { version = "1.13.0", optional = true, default-features = false, features = [ "behavior-version-latest", "credentials-process", "rt-tokio", "sso", ] } -aws-credential-types = { version = "1.3.0", optional = true } -aws-smithy-runtime-api = { version = "1.19.0", optional = true } +aws-credential-types = { version = "1.3.1", optional = true } +aws-smithy-runtime-api = { version = "1.19.1", optional = true } http = { version = "1.5.0", optional = true } reqsign-aws-v4 = { version = "3.3.1", optional = true } reqsign-core = { version = "3.3.2", optional = true } diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index dbe0ac92..e8bd0b2e 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,10 +1,10 @@ --- vault_version: "v2026.8.0" vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a" -# Cross Compile Docker Helper Scripts v1.9.0 +# Cross Compile Docker Helper Scripts v1.10.0 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags -xx_image_digest: "sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707" +xx_image_digest: "sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6" # The `rust_version` variable is extracted from `rust-toolchain.toml` # rust_version: x.yy.z # Rust version to be used debian_version: trixie # Debian release name to be used diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index be267da5..41bea5cf 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -32,10 +32,10 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c0 ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 ## And for Alpine we define all build images here, they will only be loaded when actually used -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.0 AS build_amd64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.0 AS build_arm64 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.0 AS build_armv7 -FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.0 AS build_armv6 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:x86_64-musl-stable-1.99.1 AS build_amd64 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:aarch64-musl-stable-1.99.1 AS build_arm64 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:armv7-musleabihf-stable-1.99.1 AS build_armv7 +FROM --platform=$BUILDPLATFORM ghcr.io/blackdex/rust-musl:arm-musleabi-stable-1.99.1 AS build_armv6 ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index ac82c24b..154af7ef 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -32,11 +32,11 @@ FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c0 ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts ## And these bash scripts do not have any significant difference if at all -FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:c64defb9ed5a91eacb37f96ccc3d4cd72521c4bd18d5442905b95e2226b0e707 AS xx +FROM --platform=linux/amd64 docker.io/tonistiigi/xx@sha256:40fe21f44bb8cddf1eb1226db190d0a984118f62ee02a91b77d99448d28264a6 AS xx ########################## BUILD IMAGE ########################## # hadolint ignore=DL3006 -FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.0-slim-trixie AS build +FROM --platform=$BUILDPLATFORM docker.io/library/rust:1.99.1-slim-trixie AS build # hadolint ignore=DL3067 COPY --from=xx / / ARG TARGETARCH diff --git a/macros/Cargo.toml b/macros/Cargo.toml index b397fbe1..ee9fd206 100644 --- a/macros/Cargo.toml +++ b/macros/Cargo.toml @@ -14,7 +14,7 @@ proc-macro = true [dependencies] quote = "1.0.47" -syn = "3.0.6" +syn = "3.0.9" [lints] workspace = true diff --git a/rust-toolchain.toml b/rust-toolchain.toml index a66db95c..b195f645 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.99.0" +channel = "1.99.1" components = [ "rustfmt", "clippy" ] profile = "minimal" diff --git a/src/static/scripts/datatables.css b/src/static/scripts/datatables.css index 20f6d63f..81827eed 100644 --- a/src/static/scripts/datatables.css +++ b/src/static/scripts/datatables.css @@ -4,10 +4,10 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.1.3 + * https://datatables.net/download/#bs5/dt-3.1.4 * * Included libraries: - * DataTables 3.1.3 + * DataTables 3.1.4 */ /*! DataTables Bootstrap 5 integration diff --git a/src/static/scripts/datatables.js b/src/static/scripts/datatables.js index b0502a69..8dc93bd6 100644 --- a/src/static/scripts/datatables.js +++ b/src/static/scripts/datatables.js @@ -4,13 +4,13 @@ * * To rebuild or modify this file with the latest versions of the included * software please visit: - * https://datatables.net/download/#bs5/dt-3.1.3 + * https://datatables.net/download/#bs5/dt-3.1.4 * * Included libraries: - * DataTables 3.1.3 + * DataTables 3.1.4 */ -/*! DataTables 3.1.3 +/*! DataTables 3.1.4 * Copyright (c) SpryMedia Ltd - datatables.net/license */ @@ -453,6 +453,9 @@ function assignDeep(out, ...inputs) { continue; } for (const [key, value] of Object.entries(input)) { + if (key === '__proto__') { + continue; + } if (Array.isArray(value)) { if (!Array.isArray(out[key])) { out[key] = []; @@ -489,6 +492,9 @@ function assignDeep(out, ...inputs) { function assignDeepObjects(out, extender, breakRefs = false) { let val; for (let prop in extender) { + if (prop === '__proto__') { + continue; + } if (Object.prototype.hasOwnProperty.call(extender, prop)) { val = extender[prop]; if (plainObject(val)) { @@ -2694,7 +2700,7 @@ class Dom { */ prepend(content) { return this.each(el => { - if (content instanceof Dom) { + if (arrayLike(content) || dom(content)) { // Reverse the array, so if there are multiple elements, they // end up being added sequentially, just like jQuery let itemsReversed = Array.from(content).reverse(); @@ -8714,7 +8720,7 @@ const ext = { /** * DataTables build type (expanded by the download builder) */ - builder: 'bs5/dt-3.1.3', + builder: 'bs5/dt-3.1.4', /** * Buttons. For use with the Buttons extension for DataTables. This is * defined here so other extensions can define buttons regardless of load @@ -8860,7 +8866,7 @@ const ext = { * Software version * @type string */ - version: '3.1.3' + version: '3.1.4' }; // // Backwards compatibility. Alias to pre 1.10 Hungarian notation counter parts @@ -9559,7 +9565,7 @@ register('destroy()', function (remove) { callbackFire(settings, 'destroy', 'destroy', [settings], true); // If not being removed from the document, make all columns visible if (!remove) { - new Api(settings).columns().visible(); + new Api(settings).columns().visible(true); } // Container width change listener if (settings.resizeObserver) { From b71cb7a832080d9fdfdd97bcccf065307cf73236 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Thu, 22 Oct 2026 14:20:03 +0200 Subject: [PATCH 19/43] Misc fixes regarding jobs and config (#7858) * Fix several scheduled job issues Several jobs could cause a panic, or stop without running through all items because of this panic. This could cause missed items until that panic somehow resolved it self, and probably would never work fully again. This could cause emergency access for example to never work again. Besides this, emergency access was also able to be panic if the wait-time was out-of-bounds and date/time calculation might fail. This is also fixed now by using `checked` functions and continue upon those issues so all awaiting items will be handled. Signed-off-by: BlackDex * Fix several possible panics and overflows Adjust several integer types to be a lower or unsigned type. Most types were too large, only because the function which used this value needed that specific type. But, this allowed too large values which either caused panics or might cause a DoS. This does however might possible cause configurations which have larger values, which are probably way too large anyway to be not loaded or use their defaults. During the config loading, either via env or json, it will show error logs regarding these items. Also fixed an issue with protected actions via OTP, it increased the attempt before it validated if the attempt limit was reached. This could cause an issue when set to `1`, that way it was never allowed, and `3` times would actually be `2`. Also added several config validation checks which otherwise would have prevent Vaultwarden to start, or login, so these will probably not cause issues, else users would already have issues. Signed-off-by: BlackDex * Show config errors on diagnostics page With the previous commits it is now possible to add a warning if their rare issues with loading of the config, either environment variables which we were not able to parse correctly, or if loading the `config.json` failed instead of just showing it wasn't used, while it does exists, which might be confusing. Signed-off-by: BlackDex --------- Signed-off-by: BlackDex --- .env.template | 6 +- src/api/admin.rs | 11 +- src/api/core/emergency_access.rs | 282 ++++++++++++------- src/api/core/events.rs | 13 +- src/api/core/two_factor/email.rs | 4 +- src/api/core/two_factor/mod.rs | 18 +- src/api/core/two_factor/protected_actions.rs | 9 +- src/api/icons.rs | 6 +- src/api/identity.rs | 4 +- src/auth.rs | 66 +++-- src/config.rs | 144 +++++++--- src/db/mod.rs | 6 +- src/db/models/auth_request.rs | 2 +- src/db/models/cipher.rs | 22 +- src/db/models/emergency_access.rs | 67 +++-- src/db/models/event.rs | 28 +- src/db/models/mod.rs | 4 +- src/db/models/two_factor_incomplete.rs | 4 +- src/mail.rs | 12 +- src/main.rs | 6 +- src/ratelimit.rs | 12 +- src/sso_client.rs | 2 +- src/static/scripts/admin_diagnostics.js | 11 +- src/static/templates/admin/diagnostics.hbs | 20 +- src/util.rs | 26 +- 25 files changed, 529 insertions(+), 256 deletions(-) diff --git a/.env.template b/.env.template index f729c92c..057f8fa8 100644 --- a/.env.template +++ b/.env.template @@ -78,7 +78,7 @@ # DB_CONNECTION_RETRIES=15 ## Database timeout -## Timeout when acquiring database connection +## Timeout in seconds when acquiring database connection # DATABASE_TIMEOUT=30 ## Database idle timeout @@ -173,7 +173,7 @@ ## Defaults to daily. Set blank to disable this job. Also without EVENTS_DAYS_RETAIN set, this job will not start. # EVENT_CLEANUP_SCHEDULE="0 10 0 * * *" ## Number of days to retain events stored in the database. -## If unset (the default), events are kept indefinitely and the scheduled job is disabled! +## If unset (the default) or 0, events are kept indefinitely and the scheduled job is disabled! # EVENTS_DAYS_RETAIN= ## ## Cron schedule of the job that cleans old auth requests from the auth request. @@ -233,7 +233,7 @@ # USER_SEND_LIMIT= ## Number of days to wait before auto-deleting a trashed item. -## If unset (the default), trashed items are not auto-deleted. +## If unset (the default) or 0, trashed items are not auto-deleted. ## This setting applies globally, so make sure to inform all users of any changes to this setting. # TRASH_AUTO_DELETE_DAYS= diff --git a/src/api/admin.rs b/src/api/admin.rs index 2a84f611..13fbe87d 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -201,12 +201,15 @@ fn post_admin_login( // If the token is invalid, redirect to login page if validate_token(&data.token) { // If the token received is valid, generate JWT and save it as a cookie - let claims = generate_admin_claims(); + let claims = match generate_admin_claims() { + Ok(claims) => claims, + Err(e) => return Err(AdminResponse::Ok(render_admin_login(Some(e.message()), redirect.as_deref()))), + }; let jwt = encode_jwt(&claims); let cookie = Cookie::build((COOKIE_NAME, jwt)) .path(admin_path()) - .max_age(time::Duration::minutes(CONFIG.admin_session_lifetime())) + .max_age(time::Duration::minutes(i64::from(CONFIG.admin_session_lifetime()))) .same_site(SameSite::Strict) .http_only(true) .secure(secure.https); @@ -779,6 +782,8 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A .into_keys() .collect(); + let invalid_env_vars = crate::util::INVALID_ENV_VARS.lock().unwrap().join(", "); + let diagnostics_json = json!({ "dns_resolved": dns_resolved, "current_release": VERSION, @@ -801,7 +806,9 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, conn: DbConn) -> A "db_version": get_sql_server_version(&conn).await, "admin_url": format!("{}/diagnostics", admin_url()), "overrides": &CONFIG.get_overrides().join(", "), + "config_file_status": &CONFIG.get_config_file_status(), "template_overrides": check_template_overrides().join(", "), + "invalid_env_vars": invalid_env_vars, "invalid_feature_flags": invalid_feature_flags, "host_arch": env::consts::ARCH, "host_os": env::consts::OS, diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 5813ee69..773697e5 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -1,4 +1,4 @@ -use chrono::{TimeDelta, Utc}; +use chrono::Utc; use rocket::{Route, serde::json::Json}; use serde_json::Value; @@ -15,8 +15,8 @@ use crate::{ db::{ DbConn, DbPool, models::{ - Cipher, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, Invitation, - Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, + Cipher, EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, + EmergencyAccessType, Invitation, Membership, MembershipType, OrgPolicy, TwoFactor, User, UserId, }, }, mail, @@ -136,19 +136,21 @@ async fn post_emergency_access( let data: EmergencyAccessUpdateData = data.into_inner(); + let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else { + err!("Invalid emergency access type.") + }; + + if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&data.wait_time_days) { + err!("Emergency access wait time is out-of-bounds!") + } + let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { err!("Emergency access not valid.") }; - let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { - new_type as i32 - } else { - err!("Invalid emergency access type.") - }; - - emergency_access.atype = new_type; + emergency_access.atype = new_type as i32; emergency_access.wait_time_days = data.wait_time_days; if data.key_encrypted.is_some() { emergency_access.key_encrypted = data.key_encrypted; @@ -210,11 +212,11 @@ async fn send_invite(data: Json, headers: Headers, co let email = data.email.to_lowercase(); let wait_time_days = data.wait_time_days; - let emergency_access_status = EmergencyAccessStatus::Invited as i32; + if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&wait_time_days) { + err!("Emergency access wait time is out-of-bounds!") + } - let new_type = if let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) { - new_type as i32 - } else { + let Some(new_type) = EmergencyAccessType::from_str(&data.r#type.into_string()) else { err!("Invalid emergency access type.") }; @@ -260,6 +262,8 @@ async fn send_invite(data: Json, headers: Headers, co err!(format!("Grantee user already invited: {}", &grantee_user.email)) } + let new_type = new_type as i32; + let emergency_access_status = EmergencyAccessStatus::Invited as i32; let mut new_emergency_access = EmergencyAccess::new(grantor_user.uuid, grantee_user.email, emergency_access_status, new_type, wait_time_days); new_emergency_access.save(&conn).await?; @@ -292,7 +296,7 @@ async fn resend_invite(emer_id: EmergencyAccessId, headers: Headers, conn: DbCon }; if emergency_access.status != EmergencyAccessStatus::Invited as i32 { - err!("The grantee user is already accepted or confirmed to the organization"); + err!("The grantee user is already accepted or confirmed for emergency access"); } let Some(email) = emergency_access.email.clone() else { @@ -453,11 +457,24 @@ async fn initiate_emergency_access(emer_id: EmergencyAccessId, headers: Headers, let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantee_uuid(&emer_id, &initiating_user.uuid, &conn).await else { - err!("Emergency access not valid.") + err!( + "Emergency access not valid.", + format!("Emergency access '{emer_id}', not found for user '{}'.", initiating_user.email) + ) }; if emergency_access.status != EmergencyAccessStatus::Confirmed as i32 { - err!("Emergency access not valid.") + err!( + "Emergency access not valid.", + format!("Emergency access '{emer_id}', not confirmed for user '{}'.", initiating_user.email) + ) + } + + if !EMERGENCY_ACCESS_TIMEOUT_RANGE.contains(&emergency_access.wait_time_days) { + err!( + "Emergency access not valid.", + format!("Emergency access '{emer_id}' timeout range '{}' is invalid", emergency_access.wait_time_days) + ) } let Some(grantor_user) = User::find_by_uuid(&emergency_access.grantor_uuid, &conn).await else { @@ -490,11 +507,17 @@ async fn approve_emergency_access(emer_id: EmergencyAccessId, headers: Headers, let Some(mut emergency_access) = EmergencyAccess::find_by_uuid_and_grantor_uuid(&emer_id, &headers.user.uuid, &conn).await else { - err!("Emergency access not valid.") + err!( + "Emergency access not valid.", + format!("Emergency access '{emer_id}', not found for user '{}'.", headers.user.email) + ) }; if emergency_access.status != EmergencyAccessStatus::RecoveryInitiated as i32 { - err!("Emergency access not valid.") + err!( + "Emergency access not valid.", + format!("Emergency access '{emer_id}', not initiated for user '{}'.", headers.user.email) + ) } let Some(grantor_user) = User::find_by_uuid(&headers.user.uuid, &conn).await else { @@ -747,52 +770,71 @@ pub async fn emergency_request_timeout_job(pool: DbPool) { return; } - if let Ok(conn) = pool.get().await { - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; + let Ok(conn) = pool.get().await else { + error!("Failed to get DB connection for emergency time-outs!"); + return; + }; - if emergency_access_list.is_empty() { - debug!("No emergency request timeout to approve"); - } + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; - let now = Utc::now().naive_utc(); - for mut emer in emergency_access_list { - // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) - let recovery_allowed_at = - emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days)).unwrap(); - if recovery_allowed_at.le(&now) { - // Only update the access status - // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active - emer.update_access_status_and_save(EmergencyAccessStatus::RecoveryApproved as i32, &now, &conn) - .await - .expect("Unable to update emergency access status"); + if emergency_access_list.is_empty() { + debug!("No emergency request timeout to approve"); + return; + } - if CONFIG.mail_enabled() { - // get grantor user to send Accepted email - let grantor_user = - User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); - - // get grantee user to send Accepted email - let grantee_user = - User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) - .await - .expect("Grantee user not found"); - - mail::send_emergency_access_recovery_timed_out( - &grantor_user.email, - &grantee_user.name, - emer.get_type_as_str(), - ) - .await - .expect("Error on sending email"); - - mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name) - .await - .expect("Error on sending email"); - } + let now = Utc::now().naive_utc(); + for mut emer in emergency_access_list { + match emer.is_recovery_allowed(&now) { + Ok(true) => {} // Recovery is allowed + Ok(false) => continue, + Err(e) => { + error!("Failed to check if recovery is allowed for '{}': {e}", emer.uuid); + continue; + } + } + + // Get grantor user to send Accepted email + let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else { + error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid); + continue; + }; + + let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else { + error!("Missing grantee for emergency access '{}'", emer.uuid); + continue; + }; + + // Get grantee user to send Accepted email + let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { + error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid); + continue; + }; + + // Only update the access status + // Updating the whole record could cause issues when the emergency_notification_reminder_job is also active + let status_initiated = EmergencyAccessStatus::RecoveryInitiated as i32; + let status_approved = EmergencyAccessStatus::RecoveryApproved as i32; + if let Err(e) = emer.update_access_status_and_save(status_initiated, status_approved, &now, &conn).await { + error!("Unable to update emergency access status for '{}': {e}", emer.uuid); + continue; + } + + if CONFIG.mail_enabled() { + if let Err(e) = mail::send_emergency_access_recovery_timed_out( + &grantor_user.email, + &grantee_user.name, + emer.get_type_as_str(), + ) + .await + { + error!("Failed to send emergency access recovery time-out email for '{}': {e}", emer.uuid); + } + + if let Err(e) = mail::send_emergency_access_recovery_approved(&grantee_user.email, &grantor_user.name).await + { + error!("Failed to send emergency access recovery approved email for '{}': {e}", emer.uuid); } } - } else { - error!("Failed to get DB connection while searching emergency request timed out"); } } @@ -802,55 +844,85 @@ pub async fn emergency_notification_reminder_job(pool: DbPool) { return; } - if let Ok(conn) = pool.get().await { - let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; + let Ok(conn) = pool.get().await else { + error!("Failed to get DB connection for emergency reminders!"); + return; + }; - if emergency_access_list.is_empty() { - debug!("No emergency request reminder notification to send"); + let emergency_access_list = EmergencyAccess::find_all_recoveries_initiated(&conn).await; + + if emergency_access_list.is_empty() { + debug!("No emergency request reminder notification to send"); + return; + } + + let now = Utc::now().naive_utc(); + for mut emer in emergency_access_list { + let Ok(deadline_at) = emer.deadline_at() else { + error!("Invalid deadline for emergency access '{}'", emer.uuid); + continue; + }; + + let Some(final_reminder_at) = deadline_at.checked_sub_days(chrono::Days::new(1)) else { + error!("Cannot calculate reminder date for emergency access '{}'", emer.uuid); + continue; + }; + + // Only send reminders during the last 24 hours, else continue + if now < final_reminder_at || now >= deadline_at { + continue; } - let now = Utc::now().naive_utc(); - for mut emer in emergency_access_list { - // The find_all_recoveries_initiated already checks if the recovery_initiated_at is not null (None) - // Calculate the day before the recovery will become active - let final_recovery_reminder_at = - emer.recovery_initiated_at.unwrap() + TimeDelta::try_days(i64::from(emer.wait_time_days - 1)).unwrap(); - // Calculate if a day has passed since the previous notification, else no notification has been sent before - let next_recovery_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { - last_notification_at + TimeDelta::try_days(1).unwrap() - } else { - now - }; - if final_recovery_reminder_at.le(&now) && next_recovery_reminder_at.le(&now) { - // Only update the last notification date - // Updating the whole record could cause issues when the emergency_request_timeout_job is also active - emer.update_last_notification_date_and_save(&now, &conn) - .await - .expect("Unable to update emergency access notification date"); + // Check if we did not sent a recovery mail in the past 24h already, else skip it. + // In case something goes wrong with the date calculation or `last_notification_at` is None we use now. + // This is to prevent possible missed reminders, this might cause an extra reminder, + // but that is better then no reminder at all. + let next_reminder_at = if let Some(last_notification_at) = emer.last_notification_at { + last_notification_at.checked_add_days(chrono::Days::new(1)).unwrap_or(now) + } else { + now + }; - if CONFIG.mail_enabled() { - // get grantor user to send Accepted email - let grantor_user = - User::find_by_uuid(&emer.grantor_uuid, &conn).await.expect("Grantor user not found"); - - // get grantee user to send Accepted email - let grantee_user = - User::find_by_uuid(&emer.grantee_uuid.clone().expect("Grantee user invalid"), &conn) - .await - .expect("Grantee user not found"); - - mail::send_emergency_access_recovery_reminder( - &grantor_user.email, - &grantee_user.name, - emer.get_type_as_str(), - "1", // This notification is only triggered one day before the activation - ) - .await - .expect("Error on sending email"); - } - } + // Only send a reminder if the `next_reminder_at` is less then or equal to `now` + // Else we can skip the current emergency access this run. + if next_reminder_at.gt(&now) { + continue; + } + + // Get grantor user to send Reminder email + let Some(grantor_user) = User::find_by_uuid(&emer.grantor_uuid, &conn).await else { + error!("Grantor user '{}' not found for emergency access '{}'", emer.grantor_uuid, emer.uuid); + continue; + }; + + let Some(grantee_uuid) = emer.grantee_uuid.as_ref() else { + error!("Missing grantee for emergency access '{}'", emer.uuid); + continue; + }; + + // Get grantee user to send Reminder email + let Some(grantee_user) = User::find_by_uuid(grantee_uuid, &conn).await else { + error!("Grantee user '{grantee_uuid}' not found for emergency access '{}'", emer.uuid); + continue; + }; + + // Only update the last notification date + // Updating the whole record could cause issues when the emergency_request_timeout_job is also active + if let Err(e) = emer.update_last_notification_date_and_save(&now, &conn).await { + error!("Unable to update emergency access reminder date for '{}': {e}", emer.uuid); + continue; + } + + if CONFIG.mail_enabled() + && let Err(e) = mail::send_emergency_access_recovery_reminder( + &grantor_user.email, + &grantee_user.name, + emer.get_type_as_str(), + "1", // This notification is only triggered one day before the activation + ) + .await + { + error!("Failed to send emergency access reminder email for '{}': {e}", emer.uuid); } - } else { - error!("Failed to get DB connection while searching emergency notification reminder"); } } diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 15ec11a0..0d9f32ad 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -347,14 +347,17 @@ async fn log_event_impl( pub async fn event_cleanup_job(pool: DbPool) { debug!("Start events cleanup job"); - if CONFIG.events_days_retain().is_none() { - debug!("events_days_retain is not configured, abort"); + if CONFIG.events_days_retain().is_none_or(|d| d == 0) { + debug!("events_days_retain is not configured or set to 0, skipping"); return; } - if let Ok(conn) = pool.get().await { - Event::clean_events(&conn).await.ok(); - } else { + let Ok(conn) = pool.get().await else { error!("Failed to get DB connection while trying to cleanup the events table"); + return; + }; + + if let Err(e) = Event::clean_events(&conn).await { + error!("Failed to cleanup the events table: {e}"); } } diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 531eea58..66f2a89e 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -294,7 +294,7 @@ pub async fn validate_email_code_str( if !crypto::ct_eq(issued_token, token) { email_data.add_attempt(); - if email_data.attempts >= CONFIG.email_attempts_limit() { + if email_data.attempts >= u64::from(CONFIG.email_attempts_limit()) { email_data.reset_token(); } twofactor.data = email_data.to_json(); @@ -313,7 +313,7 @@ pub async fn validate_email_code_str( twofactor.save(conn).await?; let dt = DateTime::from_timestamp(email_data.token_sent, 0).expect("Email token timestamp invalid.").naive_utc(); - let max_time = CONFIG.email_expiration_time().cast_signed(); + let max_time = i64::from(CONFIG.email_expiration_time()); if dt + TimeDelta::try_seconds(max_time).unwrap() < Utc::now().naive_utc() { err!( "Token has expired", diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index 61cb05df..db533b50 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -196,7 +196,7 @@ pub async fn enforce_2fa_policy_for_org( pub async fn send_incomplete_2fa_notifications(pool: DbPool) { debug!("Sending notifications for incomplete 2FA logins"); - if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { return; } @@ -206,11 +206,21 @@ pub async fn send_incomplete_2fa_notifications(pool: DbPool) { }; let now = Utc::now().naive_utc(); - let time_limit = TimeDelta::try_minutes(CONFIG.incomplete_2fa_time_limit()).unwrap(); - let time_before = now - time_limit; + let Some(time_limit) = TimeDelta::try_minutes(i64::from(CONFIG.incomplete_2fa_time_limit())) else { + error!("Failed to calculate the time limit for sending incomplete 2fa attempts"); + return; + }; + let Some(time_before) = now.checked_sub_signed(time_limit) else { + error!("Failed to calculate the timestamp for sending incomplete 2fa attempts"); + return; + }; + let incomplete_logins = TwoFactorIncomplete::find_logins_before(&time_before, &conn).await; for login in incomplete_logins { - let user = User::find_by_uuid(&login.user_uuid, &conn).await.expect("User not found"); + let Some(user) = User::find_by_uuid(&login.user_uuid, &conn).await else { + error!("User '{}', not found!", login.user_uuid); + continue; + }; info!( "User {} did not complete a 2FA login within the configured time limit. IP: {}", user.email, login.ip_address diff --git a/src/api/core/two_factor/protected_actions.rs b/src/api/core/two_factor/protected_actions.rs index 1cafede0..bf757d14 100644 --- a/src/api/core/two_factor/protected_actions.rs +++ b/src/api/core/two_factor/protected_actions.rs @@ -128,23 +128,24 @@ pub async fn validate_protected_action_otp( .await .map_res("Protected action token not found, try sending the code again or restart the process")?; let mut pa_data = ProtectedActionData::from_json(&pa.data)?; - pa_data.add_attempt(); - pa.data = pa_data.to_json(); // Fail after x attempts if the token has been used too many times. // Don't delete it, as we use it to keep track of attempts. - if pa_data.attempts >= CONFIG.email_attempts_limit() { + if pa_data.attempts >= u64::from(CONFIG.email_attempts_limit()) { err!("Token has expired") } // Check if the token has expired (Using the email 2fa expiration time) - let max_time = CONFIG.email_expiration_time().cast_signed(); + let max_time = i64::from(CONFIG.email_expiration_time()); if pa_data.time_since_sent().num_seconds() > max_time { pa.delete(conn).await?; err!("Token has expired") } if !crypto::ct_eq(&pa_data.token, otp) { + pa_data.add_attempt(); + pa.data = pa_data.to_json(); + pa.save(conn).await?; err!("Token is invalid") } diff --git a/src/api/icons.rs b/src/api/icons.rs index 39a259a9..25ce82a0 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -62,7 +62,7 @@ static CLIENT: LazyLock = LazyLock::new(|| { // Generate the cookie store let cookie_store = Arc::new(Jar::default()); - let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); + let icon_download_timeout = Duration::from_secs(u64::from(CONFIG.icon_download_timeout())); let pool_idle_timeout = Duration::from_secs(10); // Reuse the client between requests get_reqwest_client_builder(true) @@ -193,14 +193,14 @@ async fn get_cached_icon(path: &str) -> Option> { None } -async fn file_is_expired(path: &str, ttl: u64) -> Result { +async fn file_is_expired(path: &str, ttl: u32) -> Result { let operator = CONFIG.opendal_operator_for_path_type(&PathType::IconCache)?; let meta = operator.stat(path).await?; let modified = meta.last_modified().ok_or_else(|| std::io::Error::other(format!("No last modified time for `{path}`")))?; let age = SystemTime::now().duration_since(modified.into())?; - Ok(ttl > 0 && ttl <= age.as_secs()) + Ok(ttl > 0 && u64::from(ttl) <= age.as_secs()) } async fn icon_is_negcached(path: &str) -> bool { diff --git a/src/api/identity.rs b/src/api/identity.rs index b61258cf..a7a18cf7 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -447,9 +447,9 @@ async fn password_login(data: ConnectData, user_id: &mut Option, conn: & if user.verified_at.is_none() && CONFIG.mail_enabled() && CONFIG.signups_verify() { if user.last_verifying_at.is_none() || now.signed_duration_since(user.last_verifying_at.unwrap()).num_seconds() - > CONFIG.signups_verify_resend_time().cast_signed() + > i64::from(CONFIG.signups_verify_resend_time()) { - let resend_limit = CONFIG.signups_verify_resend_limit().cast_signed(); + let resend_limit = i32::from(CONFIG.signups_verify_resend_limit()); if resend_limit == 0 || user.login_verify_count < resend_limit { // We want to send another email verification if we require signups to verify // their email address, and we haven't sent them a reminder in a while... diff --git a/src/auth.rs b/src/auth.rs index 57720382..27a156e6 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -314,6 +314,17 @@ impl LoginJwtClaims { } } +fn calculate_invite_expire_hours(now: &DateTime) -> Result, Error> { + let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); + let Some(hours_delta) = TimeDelta::try_hours(expire_hours) else { + err!("Unable to calculate time delta for invite expire hours. Check `INVITATION_EXPIRATION_HOURS`."); + }; + let Some(exp) = now.checked_add_signed(hours_delta) else { + err!("Unable to calculate invitation expire date. Check `INVITATION_EXPIRATION_HOURS`."); + }; + Ok(exp) +} + #[derive(Debug, Serialize, Deserialize)] pub struct InviteJwtClaims { // Not before @@ -337,19 +348,19 @@ pub fn generate_invite_claims( org_id: OrganizationId, member_id: MembershipId, invited_by_email: Option, -) -> InviteJwtClaims { +) -> Result { let time_now = Utc::now(); - let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); - InviteJwtClaims { + let exp = calculate_invite_expire_hours(&time_now)?; + Ok(InviteJwtClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), + exp: exp.timestamp(), iss: JWT_INVITE_ISSUER.to_string(), sub: user_id, email, org_id, member_id, invited_by_email, - } + }) } #[derive(Debug, Serialize, Deserialize)] @@ -375,19 +386,19 @@ pub fn generate_emergency_access_invite_claims( emer_id: EmergencyAccessId, grantor_name: String, grantor_email: String, -) -> EmergencyAccessInviteJwtClaims { +) -> Result { let time_now = Utc::now(); - let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); - EmergencyAccessInviteJwtClaims { + let exp = calculate_invite_expire_hours(&time_now)?; + Ok(EmergencyAccessInviteJwtClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), + exp: exp.timestamp(), iss: JWT_EMERGENCY_ACCESS_INVITE_ISSUER.to_string(), sub: user_id, email, emer_id, grantor_name, grantor_email, - } + }) } #[derive(Debug, Serialize, Deserialize)] @@ -521,36 +532,43 @@ impl BasicJwtClaims { } } -pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { +pub fn generate_delete_claims(uuid: String) -> Result { let time_now = Utc::now(); - let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); - BasicJwtClaims { + let exp = calculate_invite_expire_hours(&time_now)?; + Ok(BasicJwtClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), + exp: exp.timestamp(), iss: JWT_DELETE_ISSUER.to_string(), sub: uuid, - } + }) } -pub fn generate_verify_email_claims(user_id: &UserId) -> BasicJwtClaims { +pub fn generate_verify_email_claims(user_id: &UserId) -> Result { let time_now = Utc::now(); - let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); - BasicJwtClaims { + let exp = calculate_invite_expire_hours(&time_now)?; + Ok(BasicJwtClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_hours(expire_hours).unwrap()).timestamp(), + exp: exp.timestamp(), iss: JWT_VERIFYEMAIL_ISSUER.to_string(), sub: user_id.to_string(), - } + }) } -pub fn generate_admin_claims() -> BasicJwtClaims { +pub fn generate_admin_claims() -> Result { let time_now = Utc::now(); - BasicJwtClaims { + let lifetime_minutes = i64::from(CONFIG.admin_session_lifetime()); + let Some(minutes_delta) = TimeDelta::try_minutes(lifetime_minutes) else { + err!("Unable to calculate time delta for admin session lifetime. Check `ADMIN_SESSION_LIFETIME`."); + }; + let Some(exp) = time_now.checked_add_signed(minutes_delta) else { + err!("Unable to calculate admin session lifetime. Check `ADMIN_SESSION_LIFETIME`."); + }; + Ok(BasicJwtClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_minutes(CONFIG.admin_session_lifetime()).unwrap()).timestamp(), + exp: exp.timestamp(), iss: JWT_ADMIN_ISSUER.to_string(), sub: "admin_panel".to_owned(), - } + }) } pub fn generate_send_claims(send_id: &SendId, file_id: &SendFileId) -> BasicJwtClaims { diff --git a/src/config.rs b/src/config.rs index 9a48f4d3..54b3d056 100644 --- a/src/config.rs +++ b/src/config.rs @@ -111,6 +111,7 @@ macro_rules! make_config { _usr: ConfigBuilder, _overrides: Vec<&'static str>, + _config_file_status: ConfigFileStatus, } // Custom Deserialize for ConfigBuilder, mainly based upon https://serde.rs/deserialize-struct.html @@ -259,11 +260,18 @@ macro_rules! make_config { builder } - async fn from_file() -> Result { + async fn from_file() -> Result, Error> { let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; - let config_bytes = operator.read(&CONFIG_FILENAME).await?; + let config_bytes = match operator.read(&CONFIG_FILENAME).await { + Ok(bytes) => bytes, + // No config file is fine, should not return any error + Err(e) if e.kind() == opendal::ErrorKind::NotFound => return Ok(None), + // Any other error might be an issue and should be passed back to the caller + Err(e) => return Err(e.into()), + }; + let builder = serde_json::from_slice(&config_bytes.to_vec())?; println!("[INFO] Using saved config from `{}` for configuration.\n", *CONFIG_FILE); - serde_json::from_slice(&config_bytes.to_vec()).map_err(Into::into) + Ok(Some(builder)) } fn clear_non_editable(&mut self) { @@ -440,6 +448,8 @@ macro_rules! make_config { "domain_origin", "domain_path", "domain", + "duo_ikey", + "duo_host", "helo_name", "org_creation_users", "signups_domains_whitelist", @@ -451,6 +461,8 @@ macro_rules! make_config { "sso_authority", "sso_callback_path", "sso_client_id", + "yubico_client_id", + "yubico_server", ]; let cfg = { @@ -483,6 +495,10 @@ macro_rules! make_config { }; overrides } + + pub fn get_config_file_status(&self) -> ConfigFileStatus { + self.inner.read().unwrap()._config_file_status + } } }; } @@ -539,7 +555,7 @@ make_config! { jobs { /// Job scheduler poll interval |> How often the job scheduler thread checks for jobs to run. /// Set to 0 to globally disable scheduled jobs. - job_poll_interval_ms: u64, false, def, 30_000; + job_poll_interval_ms: u32, false, def, 30_000; /// Send purge schedule |> Cron schedule of the job that checks for Sends past their deletion date. /// Defaults to hourly. Set blank to disable this job. send_purge_schedule: String, false, def, "0 5 * * * *".to_owned(); @@ -595,19 +611,19 @@ make_config! { /// Per-organization attachment storage limit (KB) |> Max kilobytes of attachment storage allowed per org. When this limit is reached, org members will not be allowed to upload further attachments for ciphers owned by that org. org_attachment_limit: i64, true, option; /// Per-user send storage limit (KB) |> Max kilobytes of sends storage allowed per user. When this limit is reached, the user will not be allowed to upload further sends. - user_send_limit: i64, true, option; + user_send_limit: i64, true, option; /// Trash auto-delete days |> Number of days to wait before auto-deleting a trashed item. - /// If unset, trashed items are not auto-deleted. This setting applies globally, so make + /// If unset or 0, trashed items are not auto-deleted. This setting applies globally, so make /// sure to inform all users of any changes to this setting. - trash_auto_delete_days: i64, true, option; + trash_auto_delete_days: u16, true, option; /// Incomplete 2FA time limit |> Number of minutes to wait before a 2FA-enabled login is /// considered incomplete, resulting in an email notification. An incomplete 2FA login is one /// where the correct master password was provided but the required 2FA step was not completed, /// which potentially indicates a master password compromise. Set to 0 to disable this check. /// This setting applies globally to all users. - incomplete_2fa_time_limit: i64, true, def, 3; + incomplete_2fa_time_limit: u16, true, def, 3; /// 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 @@ -620,9 +636,9 @@ make_config! { /// this will prevent logins from succeeding until the address has been verified signups_verify: bool, true, def, false; /// If signups require email verification, automatically re-send verification email if it hasn't been sent for a while (in seconds) - signups_verify_resend_time: u64, true, def, 3_600; + signups_verify_resend_time: u32, true, def, 3_600; /// If signups require email verification, limit how many emails are automatically sent when login is attempted (0 means no limit) - signups_verify_resend_limit: u32, true, def, 6; + signups_verify_resend_limit: u16, true, def, 6; /// Email domain whitelist |> Allow signups only from this list of comma-separated domains, even when signups are otherwise disabled signups_domains_whitelist: String, true, def, String::new(); /// Enable event logging |> Enables event logging for organizations. @@ -634,7 +650,7 @@ make_config! { invitations_allowed: bool, true, def, true; /// Invitation token expiration time (in hours) |> The number of hours after which an organization invite token, emergency access invite token, /// email verification token and deletion request token will expire (must be at least 1) - invitation_expiration_hours: u32, false, def, 120; + invitation_expiration_hours: u16, false, def, 120; /// Enable emergency access |> Controls whether users can enable emergency access to their accounts. This setting applies globally to all users. emergency_access_allowed: bool, true, def, true; /// Allow email change |> Controls whether users can change their email. This setting applies globally to all users. @@ -655,8 +671,8 @@ make_config! { /// Invitation organization name |> Name shown in the invitation emails that don't come from a specific organization invitation_org_name: String, true, def, "Vaultwarden".to_owned(); - /// Events days retain |> Number of days to retain events stored in the database. If unset, events are kept indefinitely. - events_days_retain: i64, false, option; + /// Events days retain |> Number of days to retain events stored in the database. If unset or 0, events are kept indefinitely. + events_days_retain: u16, false, option; }, client { @@ -694,13 +710,13 @@ make_config! { /// Temporary redirects are useful while testing different icon services, but once a service /// has been decided on, consider using permanent redirects for cacheability. The legacy codes /// are currently better supported by the Bitwarden clients. - icon_redirect_code: u32, true, def, 302; + icon_redirect_code: u16, true, def, 302; /// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be refreshed - icon_cache_ttl: u64, true, def, 2_592_000; + icon_cache_ttl: u32, true, def, 2_592_000; /// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again. - icon_cache_negttl: u64, true, def, 259_200; + icon_cache_negttl: u32, true, def, 259_200; /// Icon download timeout |> Number of seconds when to stop attempting to download an icon. - icon_download_timeout: u64, true, def, 10; + icon_download_timeout: u16, true, def, 10; /// [Deprecated] Icon blacklist Regex |> Use `http_request_block_regex` instead icon_blacklist_regex: String, false, option; @@ -751,11 +767,11 @@ make_config! { /// Max database connection retries |> Number of times to retry the database connection during startup, with 1 second between each retry, set to 0 to retry indefinitely db_connection_retries: u32, false, def, 15; - /// Timeout when acquiring database connection - database_timeout: u64, false, def, 30; + /// Timeout in seconds when acquiring database connection + database_timeout: u32, false, def, 30; /// Timeout in seconds before idle connections to the database are closed - database_idle_timeout: u64, false, def, 600; + database_idle_timeout: u32, false, def, 600; /// Database connection max pool size database_max_conns: u32, false, def, 10; @@ -776,22 +792,22 @@ make_config! { allowed_connect_src: String, true, def, String::new(); /// Seconds between login requests |> Number of seconds, on average, between login and 2FA requests from the same IP address before rate limiting kicks in - login_ratelimit_seconds: u64, false, def, 60; + login_ratelimit_seconds: u16, false, def, 60; /// Max burst size for login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `login_ratelimit_seconds`. Note that this applies to both the login and the 2FA, so it's recommended to allow a burst size of at least 2 - login_ratelimit_max_burst: u32, false, def, 10; + login_ratelimit_max_burst: u16, false, def, 10; /// Seconds between unauthenticated requests |> Number of seconds, on average, between requests from the same IP address to any of the rate limited unauthenticated endpoints - unauthenticated_ratelimit_seconds: u64, false, def, 60; + unauthenticated_ratelimit_seconds: u16, false, def, 60; /// Max burst size for unauthenticated requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `unauthenticated_ratelimit_seconds`. This is shared between several endpoints, so it needs to be more lenient than the login one - unauthenticated_ratelimit_max_burst: u32, false, def, 50; + unauthenticated_ratelimit_max_burst: u16, false, def, 50; /// Seconds between admin login requests |> Number of seconds, on average, between admin requests from the same IP address before rate limiting kicks in - admin_ratelimit_seconds: u64, false, def, 300; + admin_ratelimit_seconds: u16, false, def, 300; /// Max burst size for admin login requests |> Allow a burst of requests of up to this size, while maintaining the average indicated by `admin_ratelimit_seconds` - admin_ratelimit_max_burst: u32, false, def, 3; + admin_ratelimit_max_burst: u16, false, def, 3; /// Admin session lifetime |> Set the lifetime of admin sessions to this value (in minutes). - admin_session_lifetime: i64, true, def, 20; + admin_session_lifetime: u32, true, def, 20; /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; @@ -844,8 +860,8 @@ make_config! { sso_master_password_policy: String, true, option; /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) sso_auth_only_not_session: bool, true, def, false; - /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache - sso_client_cache_expiration: u64, true, def, 0; + /// Client cache for discovery endpoint. |> Duration in seconds (0 to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache + sso_client_cache_expiration: u32, true, def, 0; /// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required sso_debug_tokens: bool, true, def, false; }, @@ -903,11 +919,11 @@ make_config! { /// SMTP Auth mechanism |> Defaults for SSL is "Plain" and "Login" and nothing for Non-SSL connections. Possible values: ["Plain", "Login", "Xoauth2"]. Multiple options need to be separated by a comma ','. smtp_auth_mechanism: String, true, option; /// SMTP connection timeout |> Number of seconds when to stop trying to connect to the SMTP server - smtp_timeout: u64, true, def, 15; + smtp_timeout: u16, true, def, 15; /// Server name sent during HELO |> By default this value should be the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; /// Embed images as email attachments. - smtp_embed_images: bool, true, def, true; + smtp_embed_images: bool, true, def, true; /// _smtp_img_src _smtp_img_src: String, false, generated, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! @@ -925,9 +941,9 @@ make_config! { /// Email token size |> Number of digits in an email 2FA token (min: 6, max: 255). Note that the Bitwarden clients are hardcoded to mention 6 digit codes regardless of this setting. email_token_size: u8, true, def, 6; /// Token expiration time |> Maximum time in seconds a token is valid. The time the user has to open email client and copy token. - email_expiration_time: u64, true, def, 600; + email_expiration_time: u32, true, def, 600; /// Maximum attempts |> Maximum attempts before an email token is reset and a new email will need to be sent - email_attempts_limit: u64, true, def, 3; + email_attempts_limit: u8, true, def, 3; /// Setup email 2FA at signup |> Setup email 2FA provider on registration regardless of any organization policy email_2fa_enforce_on_verified_invite: bool, true, def, false; /// Auto-enable 2FA (Know the risks!) |> Automatically setup email 2FA as fallback provider when needed @@ -983,7 +999,15 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { } if cfg.database_min_conns > cfg.database_max_conns { - err!("`DATABASE_MIN_CONNS` must be smaller than or equal to `DATABASE_MAX_CONNS`."); + err!("`DATABASE_MIN_CONNS` must be less than or equal to `DATABASE_MAX_CONNS`."); + } + + if cfg.database_timeout < 1 { + err!("`DATABASE_TIMEOUT` must be at least 1 second"); + } + + if cfg.database_idle_timeout < 1 { + err!("`DATABASE_IDLE_TIMEOUT` must be at least 1 second"); } if let Some(log_file) = &cfg.log_file @@ -1274,6 +1298,30 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`PURGE_INCOMPLETE_SSO_AUTH` is not a valid cron expression") } + if cfg.login_ratelimit_seconds < 1 { + err!("`LOGIN_RATELIMIT_SECONDS` must be at least 1 second"); + } + + if cfg.login_ratelimit_max_burst < 1 { + err!("`LOGIN_RATELIMIT_MAX_BURST` must be at least 1"); + } + + if cfg.admin_ratelimit_seconds < 1 { + err!("`ADMIN_RATELIMIT_SECONDS` must be at least 1 second"); + } + + if cfg.admin_ratelimit_max_burst < 1 { + err!("`ADMIN_RATELIMIT_MAX_BURST` must be at least 1"); + } + + if cfg.unauthenticated_ratelimit_seconds < 1 { + err!("`UNAUTHENTICATED_RATELIMIT_SECONDS` must be at least 1 second"); + } + + if cfg.unauthenticated_ratelimit_max_burst < 1 { + err!("`UNAUTHENTICATED_RATELIMIT_MAX_BURST` must be at least 1"); + } + if !cfg.disable_admin_token { match cfg.admin_token.as_ref() { Some(t) if t.starts_with("$argon2") => { @@ -1443,11 +1491,34 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ "pm-32009-new-item-types", ]; +#[derive(Clone, Copy, Serialize)] +pub enum ConfigFileStatus { + NotFound, + Loaded, + Error, +} + impl Config { pub async fn load() -> Result { // Loading from env and file let env = ConfigBuilder::from_env(); - let usr = ConfigBuilder::from_file().await.unwrap_or_default(); + let (usr, config_file_status) = match ConfigBuilder::from_file().await { + Ok(Some(usr)) => (usr, ConfigFileStatus::Loaded), + Ok(None) => (ConfigBuilder::default(), ConfigFileStatus::NotFound), + Err(e) => { + println!( + "[ERROR] Failed to load configuration from `{}`\n\ + #####################################################################################\n\ + # The file seems to exist but might contain invalid JSON or invalid values. #\n\ + # Check the syntax and make sure every value is valid and within its allowed range. #\n\ + # This file is not used until those errors are resolved. #\n\ + #####################################################################################\n\ + {e:?}\n", + *CONFIG_FILE + ); + (ConfigBuilder::default(), ConfigFileStatus::Error) + } + }; // Create merged config, config file overwrites env let mut overrides = Vec::new(); @@ -1467,6 +1538,7 @@ impl Config { _env: env, _usr: usr, _overrides: overrides, + _config_file_status: config_file_status, }), }) } @@ -1500,6 +1572,7 @@ impl Config { writer.config = config; writer._usr = builder; writer._overrides = overrides; + writer._config_file_status = ConfigFileStatus::Loaded; } //Save to file @@ -1591,6 +1664,7 @@ impl Config { writer.config = config; writer._usr = usr; writer._overrides = Vec::new(); + writer._config_file_status = ConfigFileStatus::NotFound; } // Invalidate CSS Cache because several config items might have impact on the rendered CSS diff --git a/src/db/mod.rs b/src/db/mod.rs index 2eae3f3c..2ccd7d48 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -212,8 +212,8 @@ impl DbPool { let pool = Pool::builder() .max_size(max_conns) .min_idle(Some(CONFIG.database_min_conns())) - .idle_timeout(Some(Duration::from_secs(CONFIG.database_idle_timeout()))) - .connection_timeout(Duration::from_secs(CONFIG.database_timeout())) + .idle_timeout(Some(Duration::from_secs(u64::from(CONFIG.database_idle_timeout())))) + .connection_timeout(Duration::from_secs(u64::from(CONFIG.database_timeout()))) .connection_customizer(Box::new(DbConnOptions { init_stmts: conn_type.get_init_stmts(), })) @@ -233,7 +233,7 @@ impl DbPool { // Get a connection from the pool pub async fn get(&self) -> Result { - let duration = Duration::from_secs(CONFIG.database_timeout()); + let duration = Duration::from_secs(u64::from(CONFIG.database_timeout())); let permit = match timeout(duration, Arc::clone(&self.semaphore).acquire_owned()).await { Ok(p) => p.expect("Semaphore should be open"), Err(_) => { diff --git a/src/db/models/auth_request.rs b/src/db/models/auth_request.rs index b28c69f7..f2b0ecdc 100644 --- a/src/db/models/auth_request.rs +++ b/src/db/models/auth_request.rs @@ -197,7 +197,7 @@ impl AuthRequest { pub async fn purge_expired_auth_requests(conn: &DbConn) { // delete auth requests older than 15 minutes which is functionally equivalent to upstream: // https://github.com/bitwarden/server/blob/f8ee2270409f7a13125cd414c450740af605a175/src/Sql/dbo/Auth/Stored%20Procedures/AuthRequest_DeleteIfExpired.sql - let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::try_minutes(15).unwrap(); + let expiry_time = Utc::now().naive_utc() - chrono::TimeDelta::minutes(15); for auth_request in Self::find_created_before(&expiry_time, conn).await { auth_request.delete(conn).await.ok(); } diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index f39b0bfc..fca03653 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -494,12 +494,22 @@ impl Cipher { /// Purge all ciphers that are old enough to be auto-deleted. pub async fn purge_trash(conn: &DbConn) { - if let Some(auto_delete_days) = CONFIG.trash_auto_delete_days() { - let now = Utc::now().naive_utc(); - let dt = now - TimeDelta::try_days(auto_delete_days).unwrap(); - for cipher in Self::find_deleted_before(&dt, conn).await { - cipher.delete(conn).await.ok(); - } + let Some(auto_delete_days) = CONFIG.trash_auto_delete_days().filter(|&d| d != 0) else { + debug!("trash_auto_delete_days is not configured or set to 0, skipping"); + return; + }; + + let Some(days) = TimeDelta::try_days(i64::from(auto_delete_days)) else { + error!("Unable to calculate auto delete days!"); + return; + }; + let now = Utc::now().naive_utc(); + let Some(dt) = now.checked_sub_signed(days) else { + error!("Unable to calculate trash purge date!"); + return; + }; + for cipher in Self::find_deleted_before(&dt, conn).await { + cipher.delete(conn).await.ok(); } } diff --git a/src/db/models/emergency_access.rs b/src/db/models/emergency_access.rs index 09783061..02c05d5e 100644 --- a/src/db/models/emergency_access.rs +++ b/src/db/models/emergency_access.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDateTime, Utc}; +use chrono::{NaiveDateTime, TimeDelta, Utc}; use derive_more::{AsRef, Deref, Display, From}; use diesel::prelude::*; use serde_json::Value; @@ -112,8 +112,26 @@ impl EmergencyAccess { "object": "emergencyAccessGranteeDetails", })) } + + pub fn deadline_at(&self) -> Result { + if !(EMERGENCY_ACCESS_TIMEOUT_RANGE).contains(&self.wait_time_days) { + err!("Emergency access wait time is out-of-bounds!") + } + + let initiated_at = self.recovery_initiated_at.map_res("Emergency access initiation date missing!")?; + + TimeDelta::try_days(i64::from(self.wait_time_days)) + .and_then(|w| initiated_at.checked_add_signed(w)) + .map_res("Emergency access deadline is invalid") + } + + pub fn is_recovery_allowed(&self, now: &NaiveDateTime) -> Result { + Ok(self.deadline_at()?.le(now)) + } } +pub const EMERGENCY_ACCESS_TIMEOUT_RANGE: std::ops::RangeInclusive = 1..=90; + #[derive(Copy, Clone)] pub enum EmergencyAccessType { View = 0, @@ -169,27 +187,42 @@ impl EmergencyAccess { pub async fn update_access_status_and_save( &mut self, - status: i32, + from_status: i32, + to_status: i32, date: &NaiveDateTime, conn: &DbConn, ) -> EmptyResult { + let uuid = self.uuid.clone(); + let updated_at = *date; + let res = conn + .run(move |conn| { + crate::util::retry( + || { + diesel::update( + emergency_access::table + .filter(emergency_access::uuid.eq(&uuid)) + .filter(emergency_access::status.eq(from_status)), + ) + .set((emergency_access::status.eq(to_status), emergency_access::updated_at.eq(updated_at))) + .execute(conn) + }, + 10, + ) + }) + .await; + + match res { + Ok(1) => {} // One record updated, which is valid + Ok(_) => err!("Emergency access status was changed already"), + Err(e) => err!("Error updating emergency access status", format!("{e}")), + } + + self.status = to_status; + self.updated_at = updated_at; + // Update the grantee so that it will refresh it's status. User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await; - self.status = status; - date.clone_into(&mut self.updated_at); - - conn.run(move |conn| { - crate::util::retry( - || { - diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid))) - .set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date))) - .execute(conn) - }, - 10, - ) - .map_res("Error updating emergency access status") - }) - .await + Ok(()) } pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult { diff --git a/src/db/models/event.rs b/src/db/models/event.rs index cc0eb504..8dc3a5ad 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -347,17 +347,23 @@ impl Event { } pub async fn clean_events(conn: &DbConn) -> EmptyResult { - if let Some(days_to_retain) = CONFIG.events_days_retain() { - let dt = Utc::now().naive_utc() - TimeDelta::try_days(days_to_retain).unwrap(); - conn.run(move |conn| { - diesel::delete(event::table.filter(event::event_date.lt(dt))) - .execute(conn) - .map_res("Error cleaning old events") - }) - .await - } else { - Ok(()) - } + let Some(days_to_retain) = CONFIG.events_days_retain().filter(|&d| d != 0) else { + return Ok(()); + }; + + let Some(days_to_retain) = TimeDelta::try_days(i64::from(days_to_retain)) else { + err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'") + }; + + let Some(dt) = Utc::now().naive_utc().checked_sub_signed(days_to_retain) else { + err!("Unable to calculate the event retention date, check 'EVENTS_DAYS_RETAIN'") + }; + conn.run(move |conn| { + diesel::delete(event::table.filter(event::event_date.lt(dt))) + .execute(conn) + .map_res("Error cleaning old events") + }) + .await } } diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0e4073a5..3a592b34 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -24,7 +24,9 @@ pub use self::auth_request::{AuthRequest, AuthRequestId}; pub use self::cipher::{Cipher, CipherId, RepromptType}; pub use self::collection::{Collection, CollectionCipher, CollectionId, CollectionUser}; pub use self::device::{Device, DeviceId, DeviceType, DeviceWithAuthRequest, PushId}; -pub use self::emergency_access::{EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType}; +pub use self::emergency_access::{ + EMERGENCY_ACCESS_TIMEOUT_RANGE, EmergencyAccess, EmergencyAccessId, EmergencyAccessStatus, EmergencyAccessType, +}; pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; diff --git a/src/db/models/two_factor_incomplete.rs b/src/db/models/two_factor_incomplete.rs index 4b8cadcb..e4c7e308 100644 --- a/src/db/models/two_factor_incomplete.rs +++ b/src/db/models/two_factor_incomplete.rs @@ -37,7 +37,7 @@ impl TwoFactorIncomplete { ip: &ClientIp, conn: &DbConn, ) -> EmptyResult { - if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { return Ok(()); } @@ -66,7 +66,7 @@ impl TwoFactorIncomplete { } pub async fn mark_complete(user_uuid: &UserId, device_uuid: &DeviceId, conn: &DbConn) -> EmptyResult { - if CONFIG.incomplete_2fa_time_limit() <= 0 || !CONFIG.mail_enabled() { + if CONFIG.incomplete_2fa_time_limit() == 0 || !CONFIG.mail_enabled() { return Ok(()); } diff --git a/src/mail.rs b/src/mail.rs index 2efb8d70..6ebbc806 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -36,7 +36,7 @@ fn smtp_transport() -> AsyncSmtpTransport { let smtp_client = AsyncSmtpTransport::::builder_dangerous(host.as_str()) .port(CONFIG.smtp_port()) - .timeout(Some(Duration::from_secs(CONFIG.smtp_timeout()))); + .timeout(Some(Duration::from_secs(u64::from(CONFIG.smtp_timeout())))); // Determine security let smtp_client = if CONFIG.smtp_security() == *"off" { @@ -169,7 +169,7 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes } pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_delete_claims(user_id.to_string()); + let claims = generate_delete_claims(user_id.to_string())?; let delete_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -187,7 +187,7 @@ pub async fn send_delete_account(address: &str, user_id: &UserId) -> EmptyResult } pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id); + let claims = generate_verify_email_claims(user_id)?; let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -237,7 +237,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult { } pub async fn send_welcome_must_verify(address: &str, user_id: &UserId) -> EmptyResult { - let claims = generate_verify_email_claims(user_id); + let claims = generate_verify_email_claims(user_id)?; let verify_email_token = encode_jwt(&claims); let (subject, body_html, body_text) = get_text( @@ -292,7 +292,7 @@ pub async fn send_invite( org_id.clone(), member_id.clone(), invited_by_email, - ); + )?; let invite_token = encode_jwt(&claims); let mut query = url::Url::parse("https://query.builder").unwrap(); { @@ -351,7 +351,7 @@ pub async fn send_emergency_access_invite( emer_id.clone(), String::from(grantor_name), String::from(grantor_email), - ); + )?; // Build the query here to ensure proper escaping let mut query = url::Url::parse("https://query.builder").unwrap(); diff --git a/src/main.rs b/src/main.rs index f27729d6..6a7f18f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -729,7 +729,7 @@ fn schedule_jobs(pool: db::DbPool) { // Cleanup the event table of records x days old. if CONFIG.org_events_enabled() && !CONFIG.event_cleanup_schedule().is_empty() - && CONFIG.events_days_retain().is_some() + && CONFIG.events_days_retain().is_some_and(|d| d != 0) { sched.add(Job::new(CONFIG.event_cleanup_schedule().parse().unwrap(), || { runtime.spawn(api::event_cleanup_job(pool.clone())); @@ -754,7 +754,9 @@ fn schedule_jobs(pool: db::DbPool) { // tick, the one that was added earlier will run first. loop { sched.tick(); - runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(CONFIG.job_poll_interval_ms()))); + runtime.block_on(tokio::time::sleep(tokio::time::Duration::from_millis(u64::from( + CONFIG.job_poll_interval_ms(), + )))); } }) .expect("Error spawning job scheduler thread"); diff --git a/src/ratelimit.rs b/src/ratelimit.rs index 70217957..50d1f99b 100644 --- a/src/ratelimit.rs +++ b/src/ratelimit.rs @@ -7,20 +7,20 @@ use crate::{CONFIG, Error}; type Limiter = RateLimiter, DefaultClock>; static LIMITER_LOGIN: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(CONFIG.login_ratelimit_seconds()); - let burst = NonZeroU32::new(CONFIG.login_ratelimit_max_burst()).expect("Non-zero login ratelimit burst"); + let seconds = Duration::from_secs(u64::from(CONFIG.login_ratelimit_seconds())); + let burst = NonZeroU32::new(u32::from(CONFIG.login_ratelimit_max_burst())).expect("Non-zero login ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero login ratelimit seconds").allow_burst(burst)) }); static LIMITER_ADMIN: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(CONFIG.admin_ratelimit_seconds()); - let burst = NonZeroU32::new(CONFIG.admin_ratelimit_max_burst()).expect("Non-zero admin ratelimit burst"); + let seconds = Duration::from_secs(u64::from(CONFIG.admin_ratelimit_seconds())); + let burst = NonZeroU32::new(u32::from(CONFIG.admin_ratelimit_max_burst())).expect("Non-zero admin ratelimit burst"); RateLimiter::keyed(Quota::with_period(seconds).expect("Non-zero admin ratelimit seconds").allow_burst(burst)) }); static LIMITER_UNAUTHENTICATED: LazyLock = LazyLock::new(|| { - let seconds = Duration::from_secs(CONFIG.unauthenticated_ratelimit_seconds()); - let burst = NonZeroU32::new(CONFIG.unauthenticated_ratelimit_max_burst()) + let seconds = Duration::from_secs(u64::from(CONFIG.unauthenticated_ratelimit_seconds())); + let burst = NonZeroU32::new(u32::from(CONFIG.unauthenticated_ratelimit_max_burst())) .expect("Non-zero unauthenticated ratelimit burst"); RateLimiter::keyed( Quota::with_period(seconds).expect("Non-zero unauthenticated ratelimit seconds").allow_burst(burst), diff --git a/src/sso_client.rs b/src/sso_client.rs index a5dcaa83..a66d75f6 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -29,7 +29,7 @@ static CLIENT_CACHE_KEY: LazyLock = LazyLock::new(|| "sso-client".to_own static CLIENT_CACHE: LazyLock> = LazyLock::new(|| { moka::sync::Cache::builder() .max_capacity(1) - .time_to_live(Duration::from_secs(CONFIG.sso_client_cache_expiration())) + .time_to_live(Duration::from_secs(u64::from(CONFIG.sso_client_cache_expiration()))) .build() }); static REFRESH_CACHE: LazyLock>> = diff --git a/src/static/scripts/admin_diagnostics.js b/src/static/scripts/admin_diagnostics.js index ae4d4235..1d2f6605 100644 --- a/src/static/scripts/admin_diagnostics.js +++ b/src/static/scripts/admin_diagnostics.js @@ -93,7 +93,9 @@ async function generateSupportString(event, dj) { supportString += `* Running within a container: ${yn(dj.running_within_container)} (Base: ${dj.container_base_image})\n`; supportString += `* Database type: ${dj.db_type}\n`; supportString += `* Database version: ${dj.db_version}\n`; - supportString += `* Uses config.json: ${yn(dj.overrides !== "")}\n`; + supportString += `* Uses config.json: ${yn(dj.config_file_status !== "NotFound")}`; + supportString += dj.config_file_status !== "NotFound" ? ` (Valid: ${chk(dj.config_file_status === "Loaded")})\n` : "\n"; + supportString += `* Environment variables are valid: ${chk(dj.invalid_env_vars == "")}\n`; supportString += `* Uses custom templates: ${yn(dj.template_overrides !== "")}${template_overrides}\n`; supportString += `* Uses a reverse proxy: ${yn(dj.ip_header_exists)}\n`; if (dj.ip_header_exists) { @@ -116,7 +118,7 @@ async function generateSupportString(event, dj) { } supportString += `* HTTP Response Checks: ${chk(httpResponseCheck)}\n`; if (dj.invalid_feature_flags != "") { - supportString += "* Invalid feature flags: true\n"; + supportString += "* Invalid feature flags: true :x:\n"; } const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, { @@ -132,6 +134,11 @@ async function generateSupportString(event, dj) { supportString += "\n### Config & Details (Generated via diagnostics page)\n\n"; supportString += "
Show Config & Details\n"; + // Show invalid environment variables if they exists + if (dj.invalid_env_vars != "") { + supportString += `\n**Invalid environment variables:** ${dj.invalid_env_vars}\n`; + } + // Add overrides if they exists if (dj.overrides != "") { supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`; diff --git a/src/static/templates/admin/diagnostics.hbs b/src/static/templates/admin/diagnostics.hbs index 0c889353..5991bc69 100644 --- a/src/static/templates/admin/diagnostics.hbs +++ b/src/static/templates/admin/diagnostics.hbs @@ -69,12 +69,26 @@
Uses config.json
- {{#if page_data.overrides}} + {{#if (eq page_data.config_file_status "Loaded")}} Yes + {{#if page_data.overrides}} Details + {{/if}} + {{~else if (eq page_data.config_file_status "Error")}} + Errors + Details + {{~else}} + No {{/if}} - {{#unless page_data.overrides}} - No +
+
Environment variables are valid
+
+ {{#if page_data.invalid_env_vars}} + No + Details + {{/if}} + {{#unless page_data.invalid_env_vars}} + Yes {{/unless}}
Uses custom templates
diff --git a/src/util.rs b/src/util.rs index 9d319542..644312a5 100644 --- a/src/util.rs +++ b/src/util.rs @@ -210,7 +210,7 @@ impl Fairing for Cors { pub struct Cached { response: R, is_immutable: bool, - ttl: u64, + ttl: u32, } impl Cached { @@ -230,7 +230,7 @@ impl Cached { } } - pub fn ttl(response: R, ttl: u64, is_immutable: bool) -> Cached { + pub fn ttl(response: R, ttl: u32, is_immutable: bool) -> Cached { Self { response, is_immutable, @@ -251,7 +251,7 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache res.set_raw_header("Cache-Control", cache_control_header); let time_now = Local::now(); - let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.try_into().unwrap()).unwrap(); + let expiry_time = time_now + chrono::TimeDelta::try_seconds(self.ttl.into()).unwrap(); res.set_raw_header("Expires", format_datetime_http(&expiry_time)); Ok(res) } @@ -447,20 +447,34 @@ pub fn get_env_str_value(key: &str) -> Option { } } -pub fn get_env(key: &str) -> Option +pub static INVALID_ENV_VARS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +pub fn get_env(key: &'static str) -> Option where V: FromStr, { - try_parse_string(get_env_str_value(key)) + let value = get_env_str_value(key); + let value_is_some = value.is_some(); + let parsed: Option = try_parse_string(value); + if parsed.is_none() && value_is_some { + println!("[ERROR] `{key}` contains an invalid value for its type and is ignored, the default is used instead."); + INVALID_ENV_VARS.lock().unwrap().push(key); + } + parsed } -pub fn get_env_bool(key: &str) -> Option { +pub fn get_env_bool(key: &'static str) -> Option { const TRUE_VALUES: &[&str] = &["true", "t", "yes", "y", "1"]; const FALSE_VALUES: &[&str] = &["false", "f", "no", "n", "0"]; match get_env_str_value(key) { Some(val) if TRUE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(true), Some(val) if FALSE_VALUES.contains(&val.to_lowercase().as_ref()) => Some(false), + Some(_) => { + println!("[ERROR] `{key}` contains an invalid boolean value and is ignored, the default is used instead."); + INVALID_ENV_VARS.lock().unwrap().push(key); + None + } _ => None, } } From c925c04de088104f365f8fe9ce6a81da6bfcf72f Mon Sep 17 00:00:00 2001 From: MinchinWeb Date: Sun, 25 Oct 2026 09:49:02 -0600 Subject: [PATCH 20/43] Send null org key for unconfirmed members in sync (#7850) Since #7666 the sync profile's `organizationsNew` also lists Accepted (not yet confirmed) memberships. Their `akey` is still empty, so the response contained `"key": ""`, whereas upstream sends `null` until the member is confirmed. Bitwarden Android 2026.9 prefers `organizationsNew` and only skips organizations whose key is null. The empty string is handed to the SDK, which rejects it ("Invalid asymmetric type, got type 0 with 1 parts"), so the whole `initializeOrgCrypto` call fails: none of the user's organization keys are loaded and every organization item and collection fails to decrypt on that device. Send null for an empty key, matching upstream and the existing handling of the user key in `GET /api/accounts/keys`. Claude-Session: https://claude.ai/code/session_011UHYQWTz2PkXSCfucaRMZB Co-authored-by: Claude Opus 5.5 --- src/db/models/organization.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72684782..25d03dfc 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -523,7 +523,8 @@ impl Membership { // These are per user "userId": self.user_uuid, - "key": self.akey, + // Accepted (not yet confirmed) members have no key yet; send null like upstream, not "" + "key": (!self.akey.is_empty()).then_some(&self.akey), "status": self.status, "type": membership_type, "enabled": true, From 7ce705be56f7df3391f9c8e2359e2fe512ca2fae Mon Sep 17 00:00:00 2001 From: Alexander Widak Date: Sun, 25 Oct 2026 16:49:12 +0100 Subject: [PATCH 21/43] Return stored manage flag for groups in collections/details (#7795) CollectionGroup::to_json_details_for_group derived manage from !read_only && !hide_passwords, so the group permission "Can edit items" was reported as "Can manage collection" by collections/details, while groups//details reported the stored value. Saving the collection access dialog in the web vault then persisted manage=true. Fixes #7794 Co-authored-by: Alexander Widak --- src/db/models/group.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/db/models/group.rs b/src/db/models/group.rs index 007ad375..c8df7ef2 100644 --- a/src/db/models/group.rs +++ b/src/db/models/group.rs @@ -137,15 +137,15 @@ impl CollectionGroup { } pub fn to_json_details_for_group(&self) -> Value { - // If both read_only and hide_passwords are false, then manage should be true - // You can't have an entry with read_only and manage, or hide_passwords and manage - // Or an entry with everything to false - // For backwards compatibility and migration proposes we keep checking read_only and hide_password + // Return the stored manage flag. Deriving it from !read_only && !hide_passwords turned the group + // permission "Can edit items" (readOnly=false, hidePasswords=false, manage=false) into "Can manage" + // in GET /organizations//collections/details, while GET /groups//details reported the stored + // value. Saving the collection access dialog then persisted manage=true. json!({ "id": self.groups_uuid, "readOnly": self.read_only, "hidePasswords": self.hide_passwords, - "manage": self.manage || (!self.read_only && !self.hide_passwords), + "manage": self.manage, }) } } From e5a90968f29d911c713d509b0a0a490391865404 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:49:20 +0100 Subject: [PATCH 22/43] Fix bulk cipher share response for SDK clients (#7831) * Fix bulk cipher share response for SDK clients * Simplify cipher sharing code --- src/api/core/ciphers.rs | 66 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 46766896..d6c18066 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -1018,14 +1018,53 @@ struct ShareSelectedCipherData { collection_ids: Vec, } +// A bulk share returns cipherMini models, not the cipherDetails models used by +// the single-share route. Keep the encrypted legacy fields and a future v2 +// `data` blob, but do not expose details-only fields in this response. +fn cipher_mini_from_json(mut cipher: Value) -> Value { + const FIELDS: &[&str] = &[ + "id", + "organizationId", + "type", + "data", + "partialData", + "name", + "notes", + "login", + "card", + "identity", + "secureNote", + "sshKey", + "bankAccount", + "driversLicense", + "passport", + "fields", + "passwordHistory", + "attachments", + "organizationUseTotp", + "revisionDate", + "creationDate", + "deletedDate", + "reprompt", + "key", + ]; + + let Some(object) = cipher.as_object_mut() else { + return json!({ "object": "cipherMini" }); + }; + object.retain(|field, _| FIELDS.contains(&field.as_str())); + object.insert("object".into(), "cipherMini".into()); + cipher +} + #[put("/ciphers/share", data = "")] async fn put_cipher_share_selected( data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>, -) -> EmptyResult { - let mut data: ShareSelectedCipherData = data.into_inner(); +) -> JsonResult { + let data: ShareSelectedCipherData = data.into_inner(); if data.ciphers.is_empty() { err!("You must select at least one cipher.") @@ -1041,23 +1080,38 @@ async fn put_cipher_share_selected( } } - while let Some(cipher) = data.ciphers.pop() { + let mut responses = Vec::with_capacity(data.ciphers.len()); + for cipher in data.ciphers { let mut shared_cipher_data = ShareCipherData { cipher, collection_ids: data.collection_ids.clone(), }; if let Some(id) = shared_cipher_data.cipher.id.take() { - share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await? + let response = + share_cipher_by_uuid(&id, shared_cipher_data, &headers, &conn, &nt, Some(UpdateType::None)).await?; + + // Like upstream's VisibleToClient, withhold a cipher if sharing it + // removed the user's access to it. Only then include its encrypted + // mini response; the full single-share response stays internal. + if let Some(shared_cipher) = Cipher::find_by_uuid(&id, &conn).await + && shared_cipher.is_accessible_to_user(&headers.user.uuid, &conn).await + { + responses.push(cipher_mini_from_json(response.into_inner())); + } } else { err!("Request missing ids field") - }; + } } // Multi share actions do not send out a push for each cipher, we need to send a general sync here nt.send_user_update(UpdateType::SyncCiphers, &headers.user, headers.device.push_uuid.as_ref(), &conn).await; - Ok(()) + Ok(Json(json!({ + "data": responses, + "object": "list", + "continuationToken": null, + }))) } async fn share_cipher_by_uuid( From 44c4853d373c35774709a4f3129133f5d37ca204 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Sun, 25 Oct 2026 17:08:08 +0100 Subject: [PATCH 23/43] Upgrade to Rocket v0.5.2 (#7890) Upgrade Rocket to v0.5.2 which has updated all the crate dependencies. Also adjusted some code which I changed with Rkt and during that found some small bugs and fixed those too. Adjusted the ETag Caching a bit. Also updated other crates and GHA. Signed-off-by: BlackDex --- .github/workflows/release.yml | 12 +- .github/workflows/zizmor.yml | 2 +- Cargo.lock | 302 ++++++++++++++-------------------- Cargo.toml | 4 +- src/api/mod.rs | 2 +- src/api/notifications.rs | 9 +- src/api/web.rs | 94 +++++++---- src/error.rs | 4 +- src/mail.rs | 4 +- src/main.rs | 2 +- src/util.rs | 87 ++++++---- 11 files changed, 262 insertions(+), 260 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 53cf0923..6f80509d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,7 +106,7 @@ jobs: # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -121,7 +121,7 @@ jobs: # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -137,7 +137,7 @@ jobs: # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} @@ -272,7 +272,7 @@ jobs: # Login to Docker Hub - name: Login to Docker Hub - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -287,7 +287,7 @@ jobs: # Login to GitHub Container Registry - name: Login to GitHub Container Registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -303,7 +303,7 @@ jobs: # Login to Quay.io - name: Login to Quay.io - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index c08b9213..7372759b 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -24,7 +24,7 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@85b2b9fb8e3325e7a23d7d0a9d7ba5ae0f999e3e # v0.7.1 + uses: zizmorcore/zizmor-action@53290badfa369cb0bf7ad16342cb62c2fa47ac7b # v0.8.0 with: # intentionally not scanning the entire repository, # since it contains integration tests. diff --git a/Cargo.lock b/Cargo.lock index 9568e5c7..6cef9e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -323,15 +323,9 @@ dependencies = [ [[package]] name = "asyncband" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5161fb43cb8f231f06983cb2b8f9ee9cee14be0b1356d8383398cc0f312800d1" - -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" +checksum = "5d2050892d4691cf110586070786712b504985a4d37ed64a9cd8a402718e1706" [[package]] name = "atomic" @@ -415,7 +409,7 @@ dependencies = [ "bytes-utils", "fastrand", "http 1.5.0", - "http-body 1.1.0", + "http-body", "percent-encoding", "pin-project-lite", "tracing", @@ -424,9 +418,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.116.0" +version = "1.117.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26d2c93a4e6e7c133dc4090dc2273cfd92894fee8b883256427b57cacc3a384" +checksum = "5316813498fa9c403877007934ae448aa44b18136f58051f8fa3c5b79b44bdc6" dependencies = [ "arc-swap", "aws-credential-types", @@ -449,9 +443,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.118.0" +version = "1.119.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6fa86283d1c6d5c82f1fb2d82f7704d8f7a7bf5f0591813114240d296f6a11c" +checksum = "31db35e5662787b786daba15f431e4393c7ea512b70fa112bd048d1f3ecf877a" dependencies = [ "arc-swap", "aws-credential-types", @@ -474,9 +468,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.121.0" +version = "1.122.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97ff04fe350fd756c1aa45544128e88f72ffb6281cad628e5a4add464cbbab8b" +checksum = "a10491638d9e560db90c484dbc77d73b8ad202780cdc9b9e92180603acb07ada" dependencies = [ "arc-swap", "aws-credential-types", @@ -500,9 +494,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.6.2" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a3ccec0607ae3839cd76830df5ed62468ac1330acdb3dc4ec0502404113d2b6" +checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -543,7 +537,7 @@ dependencies = [ "futures-core", "futures-util", "http 1.5.0", - "http-body 1.1.0", + "http-body", "http-body-util", "percent-encoding", "pin-project-lite", @@ -598,7 +592,7 @@ dependencies = [ "bytes", "fastrand", "http 1.5.0", - "http-body 1.1.0", + "http-body", "http-body-util", "pin-project-lite", "tokio", @@ -654,7 +648,7 @@ dependencies = [ "bytes", "bytes-utils", "http 1.5.0", - "http-body 1.1.0", + "http-body", "http-body-util", "itoa", "num-integer", @@ -815,9 +809,9 @@ dependencies = [ [[package]] name = "brotli" -version = "9.0.0" +version = "9.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" +checksum = "6737ce8b81460bf9407909f73ff50bf13f7cb8577e1e76715f57ed0eba58ba44" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -826,9 +820,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "6.0.1" +version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" +checksum = "34c6783f42c21c952ad7112b354f0ba2f10760e9decea5b1b60523d6e270faaf" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -914,9 +908,9 @@ checksum = "f5813789573ae815c8b4be58c4428e0e7ae05f0227678ba9de332ded585b9159" [[package]] name = "cc" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -1242,9 +1236,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] @@ -1527,9 +1521,9 @@ dependencies = [ [[package]] name = "devise" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1d90b0c4c777a2cad215e3c7be59ac7c15adf45cf76317009b7d096d46f651d" +checksum = "143afb21c617f66c792ef2d75f9fb4c9dd774ff206bcc24df08a1bda294a37bc" dependencies = [ "devise_codegen", "devise_core", @@ -1537,9 +1531,9 @@ dependencies = [ [[package]] name = "devise_codegen" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71b28680d8be17a570a2334922518be6adc3f58ecc880cbb404eaeb8624fd867" +checksum = "50b3187e4283046e01d16d724ed05326427aa9c1c3a376548ef31820e6eb9717" dependencies = [ "devise_core", "quote", @@ -1547,15 +1541,15 @@ dependencies = [ [[package]] name = "devise_core" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b035a542cf7abf01f2e3c4d5a7acbaebfefe120ae4efc7bde3df98186e4b8af7" +checksum = "970e4b2fcd605ffa79715510663a9fe76b87f5483d59857399129cf047f73d27" dependencies = [ "bitflags 2.13.2", "proc-macro2", - "proc-macro2-diagnostics", + "proc-macro2-diagnostics 0.11.0", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1893,8 +1887,8 @@ version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ - "atomic 0.6.1", - "pear", + "atomic", + "pear 0.2.9", "serde", "toml 0.8.23", "uncased", @@ -2430,17 +2424,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.1.0" @@ -2460,7 +2443,7 @@ dependencies = [ "bytes", "futures-core", "http 1.5.0", - "http-body 1.1.0", + "http-body", "pin-project-lite", ] @@ -2485,29 +2468,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.12.0" @@ -2520,8 +2480,9 @@ dependencies = [ "futures-core", "h2", "http 1.5.0", - "http-body 1.1.0", + "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -2536,11 +2497,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", - "hyper 1.12.0", + "hyper", "hyper-util", - "rustls 0.23.46", + "rustls", "tokio", - "tokio-rustls 0.26.6", + "tokio-rustls", "tower-service", ] @@ -2555,14 +2516,14 @@ dependencies = [ "futures-channel", "futures-util", "http 1.5.0", - "http-body 1.1.0", + "http-body", "httparse", - "hyper 1.12.0", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "system-configuration", "tokio", "tower-service", @@ -2739,7 +2700,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.5", + "socket2", "widestring", "windows-registry", "windows-result", @@ -2996,12 +2957,12 @@ dependencies = [ "nom 8.0.0", "percent-encoding", "quoted_printable", - "rustls 0.23.46", + "rustls", "rustls-native-certs", "serde", - "socket2 0.6.5", + "socket2", "tokio", - "tokio-rustls 0.26.6", + "tokio-rustls", "tracing", "url", ] @@ -3484,7 +3445,7 @@ dependencies = [ "bytes", "futures", "http 1.5.0", - "http-body 1.1.0", + "http-body", "opendal-core", "reqwest", ] @@ -3715,7 +3676,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" dependencies = [ "inlinable_string", - "pear_codegen", + "pear_codegen 0.2.9", + "yansi", +] + +[[package]] +name = "pear" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bdc582f5ece4663ba62cb9cc81b9f86aa23884ee92cddb1d5e75a94991119cb" +dependencies = [ + "inlinable_string", + "pear_codegen 0.3.0", "yansi", ] @@ -3726,11 +3698,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ "proc-macro2", - "proc-macro2-diagnostics", + "proc-macro2-diagnostics 0.10.1", "quote", "syn 2.0.119", ] +[[package]] +name = "pear_codegen" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b4262794e70dac544a759fd50088c6331e7a2ba4fc204b4b6e29c9b5257f9c4" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics 0.11.0", + "quote", + "syn 3.0.9", +] + [[package]] name = "pem" version = "3.0.6" @@ -4042,6 +4026,18 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "version_check", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -4250,6 +4246,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "ref-swap" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c30c54dffee5b40af088d5d50aa3455c91a0127164b51f0215efc4cb28fb3c" + [[package]] name = "regex" version = "1.13.1" @@ -4379,9 +4381,9 @@ dependencies = [ "futures-util", "h2", "http 1.5.0", - "http-body 1.1.0", + "http-body", "http-body-util", - "hyper 1.12.0", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -4389,7 +4391,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustls 0.23.46", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", @@ -4397,7 +4399,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.6", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -4459,18 +4461,19 @@ dependencies = [ [[package]] name = "rocket" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a516907296a31df7dc04310e7043b61d71954d703b603cc6867a026d7e72d73f" +checksum = "c17431360eb641a861453b57107ee1b425a799f49ec1b0fe9b8a45fe437447ce" dependencies = [ "async-stream", "async-trait", - "atomic 0.5.3", "binascii", "bytes", "either", "figment", "futures", + "http-body-util", + "hyper-util", "indexmap 2.14.2", "log", "memchr", @@ -4478,8 +4481,8 @@ dependencies = [ "num_cpus", "parking_lot", "pin-project-lite", - "rand 0.8.8", "ref-cast", + "ref-swap", "rocket_codegen", "rocket_http", "serde", @@ -4497,48 +4500,50 @@ dependencies = [ [[package]] name = "rocket_codegen" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "575d32d7ec1a9770108c879fc7c47815a80073f96ca07ff9525a94fcede1dd46" +checksum = "352bd0b773ca6f5bea6cd18efc2fd7f04fcd34c75c242dad6ef1bbd729cc619d" dependencies = [ "devise", "glob", "indexmap 2.14.2", "proc-macro2", + "proc-macro2-diagnostics 0.11.0", "quote", "rocket_http", - "syn 2.0.119", + "syn 3.0.9", "unicode-xid", "version_check", ] [[package]] name = "rocket_http" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e274915a20ee3065f611c044bd63c40757396b6dbc057d6046aec27f14f882b9" +checksum = "847886ef963dff77126f110fd2b4401f5149440ab32973bef16c4a2cc83e525f" dependencies = [ "cookie", "either", "futures", "http 0.2.12", - "hyper 0.14.32", + "hyper", "indexmap 2.14.2", "log", "memchr", - "pear", + "pear 0.3.0", "percent-encoding", "pin-project-lite", "ref-cast", - "rustls 0.21.12", - "rustls-pemfile", + "rustls", + "rustls-pki-types", + "rustls-webpki", "serde", "smallvec", "stable-pattern", "state", "time", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", "uncased", ] @@ -4643,18 +4648,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.46" @@ -4665,7 +4658,7 @@ dependencies = [ "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.16", + "rustls-webpki", "subtle", "zeroize", ] @@ -4682,15 +4675,6 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -4711,10 +4695,10 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.46", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.16", + "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", @@ -4727,16 +4711,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.16" @@ -4823,16 +4797,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "sec1" version = "0.7.3" @@ -5159,19 +5123,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" [[package]] name = "socket2" @@ -5509,7 +5463,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -5525,23 +5479,13 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ - "rustls 0.23.46", + "rustls", "tokio", ] @@ -5715,7 +5659,7 @@ dependencies = [ "futures-core", "futures-util", "http 1.5.0", - "http-body 1.1.0", + "http-body", "http-body-util", "pin-project-lite", "tokio", @@ -5913,9 +5857,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5996,7 +5940,7 @@ dependencies = [ "rocket", "rocket_ws", "rpassword", - "rustls 0.23.46", + "rustls", "semver", "serde", "serde_json", @@ -6228,9 +6172,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] diff --git a/Cargo.toml b/Cargo.toml index ab30358f..78791730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,7 +80,7 @@ num-derive = "0.5.1" bigdecimal = "0.4.11" # Web framework -rocket = { version = "0.5.1", default-features = false, features = ["json", "tls"] } +rocket = { version = "0.5.2", default-features = false, features = ["json", "tls"] } rocket_ws = { version = "0.1.1" } # WebSockets libraries @@ -129,7 +129,7 @@ rustls = { version = "0.23.46", features = ["ring", "std"], default-features = f subtle = "2.6.1" # UUID generation -uuid = { version = "1.28.0", features = ["v4"] } +uuid = { version = "1.29.0", features = ["v4"] } # Date and time libraries chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } diff --git a/src/api/mod.rs b/src/api/mod.rs index 8baa221d..0ac78f5c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -30,7 +30,7 @@ pub use crate::api::{ }, web::catchers as web_catchers, web::routes as web_routes, - web::{invalidate_css_cache, static_files}, + web::{STATIC_FILES, invalidate_css_cache}, }; use crate::{ CONFIG, diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 32204916..3bce7219 100644 --- a/src/api/notifications.rs +++ b/src/api/notifications.rs @@ -212,7 +212,7 @@ fn websockets_hub<'r>( if last_received.elapsed() > WS_CLIENT_TIMEOUT { break; } - yield Message::Ping(create_ping()); + yield Message::Ping(PING.to_vec()); } } } @@ -295,7 +295,7 @@ fn anonymous_websockets_hub<'r>(ws: WebSocket, token: String, ip: ClientIp) -> R if last_received.elapsed() > WS_CLIENT_TIMEOUT { break; } - yield Message::Ping(create_ping()); + yield Message::Ping(PING.to_vec()); } } } @@ -359,6 +359,7 @@ fn convert_option>(option: Option) -> Value { const RECORD_SEPARATOR: u8 = 0x1e; const INITIAL_RESPONSE: [u8; 3] = [0x7b, 0x7d, RECORD_SEPARATOR]; // {, }, +const PING: [u8; 3] = [0x02, 0x91, 0x06]; #[derive(Deserialize, Copy, Clone, Eq, PartialEq)] struct InitialMessage<'a> { @@ -691,10 +692,6 @@ fn create_anonymous_update(payload: Vec<(Value, Value)>, ut: UpdateType, user_id serialize(&value) } -fn create_ping() -> Vec { - serialize(&Value::Array(vec![6.into()])) -} - // https://github.com/bitwarden/server/blob/375af7c43b10d9da03525d41452f95de3f921541/src/Core/Enums/PushType.cs #[derive(Copy, Clone, Eq, PartialEq)] pub enum UpdateType { diff --git a/src/api/web.rs b/src/api/web.rs index aa67e614..e9a8b593 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -1,13 +1,14 @@ use std::{ + collections::HashMap, path::{Path, PathBuf}, - sync::{Arc, RwLock}, + sync::{Arc, LazyLock, RwLock}, }; use rocket::{ Catcher, Route, fs::NamedFile, http::ContentType, - response::{Redirect, content::RawCss as Css, content::RawHtml as Html}, + response::{Redirect, content::RawHtml as Html}, serde::json::Json, }; use serde_json::Value; @@ -79,12 +80,12 @@ pub fn invalidate_css_cache() { } #[get("/css/vaultwarden.css")] -fn vaultwarden_css() -> EtagCached> { +fn vaultwarden_css() -> EtagCached { // If reload_templates is false, and we already have the CSS Cached, return this if !CONFIG.reload_templates() && let Some(cached) = CSS_CACHE.read().unwrap().as_ref() { - return EtagCached::new(Css(cached.css.clone()), &cached.etag); + return EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag); } // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS @@ -147,7 +148,7 @@ fn vaultwarden_css() -> EtagCached> { } // Etag Caching will let the browser send us an etag to verify and send new content if needed - EtagCached::new(Css(cached.css.clone()), &cached.etag) + EtagCached::new(cached.css.clone(), ContentType::CSS, &cached.etag) } #[get("/")] @@ -256,7 +257,7 @@ fn alive_head(_conn: DbConn) -> EmptyResult { // This endpoint/function is used during development and development only. // It allows to easily develop the admin interface by always loading the files from disk instead from a slice of bytes // This will only be active during a debug build and only when `RELOAD_TEMPLATES` is set to `true` -// NOTE: Do not forget to add any new files added to the `static_files` function below! +// NOTE: Do not forget to add any new files added to the `STATIC_FILES` HashMap below! #[cfg(debug_assertions)] #[get("/vw_static/", rank = 1)] pub async fn static_files_dev(filename: PathBuf) -> Option { @@ -276,32 +277,61 @@ pub async fn static_files_dev(filename: PathBuf) -> Option { None } +pub struct StaticFile { + pub content_type: ContentType, + pub bytes: &'static [u8], + pub etag: String, +} + +pub static STATIC_FILES: LazyLock> = LazyLock::new(|| { + [ + ("404.png", ContentType::PNG, &include_bytes!("../static/images/404.png")[..]), + ("mail-github.png", ContentType::PNG, &include_bytes!("../static/images/mail-github.png")[..]), + ("logo-gray.png", ContentType::PNG, &include_bytes!("../static/images/logo-gray.png")[..]), + ("error-x.svg", ContentType::SVG, &include_bytes!("../static/images/error-x.svg")[..]), + ("hibp.png", ContentType::PNG, &include_bytes!("../static/images/hibp.png")[..]), + ("vaultwarden-icon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-icon.png")[..]), + ("vaultwarden-favicon.png", ContentType::PNG, &include_bytes!("../static/images/vaultwarden-favicon.png")[..]), + ("404.css", ContentType::CSS, &include_bytes!("../static/scripts/404.css")[..]), + ("admin.css", ContentType::CSS, &include_bytes!("../static/scripts/admin.css")[..]), + ("admin.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin.js")[..]), + ("admin_settings.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_settings.js")[..]), + ("admin_users.js", ContentType::JavaScript, &include_bytes!("../static/scripts/admin_users.js")[..]), + ( + "admin_organizations.js", + ContentType::JavaScript, + &include_bytes!("../static/scripts/admin_organizations.js")[..], + ), + ( + "admin_diagnostics.js", + ContentType::JavaScript, + &include_bytes!("../static/scripts/admin_diagnostics.js")[..], + ), + ("bootstrap.css", ContentType::CSS, &include_bytes!("../static/scripts/bootstrap.css")[..]), + ("bootstrap.bundle.js", ContentType::JavaScript, &include_bytes!("../static/scripts/bootstrap.bundle.js")[..]), + ("jdenticon-3.3.0.js", ContentType::JavaScript, &include_bytes!("../static/scripts/jdenticon-3.3.0.js")[..]), + ("datatables.js", ContentType::JavaScript, &include_bytes!("../static/scripts/datatables.js")[..]), + ("datatables.css", ContentType::CSS, &include_bytes!("../static/scripts/datatables.css")[..]), + ] + .into_iter() + .map(|(name, content_type, bytes)| { + ( + name, + StaticFile { + content_type, + bytes, + etag: sha256_hex(bytes), + }, + ) + }) + .collect() +}); + #[get("/vw_static/", rank = 2)] -pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Error> { - match filename { - "404.png" => Ok((ContentType::PNG, include_bytes!("../static/images/404.png"))), - "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), - "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), - "error-x.svg" => Ok((ContentType::SVG, include_bytes!("../static/images/error-x.svg"))), - "hibp.png" => Ok((ContentType::PNG, include_bytes!("../static/images/hibp.png"))), - "vaultwarden-icon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-icon.png"))), - "vaultwarden-favicon.png" => Ok((ContentType::PNG, include_bytes!("../static/images/vaultwarden-favicon.png"))), - "404.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/404.css"))), - "admin.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/admin.css"))), - "admin.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin.js"))), - "admin_settings.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_settings.js"))), - "admin_users.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_users.js"))), - "admin_organizations.js" => { - Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_organizations.js"))) - } - "admin_diagnostics.js" => { - Ok((ContentType::JavaScript, include_bytes!("../static/scripts/admin_diagnostics.js"))) - } - "bootstrap.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/bootstrap.css"))), - "bootstrap.bundle.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/bootstrap.bundle.js"))), - "jdenticon-3.3.0.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/jdenticon-3.3.0.js"))), - "datatables.js" => Ok((ContentType::JavaScript, include_bytes!("../static/scripts/datatables.js"))), - "datatables.css" => Ok((ContentType::CSS, include_bytes!("../static/scripts/datatables.css"))), - _ => err!(format!("Static file not found: {filename}")), +pub fn static_files(filename: &str) -> Result, Error> { + if let Some(file) = STATIC_FILES.get(filename) { + Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag)) + } else { + err!(format!("Static file not found: {filename}")) } } diff --git a/src/error.rs b/src/error.rs index d90c38e3..3fee3dc4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -425,12 +425,12 @@ macro_rules! err_json { macro_rules! err_handler { ($expr:expr) => {{ error!(target: "auth", "Unauthorized Error: {}", $expr); - return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, $expr)); + return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, $expr)); }}; ($usr_msg:expr, $log_value:expr) => {{ let usr_msg = $usr_msg; let log_value = $log_value; error!(target: "auth", "Unauthorized Error: {usr_msg}. {log_value}"); - return ::rocket::request::Outcome::Error((rocket::http::Status::Unauthorized, usr_msg)); + return ::rocket::request::Outcome::Error((::rocket::http::Status::Unauthorized, usr_msg)); }}; } diff --git a/src/mail.rs b/src/mail.rs index 6ebbc806..06aac97f 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -738,8 +738,8 @@ async fn send_email(address: &str, subject: &str, body_html: String, body_text: let smtp_from = Address::from_str(&CONFIG.smtp_from())?; let body = if CONFIG.smtp_embed_images() { - let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png").unwrap().1.to_vec()); - let mail_github_body = Body::new(crate::api::static_files("mail-github.png").unwrap().1.to_vec()); + let logo_gray_body = Body::new(crate::api::STATIC_FILES["logo-gray.png"].bytes.to_vec()); + let mail_github_body = Body::new(crate::api::STATIC_FILES["mail-github.png"].bytes.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) diff --git a/src/main.rs b/src/main.rs index 6a7f18f3..76c561f2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -595,7 +595,7 @@ async fn launch_rocket(pool: db::DbPool, extra_debug: bool) -> Result<(), Error> .manage(Arc::clone(&WS_ANONYMOUS_SUBSCRIPTIONS)) .attach(util::AppHeaders()) .attach(util::Cors()) - .attach(util::BetterLogging(extra_debug)) + .attach(util::BetterLogging::new(extra_debug, basepath)) .ignite() .await?; diff --git a/src/util.rs b/src/util.rs index 644312a5..44d532bf 100644 --- a/src/util.rs +++ b/src/util.rs @@ -259,14 +259,16 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for Cache pub struct EtagCached { response: R, + content_type: ContentType, etag: String, } impl EtagCached { /// An `etag` response should always be quoted - pub fn new(response: R, etag: &str) -> Self { + pub fn new(response: R, content_type: ContentType, etag: &str) -> Self { Self { response, + content_type, etag: format!("\"{etag}\""), } } @@ -288,7 +290,8 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC self.response.respond_to(request)? }; - // Both 200 (OK) and 304 (Not Modified) need to return the etag and cache-control + // Both 200 (OK) and 304 (Not Modified) need to return the Content-Type Etag and Cache-Control + res.set_header(self.content_type); res.set_raw_header("Etag", self.etag); res.set_raw_header("Cache-Control", "public, no-cache"); Ok(res) @@ -299,19 +302,55 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; -// Boolean is extra debug, when true, we ignore the whitelist above and also print the mounts -pub struct BetterLogging(pub bool); +// When extra is true, we ignore the whitelist above and also print the mounts +pub struct BetterLogging { + extra: bool, + log_requests: bool, + log_response: bool, + domain_path: String, +} + +impl BetterLogging { + pub fn new(extra: bool, domain_path: &str) -> Self { + Self { + extra, + log_requests: log_enabled!(target: "request", log::Level::Info), + log_response: log_enabled!(target: "response", log::Level::Info), + domain_path: rocket::http::RawStr::new(&domain_path).percent_decode_lossy().into_owned(), + } + } + + fn log_path<'p>(&self, method: Method, path: &'p rocket::http::RawStr) -> Option> { + if !self.extra && method == Method::Options { + return None; + } + let path = path.percent_decode_lossy(); + if self.extra { + return Some(path); + } + let subpath = path.strip_prefix(&self.domain_path).unwrap_or(&path); + LOGGED_ROUTES.iter().any(|r| subpath.starts_with(r)).then_some(path) + } +} + #[rocket::async_trait] impl Fairing for BetterLogging { fn info(&self) -> Info { + let mut kind = Kind::Liftoff; + if self.log_requests { + kind = kind | Kind::Request; + } + if self.log_response { + kind = kind | Kind::Response; + } Info { name: "Better Logging", - kind: Kind::Liftoff | Kind::Request | Kind::Response, + kind, } } async fn on_liftoff(&self, rocket: &Rocket) { - if self.0 { + if self.extra { info!(target: "routes", "Routes loaded:"); let mut routes: Vec<_> = rocket.routes().collect(); routes.sort_by_key(|r| r.uri.path()); @@ -335,36 +374,28 @@ impl Fairing for BetterLogging { } async fn on_request(&self, request: &mut Request<'_>, _data: &mut Data<'_>) { - let method = request.method(); - if !self.0 && method == Method::Options { - return; - } let uri = request.uri(); - let uri_path = uri.path(); - let uri_path_str = uri_path.url_decode_lossy(); - let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str); - if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) { - match uri.query() { - Some(q) => info!(target: "request", "{method} {uri_path_str}?{}", &q[..q.len().min(30)]), - None => info!(target: "request", "{method} {uri_path_str}"), + let method = request.method(); + let Some(path) = self.log_path(method, uri.path().raw()) else { + return; + }; + match uri.query() { + Some(q) => { + let q = q.as_str(); + info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug()); } + None => info!(target: "request", "{method} {}", path.escape_debug()), } } async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { - if !self.0 && request.method() == Method::Options { + if self.log_path(request.method(), request.uri().path().raw()).is_none() { return; } - let uri_path = request.uri().path(); - let uri_path_str = uri_path.url_decode_lossy(); - let uri_subpath = uri_path_str.strip_prefix(&CONFIG.domain_path()).unwrap_or(&uri_path_str); - if self.0 || LOGGED_ROUTES.iter().any(|r| uri_subpath.starts_with(r)) { - let status = response.status(); - if let Some(route) = request.route() { - info!(target: "response", "{route} => {status}"); - } else { - info!(target: "response", "{status}"); - } + let status = response.status(); + match request.route() { + Some(r) => info!(target: "response", "{r} => {status}"), + None => info!(target: "response", "{status}"), } } } From 0f44744ef86a2a7bea615af1c024044ce5736187 Mon Sep 17 00:00:00 2001 From: Arthurmeade12 Date: Sun, 25 Oct 2026 14:42:35 -0500 Subject: [PATCH 24/43] Fix typo (#7864) sever -> server --- .env.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.template b/.env.template index 057f8fa8..06d05a48 100644 --- a/.env.template +++ b/.env.template @@ -614,7 +614,7 @@ ## we allow by default the TOTP code which was valid one step back and one in the future. ## This can however allow attackers to be a bit more lucky with their attempts because there are 3 valid codes. ## You can disable this, so that only the current TOTP Code is allowed. -## Keep in mind that when a sever drifts out of time, valid codes could be marked as invalid. +## Keep in mind that when a server drifts out of time, valid codes could be marked as invalid. ## In any case, if a code has been used it can not be used again, also codes which predates it will be invalid. # AUTHENTICATOR_DISABLE_TIME_DRIFT=false From e2d63cfca31aab36c95de8304cd995d170db962e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 28 Oct 2026 21:28:02 +0100 Subject: [PATCH 25/43] Remove expired cookies from the icon cookie jar (#7905) --- src/api/icons.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/api/icons.rs b/src/api/icons.rs index 25ce82a0..ce801422 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -663,6 +663,17 @@ impl reqwest::cookie::CookieStore for Jar { use time::Duration; let mut cookie_store = self.0.write().unwrap(); + + // The store only skips expired cookies when reading them, so remove them here or they are kept forever + let expired: Vec<_> = cookie_store + .iter_any() + .filter(|c| c.is_expired()) + .map(|c| (String::from(&c.domain), String::from(&c.path), c.name().to_owned())) + .collect(); + for (domain, path, name) in expired { + cookie_store.remove(&domain, &path, &name); + } + let cookies = cookie_headers.filter_map(|val| { std::str::from_utf8(val.as_bytes()) .map_err(RawCookieParseError::from) From a96912bf21a9baa3a35e0506d5a110572840d1a3 Mon Sep 17 00:00:00 2001 From: Luca Becker Date: Wed, 28 Oct 2026 20:54:06 +0000 Subject: [PATCH 26/43] fix(attachments): enforce quota at v2 metadata reservation (#7450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(attachments): enforce quota at v2 metadata reservation POST /ciphers//attachment/v2 persisted client-declared fileSize without checking USER_ATTACHMENT_LIMIT or ORG_ATTACHMENT_LIMIT, allowing a writer to exhaust org/user quota via metadata-only reservations before uploading bytes. Extract shared quota arithmetic into attachment_quota_remaining and use it from post_attachment_v2 (size_adjust=0) and save_attachment so both paths stay consistent. Add unit tests covering limit, size_adjust, and the v2 over-quota rejection case. * Remove unneeded tests and simplify --------- Co-authored-by: Daniel García --- src/api/core/ciphers.rs | 93 +++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 50 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index d6c18066..0c1ed8e5 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -13,7 +13,7 @@ use serde_json::Value; use crate::{ CONFIG, - api::{self, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, + api::{self, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::log_event}, auth::ClientVersion, auth::{Headers, OrgIdGuard, OwnerHeaders}, config::PathType, @@ -1214,6 +1214,40 @@ enum FileUploadType { // Azure = 1, // only used upstream } +/// Remaining attachment storage in bytes for the cipher's owner, `None` when unlimited. +/// `size_adjust` credits a v2 attachment whose record already counts as used. +async fn attachment_size_limit(cipher: &Cipher, size_adjust: i64, conn: &DbConn) -> ApiResult> { + let (limit_kb, already_used) = if let Some(ref user_id) = cipher.user_uuid { + let Some(limit_kb) = CONFIG.user_attachment_limit() else { + return Ok(None); + }; + (limit_kb, Attachment::size_by_user(user_id, conn).await) + } else if let Some(ref org_id) = cipher.organization_uuid { + let Some(limit_kb) = CONFIG.org_attachment_limit() else { + return Ok(None); + }; + (limit_kb, Attachment::size_by_org(org_id, conn).await) + } else { + err!("Cipher is neither owned by a user nor an organization"); + }; + + if limit_kb == 0 { + err!("Attachments are disabled") + } + + let Some(left) = + limit_kb.checked_mul(1024).and_then(|l| l.checked_sub(already_used)).and_then(|l| l.checked_add(size_adjust)) + else { + err!("Attachment size overflow"); + }; + + if left <= 0 { + err!("Attachment storage limit reached! Delete some attachments to free up space") + } + + Ok(Some(left)) +} + /// v2 API for creating an attachment associated with a cipher. /// This redirects the client to the API it should use to upload the attachment. /// For upstream's cloud-hosted service, it's an Azure object storage API. @@ -1243,6 +1277,13 @@ async fn post_attachment_v2( if file_size > SIZE_525_MB { err!("Max file size is 525 MB.") } + + if let Some(size_limit) = attachment_size_limit(&cipher, 0, &conn).await? + && file_size > size_limit + { + err!("Attachment storage limit exceeded with this file"); + } + let attachment_id = crypto::generate_attachment_id(); let attachment = Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); @@ -1309,55 +1350,7 @@ async fn save_attachment( Some(a) => a.file_size, // v2 API }; - let size_limit = if let Some(ref user_id) = cipher.user_uuid { - match CONFIG.user_attachment_limit() { - Some(0) => err!("Attachments are disabled"), - Some(limit_kb) => { - let already_used = Attachment::size_by_user(user_id, &conn).await; - let left = limit_kb - .checked_mul(1024) - .and_then(|l| l.checked_sub(already_used)) - .and_then(|l| l.checked_add(size_adjust)); - - let Some(left) = left else { - err!("Attachment size overflow"); - }; - - if left <= 0 { - err!("Attachment storage limit reached! Delete some attachments to free up space") - } - - Some(left) - } - None => None, - } - } else if let Some(ref org_id) = cipher.organization_uuid { - match CONFIG.org_attachment_limit() { - Some(0) => err!("Attachments are disabled"), - Some(limit_kb) => { - let already_used = Attachment::size_by_org(org_id, &conn).await; - let left = limit_kb - .checked_mul(1024) - .and_then(|l| l.checked_sub(already_used)) - .and_then(|l| l.checked_add(size_adjust)); - - let Some(left) = left else { - err!("Attachment size overflow"); - }; - - if left <= 0 { - err!("Attachment storage limit reached! Delete some attachments to free up space") - } - - Some(left) - } - None => None, - } - } else { - err!("Cipher is neither owned by a user nor an organization"); - }; - - if let Some(size_limit) = size_limit + if let Some(size_limit) = attachment_size_limit(&cipher, size_adjust, &conn).await? && size > size_limit { err!("Attachment storage limit exceeded with this file"); From 64ae55ceb2e33aa08e7291d50ca010c679064842 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Wed, 28 Oct 2026 23:14:28 +0100 Subject: [PATCH 27/43] Align cipher org checks & attachment notification with upstream (#7906) --- src/api/core/ciphers.rs | 52 +++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 0c1ed8e5..5070d8f4 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -666,6 +666,9 @@ async fn post_ciphers_import(data: Json, headers: Headers, conn: DbC for (index, mut cipher_data) in data.ciphers.into_iter().enumerate() { let folder_id = relations_map.get(&index).and_then(|i| folders.get(*i).cloned()); cipher_data.folder_id = folder_id; + // A personal import always creates personal ciphers, the organization ID is ignored instead of rejected + // Upstream: https://github.com/bitwarden/server/blob/b06b81c1fb33ce9c9872b96397ba2b4d879b66af/src/Api/Tools/Controllers/ImportCiphersController.cs#L58 + cipher_data.organization_id = None; let mut cipher = Cipher::new(cipher_data.r#type, cipher_data.name.clone()); update_cipher_from_data(&mut cipher, cipher_data, &headers, None, &conn, &nt, UpdateType::None).await?; @@ -687,6 +690,11 @@ async fn put_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { + // Only organization ciphers can be edited here, put_cipher() rejects an organization ID that isn't the cipher's + if data.organization_id.is_none() { + err_code!("Resource not found.", "Cipher is not an organization cipher", Status::NotFound.code); + } + put_cipher(cipher_id, data, headers, conn, nt).await } @@ -698,7 +706,7 @@ async fn post_cipher_admin( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - post_cipher(cipher_id, data, headers, conn, nt).await + put_cipher_admin(cipher_id, data, headers, conn, nt).await } #[post("/ciphers/", data = "")] @@ -735,6 +743,11 @@ async fn put_cipher( err!("Cipher is not write accessible") } + // Moving a cipher into an organization is only possible through the share endpoints + if cipher.organization_uuid != data.organization_id { + err!("Organization mismatch. Please resync the client before updating the cipher") + } + update_cipher_from_data(&mut cipher, data, &headers, None, &conn, &nt, UpdateType::SyncCipherUpdate).await?; Ok(Json(cipher.to_json(&headers.host, &headers.user.uuid, None, CipherSyncType::User, &conn).await?)) @@ -993,9 +1006,7 @@ async fn post_cipher_share( conn: DbConn, nt: Notify<'_>, ) -> JsonResult { - let data: ShareCipherData = data.into_inner(); - - share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await + put_cipher_share(cipher_id, data, headers, conn, nt).await } #[put("/ciphers//share", data = "")] @@ -1008,6 +1019,14 @@ async fn put_cipher_share( ) -> JsonResult { let data: ShareCipherData = data.into_inner(); + if data.cipher.organization_id.is_none() { + err!("Cipher OrganizationId is required.") + } + + if data.collection_ids.is_empty() { + err!("You must select at least one collection.") + } + share_cipher_by_uuid(&cipher_id, data, &headers, &conn, &nt, None).await } @@ -1078,6 +1097,9 @@ async fn put_cipher_share_selected( if cipher.id.is_none() { err!("Request missing ids field") } + if cipher.organization_id.is_none() { + err!("Cipher OrganizationId is required.") + } } let mut responses = Vec::with_capacity(data.ciphers.len()); @@ -1258,6 +1280,7 @@ async fn post_attachment_v2( data: Json, headers: Headers, conn: DbConn, + nt: Notify<'_>, ) -> JsonResult { let Some(cipher) = Cipher::find_by_uuid(&cipher_id, &conn).await else { err!("Cipher doesn't exist") @@ -1288,6 +1311,7 @@ async fn post_attachment_v2( let attachment = Attachment::new(attachment_id.clone(), cipher.uuid.clone(), data.file_name, file_size, Some(data.key)); attachment.save(&conn).await.expect("Error saving attachment"); + notify_attachment_created(&cipher, &headers, &conn, &nt).await; let url = format!("/ciphers/{}/attachment/{attachment_id}", cipher.uuid); let response_key = match data.admin_request { @@ -1407,13 +1431,23 @@ async fn save_attachment( save_temp_file(&PathType::Attachments, &format!("{cipher_id}/{file_id}"), data.data, true).await?; + // With the v2 API, post_attachment_v2() already did this when it created the attachment record + if attachment.is_none() { + notify_attachment_created(&cipher, headers, &conn, &nt).await; + } + + Ok((cipher, conn)) +} + +/// Sends the cipher update to the clients and logs the attachment event +async fn notify_attachment_created(cipher: &Cipher, headers: &Headers, conn: &DbConn, nt: &Notify<'_>) { nt.send_cipher_update( UpdateType::SyncCipherUpdate, - &cipher, - &cipher.update_users_revision(&conn).await, + cipher, + &cipher.update_users_revision(conn).await, &headers.device, None, - &conn, + conn, ) .await; @@ -1425,12 +1459,10 @@ async fn save_attachment( &headers.user.uuid, headers.device.atype, &headers.ip.ip, - &conn, + conn, ) .await; } - - Ok((cipher, conn)) } /// v2 API for uploading the actual data content of an attachment. From b723afe08a57cb83d7fbe77af11e4fabacf3cb22 Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:15:26 +0800 Subject: [PATCH 28/43] Document two feature flags in .env.template (#7841) --- .env.template | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index 06d05a48..f0695a16 100644 --- a/.env.template +++ b/.env.template @@ -397,12 +397,14 @@ ## - "windows-desktop-autotype": Enable the autotype feature preview on Windows. (Desktop >= 2025.8.0) ## - "windows-desktop-autotype-ga": Enable the new, still in development, autotype implementation on Windows. (Desktop >= 2026.9.1) ## Only enable one of the two autotype flags, the clients disable autotype completely when both are enabled. -## - "pm-30529-webauthn-related-origins": +## - "pm-30529-webauthn-related-origins": Allow passkeys stored in the vault to be used on related origins listed in a site's /.well-known/webauthn file (Browser >= 2026.2.0) ## - "pm-32009-new-item-types": Enable new item types: Bank Account, Driver's License, and Passport (Clients >= 2026.4.0) ## - "pm-34171-card-scanner": Enable the new card scanner feature on mobile (Android >= 2026.4.1, iOS >= 2026.4.1) ## - "enable-basic-auth-response": Enable HTTP Basic Auth autofill in the browser extension (Browser >= 2026.9.0) ## - "undetermined-cipher-scenario-logic": Enable the rewritten add/update login notification triggering logic in the browser extension (Browser >= 2026.2.0) ## - "biometrics-sdk-ipc": Enable biometric unlock over the SDK IPC framework (Desktop >= 2026.9.0, Browser >= 2026.9.0) +## - "windows-native-credential-sync": Register the desktop app as a passkey provider in Windows, so passkeys in the vault can be used by other apps and browsers. +## Requires the Microsoft Store (appx) build of the desktop app. Stable releases up to and including 2026.9.1 lack the required plugin registration, so it has no effect there yet. # EXPERIMENTAL_CLIENT_FEATURE_FLAGS= ## Require new device emails. When a user logs in an email is required to be sent. From 80d1859452bc15bec3d03261dc6c41ea877a75b4 Mon Sep 17 00:00:00 2001 From: lede <52017225+xkmttc@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:18:00 +0800 Subject: [PATCH 29/43] Fix push device unregistration endpoint (#7891) --- src/api/push.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/api/push.rs b/src/api/push.rs index e87a0985..eee1873d 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -139,21 +139,25 @@ pub async fn unregister_push_device(push_id: Option<&PushId>) -> EmptyResult { if !CONFIG.push_enabled() || push_id.is_none() { return Ok(()); } - let auth_api_token = get_auth_api_token().await?; + let data = json!({ + "id": push_id.as_ref().unwrap(), + }); + let auth_api_token = get_auth_api_token().await?; let auth_header = format!("Bearer {auth_api_token}"); - match make_http_request( - Method::POST, - &format!("{}/push/delete/{}", CONFIG.push_relay_uri(), push_id.as_ref().unwrap()), - )? - .header(AUTHORIZATION, auth_header) - .send() - .await + if let Err(e) = make_http_request(Method::POST, &(CONFIG.push_relay_uri() + "/push/delete"))? + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .header(AUTHORIZATION, auth_header) + .json(&data) + .send() + .await? + .error_for_status() { - Ok(r) => r, - Err(e) => err!(format!("An error occurred during device unregistration: {e}")), - }; + err!(format!("An error occurred during device unregistration: {e}")); + } + Ok(()) } From be5d3ad907bbe560dfe77b5078c6e6823267e95d Mon Sep 17 00:00:00 2001 From: Tauan BF <11513929+tauanbinato@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:22:12 -0400 Subject: [PATCH 30/43] Don't return internal error details in bulk member responses; gate SSO token logging (#7777) --- src/api/core/organizations.rs | 25 ++++++++++++++++++++----- src/sso.rs | 6 +++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 6fccc850..9209d891 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1156,7 +1156,10 @@ async fn bulk_reinvite_members( for member_id in data.ids { let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { Ok(()) => String::new(), - Err(e) => format!("{e:?}"), + Err(e) => { + warn!("Unable to reinvite member {member_id}: {e:?}"); + e.message().to_owned() + } }; bulk_response.push(json!( @@ -1329,7 +1332,10 @@ async fn bulk_confirm_invite( let user_key = invite.key.unwrap_or_default(); let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), - Err(e) => format!("{e:?}"), + Err(e) => { + warn!("Unable to confirm member {member_id}: {e:?}"); + e.message().to_owned() + } }; bulk_response.push(json!( @@ -1610,7 +1616,10 @@ async fn bulk_delete_member( for member_id in data.ids { let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await { Ok(()) => String::new(), - Err(e) => format!("{e:?}"), + Err(e) => { + warn!("Unable to delete member {member_id}: {e:?}"); + e.message().to_owned() + } }; bulk_response.push(json!( @@ -2222,7 +2231,10 @@ async fn bulk_revoke_members( for member_id in members { let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), - Err(e) => format!("{e:?}"), + Err(e) => { + warn!("Unable to revoke member {member_id}: {e:?}"); + e.message().to_owned() + } }; bulk_response.push(json!( @@ -2325,7 +2337,10 @@ async fn bulk_restore_members( for member_id in data.ids { let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), - Err(e) => format!("{e:?}"), + Err(e) => { + warn!("Unable to restore member {member_id}: {e:?}"); + e.message().to_owned() + } }; bulk_response.push(json!( diff --git a/src/sso.rs b/src/sso.rs index 6b575a3e..cc3b51f1 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -309,7 +309,11 @@ pub async fn exchange_code( user_name: user_name.clone(), }; - debug!("Authenticated user {authenticated_user:?}"); + if CONFIG.sso_debug_tokens() { + debug!("Authenticated user {authenticated_user:?}"); + } else { + debug!("Authenticated user {identifier}"); + } sso_auth.auth_response = Some(authenticated_user.clone()); sso_auth.updated_at = Utc::now().naive_utc(); sso_auth.save(conn).await?; From 863395f5e28eab5f939e341bb700320c5eb8e0d7 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:25:23 +0100 Subject: [PATCH 31/43] Add configurable Fill Assist rules URL (#7791) --- .env.template | 5 +++ src/api/core/mod.rs | 1 + src/api/core/organizations.rs | 62 ++++++++++++++++++++++++++++++++--- src/config.rs | 19 +++++++++++ src/db/models/mod.rs | 2 +- src/db/models/org_policy.rs | 10 ++++++ 6 files changed, 94 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index f0695a16..66cb2f64 100644 --- a/.env.template +++ b/.env.template @@ -386,6 +386,11 @@ # HTTP_REQUEST_BLOCK_NON_GLOBAL_IPS=true ## Client Settings +## Server-wide Fill Assist rules feed base directory URL, not a JSON file. +## Defaults to https://fillassist.bitwarden.com. +## Must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace. +# FILL_ASSIST_RULES_URL=https://fillassist.bitwarden.com + ## Enable experimental feature flags for clients. ## This is a comma-separated list of flags, e.g. "flag1,flag2,flag3". ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index e6a184dd..b7923982 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -247,6 +247,7 @@ fn config() -> Json { "notifications": format!("{domain}/notifications"), "sso": "", "cloudRegion": null, + "fillAssistRules": CONFIG.fill_assist_rules_url(), }, // Bitwarden uses this for the self-hosted servers to indicate the default push technology "push": { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 9209d891..3328423e 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -16,9 +16,9 @@ use crate::{ DbConn, models::{ Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, - EventType, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, MembershipStatus, - MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, OrganizationId, TwoFactor, - TwoFactorType, User, UserId, + EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, + MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, + OrganizationId, TwoFactor, TwoFactorType, User, UserId, }, }, mail, @@ -2048,6 +2048,29 @@ async fn put_policy( err!("Invalid or unsupported policy type") }; + let current_policy = OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await; + let is_currently_enabled = current_policy.as_ref().is_some_and(|policy| policy.enabled); + + // Fill Assist depends on Single Organization. Match Bitwarden's dependency checks by only checking when the + // policy changes state. + if pol_type_enum == OrgPolicyType::FillAssist && data.enabled && !is_currently_enabled { + let single_org_policy_enabled = + OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::SingleOrg, &conn).await.is_some_and(|p| p.enabled); + + if !single_org_policy_enabled { + err!("Turn on the Single organization policy because it is required for the Activate fill assist policy.") + } + } + + if pol_type_enum == OrgPolicyType::SingleOrg && !data.enabled && is_currently_enabled { + let fill_assist_policy_enabled = + OrgPolicy::find_by_org_and_type(&org_id, OrgPolicyType::FillAssist, &conn).await.is_some_and(|p| p.enabled); + + if fill_assist_policy_enabled { + err!("Turn off the Activate fill assist policy because it requires the Single organization policy.") + } + } + // Bitwarden only allows the Reset Password policy when Single Org policy is enabled // Vaultwarden encouraged to use multiple orgs instead of groups because groups were not available in the past // Now that groups are available we can enforce this option when wanted. @@ -2080,6 +2103,37 @@ async fn put_policy( } } + if pol_type_enum == OrgPolicyType::FillAssist { + let fill_assist_data = match data.data.as_ref() { + None | Some(Value::Null) => FillAssistPolicyData { + rules_url: None, + }, + Some(data) => { + let Ok(data) = serde_json::from_value::(data.clone()) else { + err!("Invalid data for FillAssist policy: rulesUrl has an invalid value.") + }; + data + } + }; + + if data.enabled { + let Some(rules_url) = fill_assist_data.rules_url.filter(|url| !url.trim().is_empty()) else { + err!("The RulesUrl field is required.") + }; + let Ok(rules_url) = url::Url::parse(&rules_url) else { + err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.") + }; + + if !rules_url.has_host() || !matches!(rules_url.scheme(), "http" | "https" | "ftp") { + err!("The RulesUrl field is not a valid fully-qualified http, https, or ftp URL.") + } + + if rules_url.scheme() != "https" { + err!("RulesUrl must use HTTPS.") + } + } + } + // When enabling the TwoFactorAuthentication policy, revoke all members that do not have 2FA if pol_type_enum == OrgPolicyType::TwoFactorAuthentication && data.enabled { two_factor::enforce_2fa_policy_for_org( @@ -2127,7 +2181,7 @@ async fn put_policy( } } - let mut policy = match OrgPolicy::find_by_org_and_type(&org_id, pol_type_enum, &conn).await { + let mut policy = match current_policy { Some(p) => p, None => OrgPolicy::new(org_id.clone(), pol_type_enum, false, "{}".to_owned()), }; diff --git a/src/config.rs b/src/config.rs index 54b3d056..35657d2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -603,6 +603,9 @@ make_config! { /// This setting applies globally to all users. To control this on a per-org basis instead, use the "Disable Send" org policy. sends_allowed: bool, true, def, true; + /// Fill Assist Rules URL |> HTTPS URL for the server-wide Fill Assist rules feed base directory. + fill_assist_rules_url: String, true, def, "https://fillassist.bitwarden.com".to_owned(); + /// HIBP Api Key |> HaveIBeenPwned API Key, request it here: https://haveibeenpwned.com/API/Key hibp_api_key: Pass, true, option; @@ -1023,6 +1026,21 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { ); } + let valid_fill_assist_rules_url = Url::parse(&cfg.fill_assist_rules_url).is_ok_and(|url| { + url.scheme() == "https" + && url.has_host() + && url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none() + && cfg.fill_assist_rules_url.trim() == cfg.fill_assist_rules_url + }); + if !valid_fill_assist_rules_url { + err!( + "`FILL_ASSIST_RULES_URL` must be a valid HTTPS URL without credentials, query parameters, fragments, or surrounding whitespace." + ); + } + let connect_src = cfg.allowed_connect_src.to_lowercase(); for url in connect_src.split_whitespace() { if !url.starts_with("https://") || Url::parse(url).is_err() { @@ -1477,6 +1495,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ // Autofill Team "undetermined-cipher-scenario-logic", "enable-basic-auth-response", + "fill-assist-targeting-rules", "ssh-agent-v2", "windows-desktop-autotype", "windows-desktop-autotype-ga", diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 3a592b34..fda76df4 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -31,7 +31,7 @@ pub use self::event::{Event, EventType}; pub use self::favorite::Favorite; pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; -pub use self::org_policy::{OrgPolicy, OrgPolicyId, OrgPolicyType}; +pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, OrganizationId, diff --git a/src/db/models/org_policy.rs b/src/db/models/org_policy.rs index 31938f76..554f51fc 100644 --- a/src/db/models/org_policy.rs +++ b/src/db/models/org_policy.rs @@ -53,6 +53,8 @@ pub enum OrgPolicyType { // AutoConfirm = 18, // Not supported (not implemented yet) // BlockClaimedDomainAccountCreation = 19, // Not supported (Not AGPLv3 Licensed) OrganizationUserNotification = 20, + // SendControls = 21, // Not supported yet + FillAssist = 22, } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Core/AdminConsole/Models/Data/Organizations/Policies/SendOptionsPolicyData.cs#L5 @@ -71,6 +73,14 @@ pub struct ResetPasswordDataModel { pub auto_enroll_enabled: bool, } +// https://github.com/bitwarden/server/blob/13bc813de8e715a71d3410f1e584e87ccdf76025/src/Core/AdminConsole/Models/Data/Organizations/Policies/FillAssistPolicyData.cs +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FillAssistPolicyData { + #[serde(alias = "RulesUrl")] + pub rules_url: Option, +} + /// Local methods impl OrgPolicy { pub fn new(org_uuid: OrganizationId, atype: OrgPolicyType, enabled: bool, data: String) -> Self { From 42660af757559a1d530a0a1fe42581d013aca1d8 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:32:37 +0100 Subject: [PATCH 32/43] Add registration email verification clicked endpoint (#7790) --- src/api/identity.rs | 27 +++++++++++++++++++++++++++ src/mail.rs | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/api/identity.rs b/src/api/identity.rs index a7a18cf7..146cec4c 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -46,6 +46,7 @@ pub fn routes() -> Vec { post_prelogin, prelogin_password, register_verification_email, + register_verification_email_clicked, register_finish, prevalidate, authorize, @@ -1034,6 +1035,13 @@ struct RegisterVerificationData { // receiveMarketingEmails: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisterVerificationEmailClickedData { + email: String, + email_verification_token: String, +} + #[derive(rocket::Responder)] enum RegisterVerificationResponse { #[response(status = 204)] @@ -1096,6 +1104,25 @@ async fn register_verification_email( } } +#[post("/accounts/register/verification-email-clicked", data = "")] +async fn register_verification_email_clicked( + data: Json, + conn: DbConn, +) -> EmptyResult { + let data = data.into_inner(); + let token_valid = auth::decode_register_verify(&data.email_verification_token) + .is_ok_and(|claims| claims.verified && claims.sub.eq_ignore_ascii_case(&data.email)); + let user_exists = User::find_by_mail(&data.email, &conn) + .await + .is_some_and(|user| !user.password_hash.is_empty() || user.private_key.is_some()); + + if !token_valid || user_exists { + err!("Expired link. Please restart registration or try logging in. You may already have an account") + } + + Ok(()) +} + #[post("/accounts/register/finish", data = "")] async fn register_finish(data: Json, ip: ClientIp, conn: DbConn) -> JsonResult { crate::ratelimit::check_limit_unauthenticated(&ip.ip)?; diff --git a/src/mail.rs b/src/mail.rs index 06aac97f..14294213 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -206,7 +206,7 @@ pub async fn send_verify_email(address: &str, user_id: &UserId) -> EmptyResult { pub async fn send_register_verify_email(email: &str, token: &str) -> EmptyResult { let mut query = url::Url::parse("https://query.builder").unwrap(); - query.query_pairs_mut().append_pair("email", email).append_pair("token", token); + query.query_pairs_mut().append_pair("email", email).append_pair("token", token).append_pair("fromEmail", "true"); let Some(query_string) = query.query() else { err!("Failed to build verify URL query parameters") }; From 0dbc92f2b709ab0dc5b58a6633cbad4dda5d4603 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:33:26 +0100 Subject: [PATCH 33/43] Make 2FA remember duration configurable (#7421) --- .env.template | 3 +++ src/auth.rs | 6 +++++- src/config.rs | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index 66cb2f64..92362088 100644 --- a/.env.template +++ b/.env.template @@ -613,6 +613,9 @@ ## Note that the checkbox would still be present, but ignored. # DISABLE_2FA_REMEMBER=false ## +## Number of days before a remembered 2FA login expires (min: 1, max: 90). +# TWO_FACTOR_REMEMBER_DAYS=30 +## ## Authenticator Settings ## Disable authenticator time drifted codes to be valid. ## TOTP codes of the previous and next 30 seconds will be invalid diff --git a/src/auth.rs b/src/auth.rs index 27a156e6..cedbb130 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -503,13 +503,17 @@ pub fn generate_2fa_remember_claims(device_uuid: DeviceId, user_uuid: UserId) -> let time_now = Utc::now(); TwoFactorRememberClaims { nbf: time_now.timestamp(), - exp: (time_now + TimeDelta::try_days(30).unwrap()).timestamp(), + exp: two_factor_remember_expiration(time_now, CONFIG.two_factor_remember_days()), iss: JWT_2FA_REMEMBER_ISSUER.to_string(), sub: device_uuid, user_uuid, } } +fn two_factor_remember_expiration(time_now: DateTime, remember_days: i64) -> i64 { + (time_now + TimeDelta::try_days(remember_days).expect("TWO_FACTOR_REMEMBER_DAYS is validated")).timestamp() +} + #[derive(Debug, Serialize, Deserialize)] pub struct BasicJwtClaims { // Not before diff --git a/src/config.rs b/src/config.rs index 35657d2c..55d08259 100644 --- a/src/config.rs +++ b/src/config.rs @@ -736,6 +736,8 @@ make_config! { /// Disable Two-Factor remember |> Enabling this would force the users to use a second factor to login every time. /// Note that the checkbox would still be present, but ignored. disable_2fa_remember: bool, true, def, false; + /// Two-Factor remember duration |> Number of days before a remembered Two-Factor login expires (min: 1, max: 90). + two_factor_remember_days: i64, true, def, 30; /// Disable authenticator time drifted codes to be valid |> Enabling this only allows the current TOTP code to be valid /// TOTP codes of the previous and next 30 seconds will be invalid. @@ -1275,6 +1277,10 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { err!("`INVITATION_EXPIRATION_HOURS` has a minimum duration of 1 hour") } + if !(1..=90).contains(&cfg.two_factor_remember_days) { + err!("`TWO_FACTOR_REMEMBER_DAYS` must be between 1 and 90 days") + } + // Validate schedule crontab format if !cfg.send_purge_schedule.is_empty() && cfg.send_purge_schedule.parse::().is_err() { err!("`SEND_PURGE_SCHEDULE` is not a valid cron expression") From f1c7eccd4a3dc9505b87d1f71f915e8bb503466b Mon Sep 17 00:00:00 2001 From: Michael Mu <69072633+MichaelMuClaude@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:38:39 +0800 Subject: [PATCH 34/43] Bump user revision once for bulk cipher move (#7840) --- src/api/core/ciphers.rs | 14 ++++++++++++-- src/db/models/cipher.rs | 9 +++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 5070d8f4..92499b9f 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -23,7 +23,7 @@ use crate::{ models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, - Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, User, UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -1738,12 +1738,22 @@ async fn move_cipher_selected( // Find all ciphers a user has access to, all others will be ignored let accessible_ciphers = Cipher::find_by_user_and_ciphers(user_id, &data.ids, &conn).await; let accessible_ciphers_count = accessible_ciphers.len(); + let mut move_result = Ok(()); for cipher in accessible_ciphers { - cipher.move_to_folder(data.folder_id.clone(), user_id, &conn).await?; + move_result = cipher.move_to_folder_without_revision_update(data.folder_id.clone(), user_id, &conn).await; + if move_result.is_err() { + break; + } if cipher_count == 1 { single_cipher = Some(cipher); } } + // Update the revision once for the whole batch. Also when a move failed halfway, + // since the ciphers moved before it are already changed. + if accessible_ciphers_count > 0 { + User::update_uuid_revision(user_id, &conn).await; + } + move_result?; if let Some(cipher) = single_cipher { nt.send_cipher_update( diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index fca03653..630146e4 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -520,7 +520,16 @@ impl Cipher { conn: &DbConn, ) -> EmptyResult { User::update_uuid_revision(user_uuid, conn).await; + self.move_to_folder_without_revision_update(folder_uuid, user_uuid, conn).await + } + /// Same as `move_to_folder`, but the caller is responsible for updating the user's revision. + pub async fn move_to_folder_without_revision_update( + &self, + folder_uuid: Option, + user_uuid: &UserId, + conn: &DbConn, + ) -> EmptyResult { match (self.get_folder_uuid(user_uuid, conn).await, folder_uuid) { // No changes (None, None) => Ok(()), From e056359a42656ecb844c856dcc9cff36ca322224 Mon Sep 17 00:00:00 2001 From: Cassian433 Date: Fri, 30 Oct 2026 03:46:14 +0530 Subject: [PATCH 35/43] Send logout notification when deleting a user (#7839) --- src/api/admin.rs | 14 +++++++++++--- src/api/core/accounts.rs | 36 +++++++++++++++++++++++++++++------- src/api/mod.rs | 4 ++-- src/api/push.rs | 32 +++++++++++++++++++------------- src/db/models/user.rs | 4 ++-- 5 files changed, 63 insertions(+), 27 deletions(-) diff --git a/src/api/admin.rs b/src/api/admin.rs index 13fbe87d..1a2b8c9a 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -17,7 +17,7 @@ use crate::{ api::{ ApiResult, EmptyResult, JsonResult, Notify, core::{log_event, two_factor}, - unregister_push_device, + push_logout_to_relay, unregister_push_device, }, auth::{ClientIp, Secure, decode_admin, encode_jwt, generate_admin_claims}, config::ConfigBuilder, @@ -419,13 +419,21 @@ async fn get_user_json(user_id: UserId, _token: AdminToken, conn: DbConn) -> Jso } #[post("/users//delete", format = "application/json")] -async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn) -> EmptyResult { +async fn delete_user(user_id: UserId, token: AdminToken, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let user = get_user_or_404(&user_id, &conn).await?; - // Get the membership records before deleting the actual user + // Get the membership records and push devices before deleting the actual user let memberships = Membership::find_any_state_by_user(&user_id, &conn).await; + let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user_id, &conn).await; let res = user.delete(&conn).await; + if res.is_ok() { + nt.send_logout(&user, None, &conn).await; + if has_push_device { + push_logout_to_relay(&user, None); + } + } + for membership in memberships { log_event( EventType::OrganizationUserDeleted, diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index dd2e5801..cd280bc7 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -13,7 +13,7 @@ use crate::{ api::{ AnonymousNotify, ApiResult, EmptyResult, JsonResult, Notify, PasswordOrOtpData, UpdateType, core::{accept_org_invite, log_user_event, two_factor::email}, - master_password_policy, register_push_device, unregister_push_device, + master_password_policy, push_logout_to_relay, register_push_device, unregister_push_device, }, auth::{ClientHeaders, ClientIp, Headers, decode_delete, decode_invite, decode_verify_email}, crypto, @@ -1301,7 +1301,7 @@ struct DeleteRecoverTokenData { } #[post("/accounts/delete-recover-token", data = "")] -async fn post_delete_recover_token(data: Json, conn: DbConn) -> EmptyResult { +async fn post_delete_recover_token(data: Json, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: DeleteRecoverTokenData = data.into_inner(); let Ok(claims) = decode_delete(&data.token) else { @@ -1315,22 +1315,44 @@ async fn post_delete_recover_token(data: Json, conn: DbC if claims.sub != *user.uuid { err!("Invalid claim"); } - user.delete(&conn).await + + // The devices are deleted together with the user, so check for push devices beforehand + let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; + user.delete(&conn).await?; + + nt.send_logout(&user, None, &conn).await; + if has_push_device { + push_logout_to_relay(&user, None); + } + Ok(()) } #[post("/accounts/delete", data = "")] -async fn post_delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { - delete_account(data, headers, conn).await +async fn post_delete_account( + data: Json, + headers: Headers, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + delete_account(data, headers, conn, nt).await } #[delete("/accounts", data = "")] -async fn delete_account(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { +async fn delete_account(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); let user = headers.user; data.validate(&user, true, &conn).await?; - user.delete(&conn).await + // The devices are deleted together with the user, so check for push devices beforehand + let has_push_device = CONFIG.push_enabled() && Device::check_user_has_push_device(&user.uuid, &conn).await; + user.delete(&conn).await?; + + nt.send_logout(&user, None, &conn).await; + if has_push_device { + push_logout_to_relay(&user, None); + } + Ok(()) } #[expect(clippy::needless_pass_by_value, reason = "Not beneficial for Headers")] diff --git a/src/api/mod.rs b/src/api/mod.rs index 0ac78f5c..450c2113 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -25,8 +25,8 @@ pub use crate::api::{ notifications::routes as notifications_routes, notifications::{AnonymousNotify, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS}, push::{ - push_cipher_update, push_folder_update, push_logout, push_send_update, push_user_update, register_push_device, - unregister_push_device, + push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update, + register_push_device, unregister_push_device, }, web::catchers as web_catchers, web::routes as web_routes, diff --git a/src/api/push.rs b/src/api/push.rs index eee1873d..4da790e4 100644 --- a/src/api/push.rs +++ b/src/api/push.rs @@ -194,22 +194,28 @@ pub async fn push_cipher_update(ut: UpdateType, cipher: &Cipher, device: &Device pub async fn push_logout(user: &User, acting_device: Option<&Device>, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { - tokio::task::spawn(send_to_push_relay(json!({ - "userId": user.uuid, - "organizationId": (), - "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()), - "identifier": acting_device.map(|d| &d.uuid), - "type": UpdateType::LogOut as i32, - "payload": { - "userId": user.uuid, - "date": format_date(&user.updated_at) - }, - "clientType": null, - "installationId": null - }))); + push_logout_to_relay(user, acting_device); } } +// Sends the logout without checking for push devices first. +// Needed after deleting a user, when the devices are already gone from the database. +pub fn push_logout_to_relay(user: &User, acting_device: Option<&Device>) { + tokio::task::spawn(send_to_push_relay(json!({ + "userId": user.uuid, + "organizationId": (), + "deviceId": acting_device.and_then(|d| d.push_uuid.as_ref()), + "identifier": acting_device.map(|d| &d.uuid), + "type": UpdateType::LogOut as i32, + "payload": { + "userId": user.uuid, + "date": format_date(&user.updated_at) + }, + "clientType": null, + "installationId": null + }))); +} + pub async fn push_user_update(ut: UpdateType, user: &User, push_uuid: Option<&PushId>, conn: &DbConn) { if Device::check_user_has_push_device(&user.uuid, conn).await { tokio::task::spawn(send_to_push_relay(json!({ diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 2f3afc02..30541428 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -362,7 +362,7 @@ impl User { } } - pub async fn delete(self, conn: &DbConn) -> EmptyResult { + pub async fn delete(&self, conn: &DbConn) -> EmptyResult { for member in Membership::find_confirmed_by_user(&self.uuid, conn).await { if member.atype == MembershipType::Owner && Membership::count_confirmed_by_org_and_type(&member.org_uuid, MembershipType::Owner, conn).await <= 1 @@ -385,7 +385,7 @@ impl User { Invitation::take(&self.email, conn).await; // Delete invitation if any conn.run(move |conn| { - diesel::delete(users::table.filter(users::uuid.eq(self.uuid))).execute(conn).map_res("Error deleting user") + diesel::delete(users::table.filter(users::uuid.eq(&self.uuid))).execute(conn).map_res("Error deleting user") }) .await } From acb574b7684876aa54ff3e4246316b1eccdf6627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sun, 1 Nov 2026 16:30:35 +0100 Subject: [PATCH 36/43] Two small fixes for upstream parity (#7918) --- src/api/core/sends.rs | 11 ++++++----- src/api/core/two_factor/email.rs | 18 +++++++++++++----- src/db/models/send.rs | 1 - 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a604b74..f8cc92bc 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -268,11 +268,12 @@ async fn post_send_file_v2(data: Json, headers: Headers, conn: DbConn) let file_id = crate::crypto::generate_send_file_id(); let mut data_value: Value = serde_json::from_str(&send.data)?; - if let Some(o) = data_value.as_object_mut() { - o.insert(String::from("id"), Value::String(file_id.clone())); - o.insert(String::from("size"), Value::Number(file_length.into())); - o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); - } + let Some(o) = data_value.as_object_mut() else { + err!("Invalid Send file data") + }; + o.insert(String::from("id"), Value::String(file_id.clone())); + o.insert(String::from("size"), Value::Number(file_length.into())); + o.insert(String::from("sizeName"), Value::String(crate::util::get_display_size(file_length))); send.data = serde_json::to_string(&data_value)?; send.save(&conn).await?; diff --git a/src/api/core/two_factor/email.rs b/src/api/core/two_factor/email.rs index 66f2a89e..15bfdbc3 100644 --- a/src/api/core/two_factor/email.rs +++ b/src/api/core/two_factor/email.rs @@ -84,18 +84,26 @@ async fn send_email_login(data: Json, client_headers: Client ) } } else if let Some(auth_request_id) = auth_request_id { - let Some(auth_request) = AuthRequest::find_by_uuid(auth_request_id, &conn).await else { - err!("AuthRequest doesn't exist", "User not found") - }; let Some(code) = &data.auth_request_access_code else { err!("no auth request access code") }; + let Some(auth_request) = AuthRequest::find_by_uuid_and_user(auth_request_id, &user.uuid, &conn).await + else { + err!("AuthRequest doesn't exist", "Invalid auth request or does not belong to user") + }; - if auth_request.device_type != client_headers.device_type + let request_expired = Utc::now().naive_utc() >= auth_request.creation_date + TimeDelta::minutes(5); + if !auth_request.approved.unwrap_or(false) + || auth_request.authentication_date.is_some() + || request_expired + || auth_request.device_type != client_headers.device_type || auth_request.request_ip != client_headers.ip.ip.to_string() || !auth_request.check_access_code(code) { - err!("AuthRequest doesn't exist", "Invalid device, IP or code") + err!( + "AuthRequest doesn't exist", + "Auth request not approved, already used or expired, or invalid device, IP or code" + ) } } else if let Some(device_identifier) = &data.device_identifier { // iOS/Android SSO logins send the email and device id but no password hash, diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 721378f3..118f947f 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -334,7 +334,6 @@ impl Send { pub async fn size_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { #[derive(serde::Deserialize)] struct FileData { - #[serde(rename = "size", alias = "Size")] size: NumberOrString, } From 79ee29d501624b25e063b86bddf1ba57d4e3092b Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:09:56 +0100 Subject: [PATCH 37/43] Add HTTP auth and policy revocation audit events (#7919) --- src/api/core/events.rs | 4 ++-- src/api/core/organizations.rs | 2 +- src/api/core/two_factor/mod.rs | 14 +++++++++++--- src/db/models/event.rs | 5 +++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/api/core/events.rs b/src/api/core/events.rs index 0d9f32ad..3b758bb2 100644 --- a/src/api/core/events.rs +++ b/src/api/core/events.rs @@ -214,8 +214,8 @@ async fn post_events_collect(data: Json>, headers: Headers, .await; } } - // Client-side cipher events (viewed, copied, toggled, autofilled). - 1107..=1114 | 1117 | 1119..=1132 => { + // Client-side cipher events (viewed, copied, toggled, autofilled, HTTP auth). + 1107..=1114 | 1117 | 1119..=1133 => { // The cipher determines the organization the event is logged to, so make sure the // user can actually access it instead of trusting the provided cipher uuid. if let Some(cipher_uuid) = &event.cipher_id diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 3328423e..ab13a8e7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2165,7 +2165,7 @@ async fn put_policy( } log_event( - EventType::OrganizationUserRemoved, + EventType::OrganizationUserRevokedSingleOrganizationNonCompliance, &member.uuid, &org_id, &headers.user.uuid, diff --git a/src/api/core/two_factor/mod.rs b/src/api/core/two_factor/mod.rs index db533b50..cfec98c7 100644 --- a/src/api/core/two_factor/mod.rs +++ b/src/api/core/two_factor/mod.rs @@ -151,7 +151,7 @@ pub async fn enforce_2fa_policy( member.save(conn).await?; log_event( - EventType::OrganizationUserRevoked, + EventType::OrganizationUserRevokedTwoFactorNonCompliance, &member.uuid, &member.org_uuid, act_user_id, @@ -185,8 +185,16 @@ pub async fn enforce_2fa_policy_for_org( member.revoke(); member.save(conn).await?; - log_event(EventType::OrganizationUserRevoked, &member.uuid, org_id, act_user_id, device_type, ip, conn) - .await; + log_event( + EventType::OrganizationUserRevokedTwoFactorNonCompliance, + &member.uuid, + org_id, + act_user_id, + device_type, + ip, + conn, + ) + .await; } } diff --git a/src/db/models/event.rs b/src/db/models/event.rs index 8dc3a5ad..e020bb3d 100644 --- a/src/db/models/event.rs +++ b/src/db/models/event.rs @@ -79,6 +79,7 @@ pub enum EventType { CipherSoftDeleted = 1115, CipherRestored = 1116, CipherClientToggledCardNumberVisible = 1117, + CipherClientHttpAuthReleased = 1133, // Collection CollectionCreated = 1300, @@ -111,8 +112,8 @@ pub enum EventType { // OrganizationUserAutomaticallyConfirmed = 1517, // OrganizationUserSelfRevoked = 1518, // User self-revoked due to declining organization data ownership policy OrganizationUserAdminResetTwoFactor = 1519, - // OrganizationUserRevoked_TwoFactorNonCompliance = 1520, - // OrganizationUserRevoked_SingleOrganizationNonCompliance = 1521, + OrganizationUserRevokedTwoFactorNonCompliance = 1520, + OrganizationUserRevokedSingleOrganizationNonCompliance = 1521, OrganizationUserNotificationBannerActionClicked = 1522, // Organization From 5d870b796fddcaf0fc35691b688933cdada70259 Mon Sep 17 00:00:00 2001 From: Mathijs van Veluw Date: Sun, 1 Nov 2026 18:59:00 +0100 Subject: [PATCH 38/43] Clippy fixes, crate and web-vault updates (#7920) --- Cargo.lock | 636 ++++++++++++++++++++++++++-------- Cargo.toml | 10 +- docker/DockerSettings.yaml | 4 +- docker/Dockerfile.alpine | 12 +- docker/Dockerfile.debian | 12 +- src/api/core/organizations.rs | 11 +- src/sso_client.rs | 2 +- 7 files changed, 512 insertions(+), 175 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cef9e27..587ac6b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.117.0" +version = "1.119.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5316813498fa9c403877007934ae448aa44b18136f58051f8fa3c5b79b44bdc6" +checksum = "3c414f25ec4aa9dc18f5cc8f55e18848bb509fcda4ce7c77bee6072e95806bc9" dependencies = [ "arc-swap", "aws-credential-types", @@ -443,9 +443,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.119.0" +version = "1.121.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31db35e5662787b786daba15f431e4393c7ea512b70fa112bd048d1f3ecf877a" +checksum = "bc638bb4355e6514334dcc3228cd4622f5ee54d704bd6a9010a5a91d7856d9d9" dependencies = [ "arc-swap", "aws-credential-types", @@ -468,9 +468,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.122.0" +version = "1.124.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a10491638d9e560db90c484dbc77d73b8ad202780cdc9b9e92180603acb07ada" +checksum = "3f8a0dd8fbde04ba58ad722113c7addb11bf94cf549b1ea0eb9ea427fb87198a" dependencies = [ "arc-swap", "aws-credential-types", @@ -494,9 +494,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.6.3" +version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d3d48355cb86b15ccbb38b8bc6b87500c824ee4ed8cbc10a961b435242b5e16" +checksum = "f385da9b7757ed4c8214797ad663ff534fb622006143530aa80e21f8e06ff85c" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -526,9 +526,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.64.2" +version = "0.64.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba8960956e0ee0ca3f3161e62913a61bc65a7ef638ca3ef1d4c7159f8443e398" +checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -546,9 +546,9 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.65.0" +version = "0.65.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f6835fb38bc6fc8c91e584b0366eb4ff595e5c485134dbf4f0bdeaa18856666" +checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.16.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cef0c41e8ca40f74f68ed8ee4d1ddbde12bb847b95ef9b3c025681ddfae58a" +checksum = "4cb80fb19c084da5fca9d797845735e184445e5b958599440d67529bf6ef1b64" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -601,9 +601,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.19.1" +version = "1.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06788004dea79018413f381891da6dcca23196acd79c4d51b767fbdacc76eae4" +checksum = "8c4e85cffe9944b10d576b075432bd8f98dbc081e8681cb37d5b2d87dc27bc84" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api-macros", @@ -618,9 +618,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api-macros" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6" dependencies = [ "proc-macro2", "quote", @@ -629,9 +629,9 @@ dependencies = [ [[package]] name = "aws-smithy-schema" -version = "1.1.0" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec8dae1015a6d959a5faf86fbdf77486cfe19e4ccb6ee009d8a46efc45028a4" +checksum = "9543fc722ed4865ab29fb66bd7ed0bef7b029d265ba8c587144511e30006d431" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -640,9 +640,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55752025c39c0e094962fd73f1c57014b191f678612e517f219468e575af9822" +checksum = "cf183d70774ea2cfcc101ca74a8884e4c76be8b9df65cd303f6a1175c88e8f80" dependencies = [ "base64-simd", "bytes", @@ -660,9 +660,9 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.64.0" +version = "0.64.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6581618a2fd006159016d1c36881d94d61b4a017f4a8d631310875e18252413" +checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-schema", @@ -691,6 +691,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -809,9 +815,9 @@ dependencies = [ [[package]] name = "brotli" -version = "9.1.0" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6737ce8b81460bf9407909f73ff50bf13f7cb8577e1e76715f57ed0eba58ba44" +checksum = "627b7cfb0578f0eafce087a90fcd40add1231900b69e4d4de2d456260a4f5d81" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -873,16 +879,16 @@ dependencies = [ [[package]] name = "cached" -version = "4.0.3" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a0dbacacc2069fc95062beec3f79a28f0f54ef2f72d7fe69000d1557f7c3d0" +checksum = "6f886e989b5b6e7991606f2550e45a99e130024465ba8d67f9f9b256eb7044bf" dependencies = [ "ahash", "async-lock", "cached_proc_macro", "cached_proc_macro_types", "hashbrown 0.17.1", - "parking_lot", + "parking_lot 0.12.5", "thiserror 2.0.21", "web-time", ] @@ -1118,6 +1124,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1215,6 +1227,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1231,16 +1259,19 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] name = "ctutils" -version = "0.4.5" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1253,7 +1284,23 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -1383,9 +1430,9 @@ dependencies = [ "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", - "lock_api", + "lock_api 0.4.14", "once_cell", - "parking_lot_core", + "parking_lot_core 0.9.12", ] [[package]] @@ -1438,7 +1485,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1713,12 +1771,27 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.3", + "digest 0.11.3", + "elliptic-curve 0.14.3", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.1", + "zeroize", ] [[package]] @@ -1727,8 +1800,18 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "signature 3.0.0", ] [[package]] @@ -1737,14 +1820,29 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "serde", + "sha2 0.11.1", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" @@ -1757,17 +1855,38 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", + "sec1 0.7.3", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.10", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -1793,9 +1912,9 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.42" +version = "0.8.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" dependencies = [ "cfg-if", "core_detect", @@ -1875,12 +1994,28 @@ dependencies = [ "subtle", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "figment" version = "0.10.19" @@ -2113,9 +2248,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2150,7 +2287,7 @@ dependencies = [ "getrandom 0.3.4", "hashbrown 0.16.1", "nonzero_ext", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "quanta", "rand 0.9.5", @@ -2178,11 +2315,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "rand_core 0.6.4", "subtle", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.20" @@ -2346,7 +2494,7 @@ dependencies = [ "moka", "ndk-context", "once_cell", - "parking_lot", + "parking_lot 0.12.5", "rand 0.10.3", "resolv-conf", "smallvec", @@ -2396,12 +2544,9 @@ dependencies = [ [[package]] name = "html5gum" -version = "0.8.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428502d3ec1742c35e015871aef742bc4722a9acfcb616b8ff79b519922e1c36" -dependencies = [ - "jetscii", -] +checksum = "bbae3133814a10216af5242a254420a74cb0d2f7715646924f5a8e7720cd188b" [[package]] name = "http" @@ -2465,7 +2610,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -2729,9 +2876,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.10.5" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -2742,12 +2889,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jetscii" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" - [[package]] name = "jiff" version = "0.2.38" @@ -2876,9 +3017,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -2892,19 +3033,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" dependencies = [ "base64 0.22.1", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hmac 0.12.1", "js-sys", - "p256", - "p384", + "p256 0.13.2", + "p384 0.13.1", "pem", "rand 0.8.8", "rsa", "serde", "serde_json", "sha2 0.10.9", - "signature", + "signature 2.2.0", "simple_asn1", "zeroize", ] @@ -3027,10 +3168,19 @@ dependencies = [ ] [[package]] -name = "log" -version = "0.4.34" +name = "lock_api" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "65d91b22da179613dcf4d6ec8349563f52a363f6849e03b00dd0a185ec4a59af" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" dependencies = [ "value-bag", ] @@ -3160,7 +3310,7 @@ dependencies = [ "equivalent", "event-listener 5.4.2", "futures-util", - "parking_lot", + "parking_lot 0.12.5", "portable-atomic", "smallvec", "tagptr", @@ -3487,21 +3637,22 @@ dependencies = [ [[package]] name = "openidconnect" -version = "4.0.1" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +checksum = "df0d098a59353679624d406825940139e2b2d554a52b64c9fb1b1bc42a11fe5c" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "dyn-clone", - "ed25519-dalek", + "ed25519-dalek 3.0.0", + "getrandom 0.4.3", "hmac 0.12.1", "http 1.5.0", "itertools", "log", "oauth2", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "rand 0.8.8", "rsa", "serde", @@ -3543,9 +3694,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" [[package]] name = "openssl-src" @@ -3600,24 +3751,51 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.3", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + [[package]] name = "p384" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", "sha2 0.10.9", ] +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.3", + "fiat-crypto 0.3.0", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -3630,8 +3808,18 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "lock_api", - "parking_lot_core", + "lock_api 0.4.14", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cf937ca4e1d0b7578784559063422fc39ae7c5a00f02471f2614d5486fd226" +dependencies = [ + "lock_api 0.5.0", + "parking_lot_core 0.10.0", ] [[package]] @@ -3642,7 +3830,19 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "parking_lot_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cfff060068cc18aa7815010fcaf80c68c7fb9e85fc7f5c0f9bf142c21d19826" +dependencies = [ + "libc", + "redox_syscall 0.9.5", "smallvec", "windows-link", ] @@ -3734,6 +3934,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3742,21 +3951,20 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.4" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd47a460a9cbc3b3429c7e22bf0d76653e779319bae45a499ca010c9a990bb23" +checksum = "a8db75153506001cb36ed924f08cadc862eb3f8c4d0d534139f29074769d7660" dependencies = [ "memchr", - "psm", - "stacker", + "pest_stack_guard", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.9.4" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "203e46697708f7519ab1608393cf89239cafb46b8e6d08a8ffe359359b11ef44" +checksum = "20aa17e11fb35664b67d5214a0afac57a360d231b6ec53d6eba0700b27d933b4" dependencies = [ "pest", "pest_generator", @@ -3764,9 +3972,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.4" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e84ae6340a458d3d6cfc73cf328cf3cfe90b2484cd0de25eb66aa954615ed4d5" +checksum = "626ec5afccf44ccbfeb2b232316510bb55b4bf34d891afcb0fb07d2b17d7fe9d" dependencies = [ "pest", "pest_meta", @@ -3777,13 +3985,23 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.9.4" +version = "2.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b57f92f32b3dbce6598fd8f5cdf1f06a6072570b8103fa21728fa9036923e710" +checksum = "b3f45b6caa65c1b08186832745159c2529994146340a7bf44168095469720f5d" dependencies = [ "pest", ] +[[package]] +name = "pest_stack_guard" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e6cd2217057fa86b6eaa512917e58089d875b957a31e35e7ab729977e0a5f2" +dependencies = [ + "psm", + "stacker", +] + [[package]] name = "phc" version = "0.6.1" @@ -3890,9 +4108,9 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -3901,8 +4119,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.3", + "spki 0.8.1", ] [[package]] @@ -3966,9 +4194,9 @@ dependencies = [ [[package]] name = "pq-sys" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9152654059e4fb5b66a4052acf3ee43a7a7132332e44cdb72a81ee93bd038950" +checksum = "297a95d6f2378891f949d9556723e15140e50d29eac07458dabcee8688995b1d" dependencies = [ "libc", "pkg-config", @@ -3986,13 +4214,40 @@ dependencies = [ "num-traits", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.10", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.3", + "once_cell", + "primefield", + "serdect", + "wnaf", ] [[package]] @@ -4128,7 +4383,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" dependencies = [ "log", - "parking_lot", + "parking_lot 0.12.5", "scheduled-thread-pool", ] @@ -4226,6 +4481,15 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_syscall" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "ref-cast" version = "1.0.27" @@ -4427,6 +4691,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.10", + "hmac 0.13.0", +] + [[package]] name = "ring" version = "0.17.14" @@ -4479,7 +4753,7 @@ dependencies = [ "memchr", "multer", "num_cpus", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "ref-cast", "ref-swap", @@ -4580,10 +4854,10 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -4637,9 +4911,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" dependencies = [ "bitflags 2.13.2", "errno", @@ -4754,11 +5028,11 @@ dependencies = [ [[package]] name = "scheduled-thread-pool" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +checksum = "f304b290c05b371b2aa1e8718242ff0394530f9334ecedcb9ae51330f37fc1ba" dependencies = [ - "parking_lot", + "parking_lot 0.13.0", ] [[package]] @@ -4803,10 +5077,24 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.3", + "hybrid-array", "subtle", "zeroize", ] @@ -4986,6 +5274,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -5075,6 +5373,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5151,11 +5459,11 @@ checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spinning_top" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +checksum = "9cf02766a1eb909bc7653557c66b384e44edc3ad979e6257c6173c65ea51bc7d" dependencies = [ - "lock_api", + "lock_api 0.4.14", ] [[package]] @@ -5165,7 +5473,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.3", ] [[package]] @@ -5460,7 +5778,7 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", + "parking_lot 0.12.5", "pin-project-lite", "signal-hook-registry", "socket2", @@ -5851,9 +6169,18 @@ checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" [[package]] name = "uuid" @@ -6015,12 +6342,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -6028,9 +6354,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.79" +version = "0.4.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" dependencies = [ "js-sys", "tokio", @@ -6039,9 +6365,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6049,9 +6375,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", @@ -6062,9 +6388,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] @@ -6084,9 +6410,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" dependencies = [ "js-sys", "wasm-bindgen", @@ -6467,6 +6793,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" @@ -6492,9 +6830,9 @@ dependencies = [ [[package]] name = "xattr" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" dependencies = [ "libc", "rustix", diff --git a/Cargo.toml b/Cargo.toml index 78791730..6b4e8965 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ syslog = "7.0.0" macros = { path = "./macros" } # Logging -log = "0.4.34" +log = "0.4.35" fern = { version = "0.7.1", features = ["syslog-7", "reopen-1"] } # We need the `log` feature for `tracing` to enable logging for several crates to work, like lettre or webauthn-rs tracing = { version = "0.1.44", features = ["log"] } @@ -204,7 +204,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [ hickory-resolver = "0.26.3" # Favicon extraction libraries -html5gum = "0.8.4" +html5gum = "0.9.0" regex = { version = "1.13.1", default-features = false, features = [ "perf", "std", @@ -215,7 +215,7 @@ bytes = "1.12.1" svg-hush = "0.9.7" # Cache function results (Used for version check and favicon fetching) -cached = { version = "4.0.3", features = ["async"] } +cached = { version = "4.1.0", features = ["async"] } # Used for custom short lived cookie jar during favicon extraction cookie = "0.18.2" @@ -235,7 +235,7 @@ governor = "0.10.4" ipnet = "2.12.2" # OIDC for SSO -openidconnect = { version = "4.0.1", default-features = false } +openidconnect = { version = "4.1.0", default-features = false } moka = { version = "0.12.16", features = ["future"] } # Check client versions for specific features. @@ -268,7 +268,7 @@ aws-config = { version = "1.13.0", optional = true, default-features = false, fe "sso", ] } aws-credential-types = { version = "1.3.1", optional = true } -aws-smithy-runtime-api = { version = "1.19.1", optional = true } +aws-smithy-runtime-api = { version = "1.19.2", optional = true } http = { version = "1.5.0", optional = true } reqsign-aws-v4 = { version = "3.3.1", optional = true } reqsign-core = { version = "3.3.2", optional = true } diff --git a/docker/DockerSettings.yaml b/docker/DockerSettings.yaml index e8bd0b2e..4ef24c9d 100644 --- a/docker/DockerSettings.yaml +++ b/docker/DockerSettings.yaml @@ -1,6 +1,6 @@ --- -vault_version: "v2026.8.0" -vault_image_digest: "sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a" +vault_version: "v2026.10.1" +vault_image_digest: "sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52" # Cross Compile Docker Helper Scripts v1.10.0 # We use the linux/amd64 platform shell scripts since there is no difference between the different platform scripts # https://github.com/tonistiigi/xx | https://hub.docker.com/r/tonistiigi/xx/tags diff --git a/docker/Dockerfile.alpine b/docker/Dockerfile.alpine index 41bea5cf..95e8784d 100644 --- a/docker/Dockerfile.alpine +++ b/docker/Dockerfile.alpine @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 -# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1 +# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a -# [docker.io/vaultwarden/web-vault:v2026.8.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 +# [docker.io/vaultwarden/web-vault:v2026.10.1] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault ########################## ALPINE BUILD IMAGES ########################## ## NOTE: The Alpine Base Images do not support other platforms then linux/amd64 and linux/arm64 diff --git a/docker/Dockerfile.debian b/docker/Dockerfile.debian index 154af7ef..621cfafe 100644 --- a/docker/Dockerfile.debian +++ b/docker/Dockerfile.debian @@ -19,15 +19,15 @@ # - From https://hub.docker.com/r/vaultwarden/web-vault/tags, # click the tag name to view the digest of the image it currently points to. # - From the command line: -# $ docker pull docker.io/vaultwarden/web-vault:v2026.8.0 -# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.8.0 -# [docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a] +# $ docker pull docker.io/vaultwarden/web-vault:v2026.10.1 +# $ docker image inspect --format "{{.RepoDigests}}" docker.io/vaultwarden/web-vault:v2026.10.1 +# [docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52] # # - Conversely, to get the tag name from the digest: -# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a -# [docker.io/vaultwarden/web-vault:v2026.8.0] +# $ docker image inspect --format "{{.RepoTags}}" docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 +# [docker.io/vaultwarden/web-vault:v2026.10.1] # -FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:632375471c7c06a799335cd74dfeaef63f92f45f9cd322c337ae7ad70081e48a AS vault +FROM --platform=linux/amd64 docker.io/vaultwarden/web-vault@sha256:7baaa7d5349742548b6c44e557c1512157b22010cdf314ccc399ffdcacb5eb52 AS vault ########################## Cross Compile Docker Helper Scripts ########################## ## We use the linux/amd64 no matter which Build Platform, since these are all bash scripts diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index ab13a8e7..d370c7cf 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2907,12 +2907,11 @@ async fn recover_account( err!("Organization user must be confirmed for password reset functionality"); } - let fallback_2fa_email = - if req.reset_two_factor && CONFIG.mail_enabled() && CONFIG.email_2fa_auto_fallback() && user.verified() { - TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none() - } else { - false - }; + let fallback_2fa_email = req.reset_two_factor + && CONFIG.mail_enabled() + && CONFIG.email_2fa_auto_fallback() + && user.verified() + && TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::Email, &conn).await.is_none(); // Check the new password before the email below, so that a rejected request doesn't tell the user // their password was reset diff --git a/src/sso_client.rs b/src/sso_client.rs index a66d75f6..246e5678 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -260,7 +260,7 @@ impl Client { }; if CONFIG.sso_debug_tokens() { - debug!("Id token: {}", id_token.to_string()); + debug!("Id token: {id_token}"); debug!("Access token: {}", token_response.access_token().secret()); debug!("Refresh token: {:?}", token_response.refresh_token().map(RefreshToken::secret)); debug!("Expiration time: {:?}", token_response.expires_in()); From 6ed6921cc2b0e03b4d74189574d2a5e71c512e97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sun, 1 Nov 2026 22:38:18 +0100 Subject: [PATCH 39/43] Escape request values in log messages (#7921) --- src/api/core/accounts.rs | 8 ++++++-- src/api/core/ciphers.rs | 6 +++--- src/api/core/organizations.rs | 33 +++++++++++++++++++++------------ src/api/icons.rs | 2 +- src/api/identity.rs | 2 +- src/api/web.rs | 2 +- src/auth/send.rs | 6 +++++- src/config.rs | 2 +- src/sso.rs | 4 ++-- 9 files changed, 41 insertions(+), 24 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index cd280bc7..69918212 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -1148,7 +1148,11 @@ async fn post_email_token(data: Json, headers: Headers, conn: Db error!("Error sending change-email email: {e:#?}"); } } else { - debug!("Email change request for user ({}) to email ({}) with token ({token})", user.uuid, data.new_email); + debug!( + "Email change request for user ({}) to email ({}) with token ({token})", + user.uuid, + data.new_email.escape_debug() + ); } user.email_new = Some(data.new_email); @@ -1403,7 +1407,7 @@ async fn password_hint(data: Json, ip: ClientIp, conn: DbConn) mail::send_password_hint(email, hint).await?; Ok(()) } else if let Some(hint) = hint { - err!(format!("Your password hint is: {hint}")); + err_silent!(format!("Your password hint is: {hint}")); } else { err!(NO_HINT); } diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 92499b9f..97f5fd49 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -458,7 +458,7 @@ pub async fn update_cipher_from_data( { match NaiveDateTime::parse_from_str(&dt, "%+") { // ISO 8601 format - Err(err) => warn!("Error parsing LastKnownRevisionDate '{dt}': {err}"), + Err(err) => warn!("Error parsing LastKnownRevisionDate '{}': {err}", dt.escape_debug()), Ok(dt) if cipher.updated_at.signed_duration_since(dt).num_seconds() > 1 => { err!("The client copy of this cipher is out of date. Resync the client and try again.") } @@ -521,7 +521,7 @@ pub async fn update_cipher_from_data( // A missing attachment means it was removed via an other client. // Also the Desktop Client supports removing attachments and save an update afterwards. // Bitwarden it self ignores these mismatches server side. - warn!("Attachment {id} doesn't exist"); + warn!("Attachment {} doesn't exist", id.escape_debug()); continue; }; @@ -580,7 +580,7 @@ pub async fn update_cipher_from_data( match data.archived_date { Some(dt_str) => match NaiveDateTime::parse_from_str(&dt_str, "%+") { Ok(dt) => cipher.set_archived_at(dt, &headers.user.uuid, conn).await?, - Err(err) => warn!("Error parsing ArchivedDate '{dt_str}': {err}"), + Err(err) => warn!("Error parsing ArchivedDate '{}': {err}", dt_str.escape_debug()), }, None => cipher.unarchive(&headers.user.uuid, conn).await?, } diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index d370c7cf..0182b820 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -131,13 +131,16 @@ impl FullCollectionData { let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id, org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", e.id.escape_debug(), org_id)) } let org_memberships = Membership::find_by_org(org_id, conn).await; let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(&m.id)) { - err!("Invalid member", format!("Member {} does not belong to organization {}!", e.id, org_id)) + err!( + "Invalid member", + format!("Member {} does not belong to organization {}!", e.id.escape_debug(), org_id) + ) } Ok(()) @@ -552,7 +555,7 @@ async fn post_bulk_access_collections( let org_groups = Group::find_by_organization(&org_id, &conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|g| &g.uuid).collect(); if let Some(g) = data.groups.iter().find(|g| !org_group_ids.contains(&g.id)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id, org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", g.id.escape_debug(), org_id)) } for col_id in data.collection_ids { @@ -975,13 +978,16 @@ impl InviteData { let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().flatten().find(|c| !org_collection_ids.contains(&c.id)) { - err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) + err!( + "Invalid collection", + format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id) + ) } let org_groups = Group::find_by_organization(org_id, conn).await; let org_group_ids: HashSet<&GroupId> = org_groups.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.groups.iter().find(|g| !org_group_ids.contains(g)) { - err!("Invalid group", format!("Group {} does not belong to organization {}!", e, org_id)) + err!("Invalid group", format!("Group {} does not belong to organization {}!", e.escape_debug(), org_id)) } Ok(()) @@ -1157,7 +1163,7 @@ async fn bulk_reinvite_members( let err_msg = match reinvite_member_impl(&org_id, &member_id, &headers.user.email, &conn).await { Ok(()) => String::new(), Err(e) => { - warn!("Unable to reinvite member {member_id}: {e:?}"); + warn!("Unable to reinvite member {}: {e:?}", member_id.escape_debug()); e.message().to_owned() } }; @@ -1333,7 +1339,7 @@ async fn bulk_confirm_invite( let err_msg = match confirm_invite_impl(&org_id, &member_id, &user_key, &headers, &conn, &nt).await { Ok(()) => String::new(), Err(e) => { - warn!("Unable to confirm member {member_id}: {e:?}"); + warn!("Unable to confirm member {}: {e:?}", member_id.escape_debug()); e.message().to_owned() } }; @@ -1617,7 +1623,7 @@ async fn bulk_delete_member( let err_msg = match delete_member_impl(&org_id, &member_id, &headers, &conn, &nt).await { Ok(()) => String::new(), Err(e) => { - warn!("Unable to delete member {member_id}: {e:?}"); + warn!("Unable to delete member {}: {e:?}", member_id.escape_debug()); e.message().to_owned() } }; @@ -2286,7 +2292,7 @@ async fn bulk_revoke_members( let err_msg = match revoke_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), Err(e) => { - warn!("Unable to revoke member {member_id}: {e:?}"); + warn!("Unable to revoke member {}: {e:?}", member_id.escape_debug()); e.message().to_owned() } }; @@ -2392,7 +2398,7 @@ async fn bulk_restore_members( let err_msg = match restore_member_impl(&org_id, &member_id, &headers, &conn).await { Ok(()) => String::new(), Err(e) => { - warn!("Unable to restore member {member_id}: {e:?}"); + warn!("Unable to restore member {}: {e:?}", member_id.escape_debug()); e.message().to_owned() } }; @@ -2547,13 +2553,16 @@ impl GroupRequest { let org_collections = Collection::find_by_organization(org_id, conn).await; let org_collection_ids: HashSet<&CollectionId> = org_collections.iter().map(|c| &c.uuid).collect(); if let Some(e) = self.collections.iter().find(|c| !org_collection_ids.contains(&c.id)) { - err!("Invalid collection", format!("Collection {} does not belong to organization {}!", e.id, org_id)) + err!( + "Invalid collection", + format!("Collection {} does not belong to organization {}!", e.id.escape_debug(), org_id) + ) } let org_memberships = Membership::find_by_org(org_id, conn).await; let org_membership_ids: HashSet<&MembershipId> = org_memberships.iter().map(|m| &m.uuid).collect(); if let Some(e) = self.users.iter().find(|m| !org_membership_ids.contains(m)) { - err!("Invalid member", format!("Member {} does not belong to organization {}!", e, org_id)) + err!("Invalid member", format!("Member {} does not belong to organization {}!", e.escape_debug(), org_id)) } Ok(()) diff --git a/src/api/icons.rs b/src/api/icons.rs index ce801422..1c7b2a57 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -264,7 +264,7 @@ fn get_favicons_node(dom: Tokenizer, FaviconEmitter>, icons: &m TAG_BASE => { base_url = if let Some(href) = token.tag.attributes.get(ATTR_HREF) { let href = std::str::from_utf8(href).unwrap_or_default(); - debug!("Found base href: {href}"); + debug!("Found base href: {}", href.escape_debug()); match base_url.join(href) { Ok(inner_url) => inner_url, _ => continue, diff --git a/src/api/identity.rs b/src/api/identity.rs index 146cec4c..4608ed77 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -112,7 +112,7 @@ async fn login(data: Form, client_header: ClientHeaders, conn: DbCo .await?; Ok(Json(tokens.to_json())) } - t => err!("Invalid type", t), + t => err!("Invalid type", t.escape_debug().to_string()), }; if let Some(user_id) = user_id { diff --git a/src/api/web.rs b/src/api/web.rs index e9a8b593..5af97d66 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -332,6 +332,6 @@ pub fn static_files(filename: &str) -> Result, Error> if let Some(file) = STATIC_FILES.get(filename) { Ok(EtagCached::new(file.bytes, file.content_type.clone(), &file.etag)) } else { - err!(format!("Static file not found: {filename}")) + err!(format!("Static file not found: {}", filename.escape_debug())) } } diff --git a/src/auth/send.rs b/src/auth/send.rs index 518b7098..85b91d99 100644 --- a/src/auth/send.rs +++ b/src/auth/send.rs @@ -72,7 +72,11 @@ impl SendTokens { conn: &DbConn, ) -> ApiResult { let Some(send_id) = Self::as_send_id(access_id) else { - return Self::invalid_error(&format!("Can't convert {access_id}"), "send_id_invalid", false); + return Self::invalid_error( + &format!("Can't convert {}", access_id.escape_debug()), + "send_id_invalid", + false, + ); }; let Some(send) = Send::find_by_uuid(&send_id, conn).await else { diff --git a/src/config.rs b/src/config.rs index 55d08259..8bebc4d4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1616,7 +1616,7 @@ impl Config { pub fn is_email_domain_allowed(&self, email: &str) -> bool { let e: Vec<&str> = email.rsplitn(2, '@').collect(); if e.len() != 2 || e[0].is_empty() || e[1].is_empty() { - warn!("Failed to parse email address '{email}'"); + warn!("Failed to parse email address '{}'", email.escape_debug()); return false; } let email_domain = e[0].to_lowercase(); diff --git a/src/sso.rs b/src/sso.rs index cc3b51f1..baa17492 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -266,8 +266,8 @@ pub async fn exchange_code( (_, Some(re)) => { let error_msg = format!( "SSO authorization failed: {}, {}", - re.error, - re.error_description.as_ref().unwrap_or(&String::new()) + re.error.escape_debug(), + re.error_description.as_deref().unwrap_or_default().escape_debug() ); sso_auth.delete(conn).await?; err!(error_msg); From 286499781ad7c8e2a3c173ced6f4e0a1fa28b8ad Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:28:51 +0100 Subject: [PATCH 40/43] Add Fill Assist feature flag in .env.template (#7928) --- .env.template | 1 + 1 file changed, 1 insertion(+) diff --git a/.env.template b/.env.template index 92362088..bd7fe084 100644 --- a/.env.template +++ b/.env.template @@ -396,6 +396,7 @@ ## Note that clients cache the /api/config endpoint for about 1 hour and it could take some time before they are enabled or disabled! ## ## The following flags are available: +## - "fill-assist-targeting-rules": Enable Fill Assist targeting rules in the browser extension. (Browser >= 2026.6.0) ## - "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-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) From 82fc47e83e39c5d09e85a6f259c8ab51166735d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Tue, 3 Nov 2026 00:12:56 +0100 Subject: [PATCH 41/43] Redact query values in request logs (#7929) --- src/util.rs | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/util.rs b/src/util.rs index 44d532bf..369e255b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -302,6 +302,20 @@ impl<'r, R: 'r + Responder<'r, 'static> + Send> Responder<'r, 'static> for EtagC // Effectively ignores, any static file route, and the alive endpoint const LOGGED_ROUTES: [&str; 7] = ["/api", "/admin", "/identity", "/icons", "/attachments", "/events", "/notifications"]; +const MAX_LOGGED_QUERY_BYTES: usize = 100; + +// Query values can hold secrets, like the access code of a login request, so we don't log them +fn redact_query_values(query: &str) -> String { + query + .split('&') + .map(|param| match param.split_once('=') { + Some((name, _)) => format!("{name}=…"), + None => param.to_owned(), + }) + .collect::>() + .join("&") +} + // When extra is true, we ignore the whitelist above and also print the mounts pub struct BetterLogging { extra: bool, @@ -381,8 +395,9 @@ impl Fairing for BetterLogging { }; match uri.query() { Some(q) => { - let q = q.as_str(); - info!(target: "request", "{method} {}?{}", path.escape_debug(), q[..q.floor_char_boundary(30)].escape_debug()); + let query = redact_query_values(q.as_str()); + let query = &query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)]; + info!(target: "request", "{method} {}?{}", path.escape_debug(), query.escape_debug()); } None => info!(target: "request", "{method} {}", path.escape_debug()), } @@ -1027,3 +1042,28 @@ mod tests { }); } } + +#[cfg(test)] +mod query_log_tests { + use super::{MAX_LOGGED_QUERY_BYTES, redact_query_values}; + + #[test] + fn test_redact_query_values() { + assert_eq!(redact_query_values("code=SECRET"), "code=…"); + assert_eq!(redact_query_values("code=SECRET&other=SECRET"), "code=…&other=…"); + assert_eq!(redact_query_values("a=1&b=2&flag"), "a=…&b=…&flag"); + assert_eq!(redact_query_values("a=b=SECRET"), "a=…"); + assert_eq!(redact_query_values("a=1&&=SECRET"), "a=…&&=…"); + assert_eq!(redact_query_values("a=x%26b%3DSECRET"), "a=…"); + assert_eq!(redact_query_values("a=1;b=SECRET"), "a=…"); + assert_eq!(redact_query_values("a=1&a=SECRET"), "a=…&a=…"); + assert_eq!(redact_query_values(""), ""); + } + + #[test] + fn test_redacted_query_truncates_on_a_char_boundary() { + let name = "a".repeat(MAX_LOGGED_QUERY_BYTES - 1); + let query = redact_query_values(&format!("{name}=SECRET")); + assert_eq!(&query[..query.floor_char_boundary(MAX_LOGGED_QUERY_BYTES)], format!("{name}=")); + } +} From 904b80115d774fe3238408c19303720ef6def2b2 Mon Sep 17 00:00:00 2001 From: Tom <83423411+tom27052006@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:11:52 +0100 Subject: [PATCH 42/43] [PR 1] Add experimental Custom Roles foundation (#7847) --- .env.template | 9 + .../down.sql | 22 ++ .../up.sql | 13 + .../down.sql | 20 + .../up.sql | 13 + .../down.sql | 19 + .../up.sql | 11 + src/api/admin.rs | 38 +- src/api/core/organizations.rs | 56 ++- src/api/web.rs | 3 +- src/config.rs | 4 + src/db/mod.rs | 71 ++++ src/db/models/cipher.rs | 12 +- src/db/models/collection.rs | 14 +- src/db/models/mod.rs | 4 +- src/db/models/organization.rs | 343 ++++++++++++++++-- src/db/schema.rs | 9 + src/static/scripts/admin_users.js | 16 +- src/static/templates/admin/users.hbs | 5 + .../templates/scss/vaultwarden.scss.hbs | 7 + 20 files changed, 621 insertions(+), 68 deletions(-) create mode 100644 migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql create mode 100644 migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql create mode 100644 migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql diff --git a/.env.template b/.env.template index bd7fe084..2d5c5d8e 100644 --- a/.env.template +++ b/.env.template @@ -497,6 +497,15 @@ ## KNOW WHAT YOU ARE DOING! # ORG_GROUPS_ENABLED=false +## EXPERIMENTAL FEATURE: Custom roles +## Enables the Custom member role with granular permissions for organizations. +## This is still being developed: only the permissions this version enforces can be granted. +## This is enforced by the server and is unrelated to EXPERIMENTAL_CLIENT_FEATURE_FLAGS. +## Disabling it again keeps the stored Custom roles and permissions, but they grant nothing until it is re-enabled. +## Disabled by default because this is an experimental feature! +## KNOW WHAT YOU ARE DOING! +# EXPERIMENTAL_CUSTOM_ROLES=false + ## Increase secure note size limit (Know the risks!) ## Sets the secure note size limit to 100_000 instead of the default 10_000. ## WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..85ebcbfb --- /dev/null +++ b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,22 @@ +-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the +-- versions before this one don't know. So while there are any, this fails on the duplicate key below, before anything +-- is dropped: change their role first, or restore the backup taken before the upgrade. +-- A failed attempt leaves the temporary table on this connection. Clear it before retrying. +DROP TEMPORARY TABLE IF EXISTS custom_role_downgrade; +CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); +INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); +INSERT INTO custom_role_downgrade + SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' + FROM users_organizations WHERE atype = 4 LIMIT 1; +DROP TEMPORARY TABLE custom_role_downgrade; + +ALTER TABLE users_organizations + DROP COLUMN manage_users, + DROP COLUMN manage_groups, + DROP COLUMN manage_policies, + DROP COLUMN create_new_collections, + DROP COLUMN edit_any_collection, + DROP COLUMN delete_any_collection, + DROP COLUMN access_event_logs, + DROP COLUMN access_import_export, + DROP COLUMN access_reports; diff --git a/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..d28b0d64 --- /dev/null +++ b/migrations/mysql/2026-10-10-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,13 @@ +-- The granular permissions of the Custom member role. They only take effect on a Custom membership +-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. +-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..0cc5dd03 --- /dev/null +++ b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,20 @@ +-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the +-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their +-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. +CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); +INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); +INSERT INTO custom_role_downgrade + SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' + FROM users_organizations WHERE atype = 4 LIMIT 1; +DROP TABLE custom_role_downgrade; + +ALTER TABLE users_organizations + DROP COLUMN manage_users, + DROP COLUMN manage_groups, + DROP COLUMN manage_policies, + DROP COLUMN create_new_collections, + DROP COLUMN edit_any_collection, + DROP COLUMN delete_any_collection, + DROP COLUMN access_event_logs, + DROP COLUMN access_import_export, + DROP COLUMN access_reports; diff --git a/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..d28b0d64 --- /dev/null +++ b/migrations/postgresql/2026-10-10-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,13 @@ +-- The granular permissions of the Custom member role. They only take effect on a Custom membership +-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. +-- A single statement, so an interrupted upgrade cannot leave only some of the columns behind. +ALTER TABLE users_organizations + ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql new file mode 100644 index 00000000..11c30bec --- /dev/null +++ b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/down.sql @@ -0,0 +1,19 @@ +-- Dropping the permissions would silently take them away from the Custom members, who would also keep a role (4) the +-- versions before this one don't know. So while there are any, this fails on the duplicate key below: change their +-- role first, or restore the backup taken before the upgrade. It needs to run in one transaction, as Diesel does. +CREATE TEMPORARY TABLE custom_role_downgrade (blocked_by VARCHAR(255) NOT NULL PRIMARY KEY); +INSERT INTO custom_role_downgrade VALUES ('Custom members: change their role first, or restore the backup taken before the upgrade'); +INSERT INTO custom_role_downgrade + SELECT 'Custom members: change their role first, or restore the backup taken before the upgrade' + FROM users_organizations WHERE atype = 4 LIMIT 1; +DROP TABLE custom_role_downgrade; + +ALTER TABLE users_organizations DROP COLUMN manage_users; +ALTER TABLE users_organizations DROP COLUMN manage_groups; +ALTER TABLE users_organizations DROP COLUMN manage_policies; +ALTER TABLE users_organizations DROP COLUMN create_new_collections; +ALTER TABLE users_organizations DROP COLUMN edit_any_collection; +ALTER TABLE users_organizations DROP COLUMN delete_any_collection; +ALTER TABLE users_organizations DROP COLUMN access_event_logs; +ALTER TABLE users_organizations DROP COLUMN access_import_export; +ALTER TABLE users_organizations DROP COLUMN access_reports; diff --git a/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql new file mode 100644 index 00000000..c708c693 --- /dev/null +++ b/migrations/sqlite/2026-10-10-120000_add_custom_role_permissions/up.sql @@ -0,0 +1,11 @@ +-- The granular permissions of the Custom member role. They only take effect on a Custom membership +-- (atype 4) while EXPERIMENTAL_CUSTOM_ROLES is enabled, and every existing membership starts without any. +ALTER TABLE users_organizations ADD COLUMN manage_users BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN manage_groups BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN manage_policies BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN create_new_collections BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN edit_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN delete_any_collection BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN access_event_logs BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN access_import_export BOOLEAN NOT NULL DEFAULT 0; -- FALSE +ALTER TABLE users_organizations ADD COLUMN access_reports BOOLEAN NOT NULL DEFAULT 0; -- FALSE diff --git a/src/api/admin.rs b/src/api/admin.rs index 1a2b8c9a..5e51306c 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,4 +1,4 @@ -use std::{env, sync::LazyLock}; +use std::{collections::HashMap, env, sync::LazyLock}; use reqwest::Method; use rocket::{ @@ -26,6 +26,7 @@ use crate::{ models::{ Attachment, Cipher, Collection, Device, Event, EventType, Group, Invitation, Membership, MembershipId, MembershipType, OrgPolicy, Organization, OrganizationId, SsoUser, TwoFactor, User, UserId, + custom_roles_enabled, }, }, error::{Error, MapResult}, @@ -256,6 +257,7 @@ struct AdminTemplateData { logged_in: bool, urlpath: String, sso_enabled: bool, + custom_roles_enabled: bool, } impl AdminTemplateData { @@ -266,6 +268,7 @@ impl AdminTemplateData { logged_in: true, urlpath: CONFIG.domain_path(), sso_enabled: CONFIG.sso_enabled(), + custom_roles_enabled: custom_roles_enabled(), } } @@ -390,6 +393,18 @@ async fn users_overview(_token: AdminToken, conn: DbConn) -> ApiResult = Membership::find_confirmed_by_user(&u.uuid, &conn) + .await + .into_iter() + .map(|member| (member.org_uuid.to_string(), member.atype)) + .collect(); + for org in usr["organizations"].as_array_mut().into_iter().flatten() { + if let Some(atype) = org["id"].as_str().and_then(|org_id| roles.get(org_id)) { + org["type"] = json!(atype); + } + } + users_json.push(usr); } @@ -565,10 +580,14 @@ async fn update_membership_type(data: Json, token: AdminToke err!("The specified user isn't member of the organization") }; - let new_type = if let Some(new_type) = MembershipType::from_str(&data.user_type.into_string()) { - new_type as i32 - } else { - err!("Invalid type") + let new_type = match MembershipType::from_str(&data.user_type.into_string()) { + // Unlike the clients, the admin panel tells the Custom and the legacy Manager role apart. Unless Custom roles + // are enabled, a Custom role is a Manager here as well, but an existing Custom member is kept as one. + Some(MembershipType::Custom) if !custom_roles_enabled() && !member_to_edit.has_type(MembershipType::Custom) => { + MembershipType::Manager + } + Some(new_type) => new_type, + None => err!("Invalid type"), }; if member_to_edit.atype == MembershipType::Owner && new_type != MembershipType::Owner { @@ -578,7 +597,14 @@ async fn update_membership_type(data: Json, token: AdminToke } } - member_to_edit.atype = new_type; + // A Manager only becomes a Custom member if that changes nothing they may do + if new_type == MembershipType::Custom + && let Some(loss) = member_to_edit.custom_role_conversion_loss(&conn).await + { + err!(loss) + } + + member_to_edit.set_type(new_type); // This check is also done at api::organizations::{accept_invite, _confirm_invite, _activate_member, edit_member}, update_membership_type OrgPolicy::check_user_allowed(&member_to_edit, "modify", &conn).await?; diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 0182b820..607db45b 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -15,10 +15,10 @@ use crate::{ db::{ DbConn, models::{ - Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, Device, - EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, Membership, MembershipId, - MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, OrganizationApiKey, - OrganizationId, TwoFactor, TwoFactorType, User, UserId, + Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, CollectionId, CollectionUser, + CustomPermission, Device, EventType, FillAssistPolicyData, Group, GroupId, GroupUser, Invitation, + Membership, MembershipId, MembershipStatus, MembershipType, OrgPolicy, OrgPolicyType, Organization, + OrganizationApiKey, OrganizationId, TwoFactor, TwoFactorType, User, UserId, custom_roles_enabled, }, }, mail, @@ -1008,14 +1008,12 @@ async fn send_invite( data.validate(&org_id, &conn).await?; // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // The from_str() will convert the custom role type into a manager role type + // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3) let raw_type = &data.r#type.into_string(); - // Membership::from_str will convert custom (4) to manager (3) - let new_type = if let Some(new_type) = MembershipType::from_str(raw_type) { - new_type as i32 - } else { + let Some(requested_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; + let new_type = MembershipType::requested(requested_type, None, custom_roles_enabled()); if new_type != MembershipType::User && headers.membership_type != MembershipType::Owner { err!("Only Owners can invite Managers, Admins or Owners") @@ -1025,11 +1023,19 @@ async fn send_invite( // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") + || (new_type == MembershipType::Manager + && raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); + // Checked before anything is created, so a rejected permission leaves nothing behind + let custom_permissions = if new_type == MembershipType::Custom { + Some(CustomPermission::requested(&data.permissions)?) + } else { + None + }; + let mut user_created: bool; for email in &data.emails { let mut member_status = MembershipStatus::Invited as i32; @@ -1071,8 +1077,11 @@ async fn send_invite( }; let mut new_member = Membership::new(user.uuid.clone(), org_id.clone(), Some(headers.user.email.clone())); + new_member.set_type(new_type); new_member.access_all = access_all; - new_member.atype = new_type; + if let Some(permissions) = &custom_permissions { + new_member.set_custom_permissions(permissions); + } new_member.status = member_status; new_member.save(&conn).await?; @@ -1508,24 +1517,32 @@ async fn edit_member( let data: EditUserData = data.into_inner(); // HACK: We need the raw user-type to be sure custom role is selected to determine the access_all permission - // The from_str() will convert the custom role type into a manager role type + // Unless Custom roles are enabled, a custom role (4) is converted to a manager role (3) let raw_type = &data.r#type.into_string(); - // MembershipType::from_str will convert custom (4) to manager (3) - let Some(new_type) = MembershipType::from_str(raw_type) else { + let Some(requested_type) = MembershipType::from_str(raw_type) else { err!("Invalid type") }; + let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { + err!("The specified user isn't member of the organization") + }; + let new_type = MembershipType::requested(requested_type, Some(&member_to_edit), custom_roles_enabled()); + // HACK: This converts the Custom role which has the `Manage all collections` box checked into an access_all flag // Since the parent checkbox is not sent to the server we need to check and verify the child checkboxes // If the box is not checked, the user will still be a manager, but not with the access_all permission let access_all = new_type >= MembershipType::Admin - || (raw_type.eq("4") + || (new_type == MembershipType::Manager + && raw_type.eq("4") && data.permissions.get("editAnyCollection") == Some(&json!(true)) && data.permissions.get("deleteAnyCollection") == Some(&json!(true)) && data.permissions.get("createNewCollections") == Some(&json!(true))); - let Some(mut member_to_edit) = Membership::find_by_uuid_and_org(&member_id, &org_id, &conn).await else { - err!("The specified user isn't member of the organization") + // While Custom roles are disabled, a Custom member keeps their stored permissions untouched + let custom_permissions = if new_type == MembershipType::Custom && custom_roles_enabled() { + Some(CustomPermission::requested(&data.permissions)?) + } else { + None }; if new_type != member_to_edit.atype @@ -1549,8 +1566,11 @@ async fn edit_member( } } + member_to_edit.set_type(new_type); member_to_edit.access_all = access_all; - member_to_edit.atype = new_type as i32; + if let Some(permissions) = &custom_permissions { + member_to_edit.set_custom_permissions(permissions); + } // This check is also done at accept_invite, _confirm_invite, _activate_member, edit_member, admin::update_membership_type // We need to perform the check after changing the type since `admin` is exempt. diff --git a/src/api/web.rs b/src/api/web.rs index 5af97d66..2818b5b7 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -20,7 +20,7 @@ use crate::{ crypto::sha256_hex, db::{ DbConn, - models::{AttachmentId, CipherId}, + models::{AttachmentId, CipherId, custom_roles_enabled}, }, error::Error, util::{Cached, EtagCached}, @@ -90,6 +90,7 @@ fn vaultwarden_css() -> EtagCached { // Else, there is either no cache, or reload_templates is true and we need to rebuild the CSS let css_options = json!({ + "custom_roles_enabled": custom_roles_enabled(), "email_change_allowed": CONFIG.email_change_allowed(), "emergency_access_allowed": CONFIG.emergency_access_allowed(), "load_user_scss": true, diff --git a/src/config.rs b/src/config.rs index 8bebc4d4..1d75d342 100644 --- a/src/config.rs +++ b/src/config.rs @@ -817,6 +817,10 @@ make_config! { /// Enable groups (BETA!) (Know the risks!) |> Enables groups support for organizations (Currently contains known issues!). org_groups_enabled: bool, false, def, false; + /// Enable Custom roles (EXPERIMENTAL!) (Know the risks!) |> Enables the in-development Custom member role with granular permissions for organizations. + /// Only the permissions this version enforces can be granted. Disabling it again keeps the stored Custom roles, but they grant nothing until it is re-enabled. + experimental_custom_roles: bool, false, def, false; + /// Increase note size limit (Know the risks!) |> Sets the secure note size limit to 100_000 instead of the default 10_000. /// WARNING: This could cause issues with clients. Also exports will not work on Bitwarden servers! increase_note_size_limit: bool, true, def, false; diff --git a/src/db/mod.rs b/src/db/mod.rs index 2ccd7d48..62b40965 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -534,3 +534,74 @@ mod postgresql_migrations { Ok(()) } } + +/// A throwaway SQLite database with the real schema, for tests that need to run queries. +#[cfg(all(test, sqlite))] +pub mod test_db { + use super::sqlite_migrations::MIGRATIONS; + use diesel::connection::SimpleConnection; + use diesel_migrations::MigrationHarness; + + #[test] + fn custom_role_migration_preserves_memberships_and_guards_downgrade() { + use super::schema::users_organizations::dsl::*; + use diesel::{migration::MigrationSource, prelude::*}; + let mut conn = SqliteConnection::establish(":memory:").unwrap(); + for migration in conn.pending_migrations(MIGRATIONS).unwrap() { + if migration.name().version().to_string() == "20261010120000" { + break; + } + conn.run_migration(&migration).unwrap(); + } + conn.batch_execute( + "PRAGMA foreign_keys=OFF; + INSERT INTO organizations (uuid,name,billing_email) VALUES ('org','Org','a@b'); + INSERT INTO users_organizations (uuid,user_uuid,org_uuid,access_all,akey,status,atype) VALUES + ('0','0','org',TRUE,'key',2,0), ('1','1','org',TRUE,'key',2,1), + ('2','2','org',FALSE,'key',1,2), ('3','3','org',FALSE,'key',2,3), ('4','4','org',TRUE,'key',2,3);", + ) + .unwrap(); + let rows = |conn: &mut SqliteConnection| { + users_organizations + .select((uuid, user_uuid, org_uuid, access_all, akey, status, atype)) + .order(uuid) + .load::<(String, String, String, bool, String, i32, i32)>(conn) + .unwrap() + }; + let before = rows(&mut conn); + conn.run_pending_migrations(MIGRATIONS).unwrap(); + assert_eq!(rows(&mut conn), before); + let grants = |conn: &mut SqliteConnection| { + diesel::select(diesel::dsl::sql::( + "(SELECT COUNT(*) FROM users_organizations WHERE manage_users OR manage_groups OR manage_policies + OR create_new_collections OR edit_any_collection OR delete_any_collection + OR access_event_logs OR access_import_export OR access_reports)", + )) + .get_result::(conn) + .unwrap() + }; + assert_eq!(grants(&mut conn), 0); + let migration = MigrationSource::::migrations(&MIGRATIONS) + .unwrap() + .into_iter() + .find(|m| m.name().version().to_string() == "20261010120000") + .unwrap(); + for member_status in [1, 2] { + conn.batch_execute(&format!( + "UPDATE users_organizations SET atype=4,manage_users=TRUE,status={member_status} WHERE uuid='2'" + )) + .unwrap(); + let custom = rows(&mut conn); + assert!(conn.revert_migration(&*migration).is_err()); + assert_eq!(rows(&mut conn), custom); + assert!(users_organizations.filter(uuid.eq("2")).select(manage_users).first::(&mut conn).unwrap()); + } + conn.batch_execute("UPDATE users_organizations SET atype=2,manage_users=FALSE,status=1 WHERE uuid='2'") + .unwrap(); + conn.revert_migration(&*migration).unwrap(); + assert!(diesel::sql_query("SELECT manage_users FROM users_organizations").execute(&mut conn).is_err()); + conn.run_pending_migrations(MIGRATIONS).unwrap(); + assert_eq!(grants(&mut conn), 0); + assert_eq!(rows(&mut conn), before); + } +} diff --git a/src/db/models/cipher.rs b/src/db/models/cipher.rs index 630146e4..db8e9bf4 100644 --- a/src/db/models/cipher.rs +++ b/src/db/models/cipher.rs @@ -25,7 +25,7 @@ use macros::UuidFromParam; use super::{ Archive, Attachment, CollectionCipher, CollectionId, Favorite, FolderCipher, FolderId, Group, Membership, - MembershipStatus, MembershipType, OrganizationId, User, UserId, + MembershipStatus, OrganizationId, User, UserId, organization::ORG_ADMIN_ATYPES, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -872,7 +872,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner ); } @@ -907,7 +907,7 @@ impl Cipher { if !visible_only { query = query.or_filter( - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin/owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin/owner ); } @@ -1111,7 +1111,7 @@ impl Cipher { .or(collections_groups::collections_uuid .is_not_null() // Access via groups .and(collections_groups::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1140,7 +1140,7 @@ impl Cipher { .or(users_collections::user_uuid .eq(user_uuid) // User has access to collection .and(users_collections::read_only.eq(false))) - .or(users_organizations::atype.le(MembershipType::Admin as i32)), // User is admin or owner + .or(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)), // User is admin or owner ) .select(ciphers_collections::collection_uuid) .load::(conn) @@ -1182,7 +1182,7 @@ impl Cipher { ) .or_filter(users_collections::user_uuid.eq(user_uuid)) // User has access to collection .or_filter(users_organizations::access_all.eq(true)) // User has access all - .or_filter(users_organizations::atype.le(MembershipType::Admin as i32)) // User is admin or owner + .or_filter(users_organizations::atype.eq_any(ORG_ADMIN_ATYPES)) // User is admin or owner .or_filter(groups::access_all.eq(true)) //Access via group .or_filter(collections_groups::collections_uuid.is_not_null()) //Access via group .filter(users_organizations::status.eq(MembershipStatus::Confirmed as i32)) diff --git a/src/db/models/collection.rs b/src/db/models/collection.rs index 21387e4d..68c48ce9 100644 --- a/src/db/models/collection.rs +++ b/src/db/models/collection.rs @@ -18,7 +18,7 @@ use macros::UuidFromParam; use super::{ CipherId, CollectionGroup, GroupUser, Membership, MembershipId, MembershipStatus, MembershipType, OrganizationId, - User, UserId, + User, UserId, organization::ORG_ADMIN_ATYPES, }; // See (v2026.7.0): https://github.com/bitwarden/server/blob/5d4461aa42cadbacfef8fe2166c5453a5c52773a/src/Core/AdminConsole/Entities/Collection.cs @@ -375,7 +375,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( @@ -412,7 +412,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), )) .select(collections::all_columns) @@ -455,7 +455,7 @@ impl Collection { ) .filter( users_organizations::atype - .le(MembershipType::Admin as i32) // Org admin or owner + .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -489,7 +489,7 @@ impl Collection { ) .filter( users_organizations::atype - .le(MembershipType::Admin as i32) // Org admin or owner + .eq_any(ORG_ADMIN_ATYPES) // Org admin or owner .or(users_organizations::access_all.eq(true)) // access_all via membership .or(users_collections::collection_uuid .eq(&self.uuid) // write access given to collection @@ -540,7 +540,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( @@ -600,7 +600,7 @@ impl Collection { // Directly accessed collection users_organizations::access_all.eq(true).or( // access_all in Organization - users_organizations::atype.le(MembershipType::Admin as i32), // Org admin or owner + users_organizations::atype.eq_any(ORG_ADMIN_ATYPES), // Org admin or owner ), ) .or( diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index fda76df4..8f62bf0b 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -33,8 +33,8 @@ pub use self::folder::{Folder, FolderCipher, FolderId}; pub use self::group::{CollectionGroup, Group, GroupId, GroupUser}; pub use self::org_policy::{FillAssistPolicyData, OrgPolicy, OrgPolicyId, OrgPolicyType}; pub use self::organization::{ - Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, OrganizationApiKey, - OrganizationId, + CustomPermission, Membership, MembershipId, MembershipStatus, MembershipType, OrgApiKeyId, Organization, + OrganizationApiKey, OrganizationId, custom_roles_enabled, }; pub use self::send::{Send, SendFileId, SendId, SendType}; pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth}; diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 25d03dfc..b7fcdfdf 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -1,4 +1,7 @@ -use std::cmp::Ordering; +use std::{ + cmp::Ordering, + collections::{HashMap, HashSet}, +}; use chrono::{NaiveDateTime, Utc}; use derive_more::{AsRef, Deref, Display, From}; @@ -21,8 +24,8 @@ use crate::{ use macros::UuidFromParam; use super::{ - Cipher, CipherId, Collection, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, OrgPolicyType, - TwoFactor, User, UserId, + Cipher, CipherId, Collection, CollectionGroup, CollectionId, CollectionUser, Group, GroupId, GroupUser, OrgPolicy, + OrgPolicyType, TwoFactor, User, UserId, }; #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -41,6 +44,7 @@ pub struct Organization { #[diesel(table_name = users_organizations)] #[diesel(treat_none_as_null = true)] #[diesel(primary_key(uuid))] +#[expect(clippy::struct_excessive_bools, reason = "Each Custom role permission is a column of its own")] pub struct Membership { pub uuid: MembershipId, pub user_uuid: UserId, @@ -54,6 +58,17 @@ pub struct Membership { pub atype: i32, pub reset_password_key: Option, pub external_id: Option, + + // The permissions of a Custom role, only accessed through `CustomPermission`. + manage_users: bool, + manage_groups: bool, + manage_policies: bool, + create_new_collections: bool, + edit_any_collection: bool, + delete_any_collection: bool, + access_event_logs: bool, + access_import_export: bool, + access_reports: bool, } #[derive(Identifiable, Queryable, Insertable, AsChangeset)] @@ -89,42 +104,68 @@ impl MembershipStatus { } } -#[derive(Copy, Clone, PartialEq, Eq, num_derive::FromPrimitive)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, num_derive::FromPrimitive)] pub enum MembershipType { Owner = 0, Admin = 1, User = 2, + // The legacy Manager role. The Bitwarden clients no longer know it and are shown a Custom role instead. Manager = 3, + // Only granted while EXPERIMENTAL_CUSTOM_ROLES is enabled. + Custom = 4, } impl MembershipType { pub fn from_str(s: &str) -> Option { - #[expect( - clippy::match_same_arms, - reason = "Specifically define `4|Custom` since this is a hack, not a default" - )] match s { "0" | "Owner" => Some(MembershipType::Owner), "1" | "Admin" => Some(MembershipType::Admin), "2" | "User" => Some(MembershipType::User), "3" | "Manager" => Some(MembershipType::Manager), - // HACK: We convert the custom role to a manager role - "4" | "Custom" => Some(MembershipType::Manager), + "4" | "Custom" => Some(MembershipType::Custom), _ => None, } } + + /// The role a member invite or edit from the Bitwarden clients asks for, given the member's current + /// role (`None` for an invite). + /// + /// The clients no longer know the legacy Manager role and are shown a Custom role in its place, so a + /// requested Custom role is the legacy Manager unless EXPERIMENTAL_CUSTOM_ROLES is enabled. An existing + /// Manager or Custom member keeps their role when the request repeats it, so an unrelated edit never + /// turns one into the other. + pub fn requested( + requested: MembershipType, + current: Option<&Membership>, + custom_roles_enabled: bool, + ) -> MembershipType { + if requested != MembershipType::Custom { + return requested; + } + match current { + Some(member) if member.has_type(MembershipType::Custom) => MembershipType::Custom, + Some(member) if member.has_type(MembershipType::Manager) => MembershipType::Manager, + _ if custom_roles_enabled => MembershipType::Custom, + _ => MembershipType::Manager, + } + } + + /// The authority of a role, which is what roles are compared by. A Custom member gets its authority from + /// its permissions, never from its role, so the role itself ranks right above User and below Manager. + const fn rank(self) -> u8 { + match self { + MembershipType::User => 0, + MembershipType::Custom => 1, + MembershipType::Manager => 2, + MembershipType::Admin => 3, + MembershipType::Owner => 4, + } + } } impl Ord for MembershipType { fn cmp(&self, other: &MembershipType) -> Ordering { - // For easy comparison, map each variant to an access level (where 0 is lowest). - const ACCESS_LEVEL: [i32; 4] = [ - 3, // Owner - 2, // Admin - 0, // User - 1, // Manager && Custom - ]; - ACCESS_LEVEL[*self as usize].cmp(&ACCESS_LEVEL[*other as usize]) + self.rank().cmp(&other.rank()) } } @@ -180,6 +221,91 @@ impl PartialOrd for i32 { } } +/// The granular permissions of the Custom role. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CustomPermission { + ManageUsers, + ManageGroups, + ManagePolicies, + CreateNewCollections, + EditAnyCollection, + DeleteAnyCollection, + AccessEventLogs, + AccessImportExport, + AccessReports, +} + +/// The permissions this version enforces on every endpoint they open. Only these can be granted, a stored +/// grant of any other one grants nothing. They are implemented one at a time, so this grows with them. +const SUPPORTED_CUSTOM_PERMISSIONS: &[CustomPermission] = &[]; + +impl CustomPermission { + pub const ALL: [CustomPermission; 9] = [ + CustomPermission::ManageUsers, + CustomPermission::ManageGroups, + CustomPermission::ManagePolicies, + CustomPermission::CreateNewCollections, + CustomPermission::EditAnyCollection, + CustomPermission::DeleteAnyCollection, + CustomPermission::AccessEventLogs, + CustomPermission::AccessImportExport, + CustomPermission::AccessReports, + ]; + + /// The key of the permission in the `permissions` object of the Bitwarden clients. + pub const fn json_key(self) -> &'static str { + match self { + CustomPermission::ManageUsers => "manageUsers", + CustomPermission::ManageGroups => "manageGroups", + CustomPermission::ManagePolicies => "managePolicies", + CustomPermission::CreateNewCollections => "createNewCollections", + CustomPermission::EditAnyCollection => "editAnyCollection", + CustomPermission::DeleteAnyCollection => "deleteAnyCollection", + CustomPermission::AccessEventLogs => "accessEventLogs", + CustomPermission::AccessImportExport => "accessImportExport", + CustomPermission::AccessReports => "accessReports", + } + } + + pub fn is_supported(self) -> bool { + SUPPORTED_CUSTOM_PERMISSIONS.contains(&self) + } + + /// The permissions a member invite or edit grants a Custom role. + /// + /// A permission is only granted when it is set to `true`, and anything but a boolean is rejected: this + /// replaces the member's permissions, so a malformed value must not silently remove one. Granting a + /// permission this version does not enforce is rejected as well. The clients also send permissions + /// Vaultwarden does not know at all, like `manageSso`, those are ignored. + pub fn requested(permissions: &HashMap) -> Result, crate::Error> { + let mut granted = HashSet::new(); + for permission in CustomPermission::ALL { + match permissions.get(permission.json_key()) { + None | Some(Value::Bool(false)) => {} + Some(Value::Bool(true)) if permission.is_supported() => { + granted.insert(permission); + } + Some(Value::Bool(true)) => { + err!(format!("The Custom role permission '{}' is not supported yet", permission.json_key())) + } + Some(_) => { + err!(format!("The Custom role permission '{}' must be true or false", permission.json_key())) + } + } + } + Ok(granted) + } +} + +/// The stored roles with authority over the whole organization. Queries match them by value, comparing +/// `atype <= Admin` would also match any unknown value below it. +pub(super) const ORG_ADMIN_ATYPES: [i32; 2] = [MembershipType::Owner as i32, MembershipType::Admin as i32]; + +/// Whether the experimental Custom role is enabled. Nothing is ever granted through a Custom role otherwise. +pub fn custom_roles_enabled() -> bool { + CONFIG.experimental_custom_roles() +} + /// Local methods impl Organization { pub fn new(name: String, billing_email: &str, private_key: Option, public_key: Option) -> Self { @@ -271,9 +397,92 @@ impl Membership { atype: MembershipType::User as i32, reset_password_key: None, external_id: None, + + manage_users: false, + manage_groups: false, + manage_policies: false, + create_new_collections: false, + edit_any_collection: false, + delete_any_collection: false, + access_event_logs: false, + access_import_export: false, + access_reports: false, } } + fn custom_permission_mut(&mut self, permission: CustomPermission) -> &mut bool { + match permission { + CustomPermission::ManageUsers => &mut self.manage_users, + CustomPermission::ManageGroups => &mut self.manage_groups, + CustomPermission::ManagePolicies => &mut self.manage_policies, + CustomPermission::CreateNewCollections => &mut self.create_new_collections, + CustomPermission::EditAnyCollection => &mut self.edit_any_collection, + CustomPermission::DeleteAnyCollection => &mut self.delete_any_collection, + CustomPermission::AccessEventLogs => &mut self.access_event_logs, + CustomPermission::AccessImportExport => &mut self.access_import_export, + CustomPermission::AccessReports => &mut self.access_reports, + } + } + + /// Change the role. Nothing the old role carried survives a role change: the Custom role permissions are + /// dropped, so changing the role back later starts from none. A Custom role never keeps the legacy + /// `access_all` flag, its reach only comes from its permissions. + pub fn set_type(&mut self, new_type: MembershipType) { + if self.atype != new_type as i32 { + for permission in CustomPermission::ALL { + *self.custom_permission_mut(permission) = false; + } + } + if new_type == MembershipType::Custom { + self.access_all = false; + } + self.atype = new_type as i32; + } + + /// Grant this Custom member exactly `permissions` out of those this version enforces. A stored grant of a + /// permission this version does not enforce is kept as it is: it grants nothing here. + pub fn set_custom_permissions(&mut self, permissions: &HashSet) { + for permission in CustomPermission::ALL.into_iter().filter(|p| p.is_supported()) { + *self.custom_permission_mut(permission) = permissions.contains(&permission); + } + } + + /// Why this legacy Manager can't become a Custom member without losing what they may do now, if they can't. A + /// conversion must not silently change their access, and a Custom role can't keep access to all collections + /// (which also puts every collection in the Manager's own vault), nor manage any collection yet. + pub async fn custom_role_conversion_loss(&self, conn: &DbConn) -> Option<&'static str> { + if !self.has_type(MembershipType::Manager) { + return None; + } + if self.access_all { + return Some( + "This Manager has access to all collections, which a Custom member can't have: the collections they \ + aren't assigned to would disappear from their vault. Uncheck Manage all collections for them first, \ + or keep the Manager role.", + ); + } + // Whatever their status, the collections they manage directly, through a group or through a group with + // access to all collections + let mut manages = GroupUser::has_full_access_by_member(&self.org_uuid, &self.uuid, conn).await + || CollectionUser::find_by_organization_and_user_uuid(&self.org_uuid, &self.user_uuid, conn) + .await + .iter() + .any(|assignment| assignment.manage); + for group_user in GroupUser::find_by_member(&self.uuid, conn).await { + if manages { + break; + } + manages = CollectionGroup::find_by_group(&group_user.groups_uuid, &self.org_uuid, conn) + .await + .iter() + .any(|assignment| assignment.manage); + } + manages.then_some( + "This Manager manages collections, with the manage permission or through a group with access to all \ + collections, which a Custom member can't do yet. Remove that first, or keep the Manager role.", + ) + } + pub fn restore(&mut self) -> bool { if self.status < MembershipStatus::Invited as i32 { self.status += ACTIVATE_REVOKE_DIFF; @@ -712,6 +921,12 @@ impl Membership { } pub async fn save(&self, conn: &DbConn) -> EmptyResult { + // The legacy `access_all` flag reaches every collection, which a Custom role may only do through its + // permissions, so it must never be stored on one. `set_type` takes care of this. + if self.has_type(MembershipType::Custom) && self.access_all { + err!("A Custom role membership can't have access to all collections") + } + User::update_uuid_revision(&self.user_uuid, conn).await; db_run! { conn: @@ -1228,13 +1443,93 @@ pub struct OrgApiKeyId(String); #[cfg(test)] mod tests { use super::*; + use MembershipType::{Admin, Custom, Manager, Owner, User}; + + fn member(role: MembershipType) -> Membership { + let mut member = Membership::new("user".into(), "org".into(), None); + member.atype = role as i32; + member.status = MembershipStatus::Confirmed as i32; + member + } #[test] - #[allow(non_snake_case)] - fn partial_cmp_MembershipType() { - assert!(MembershipType::Owner > MembershipType::Admin); - assert!(MembershipType::Admin > MembershipType::Manager); - assert!(MembershipType::Manager > MembershipType::User); - assert!(MembershipType::Manager == MembershipType::from_str("4").unwrap()); + fn membership_type_authority() { + assert!(Owner > Admin && Admin > Manager && Manager > Custom && Custom > User); + assert!((Custom as i32) >= User && (Custom as i32) < Manager && (Custom as i32) < Admin); + for raw in [99, -1] { + assert_eq!((raw >= User, raw >= Manager, raw >= Admin, raw < Admin), (false, false, false, true)); + } + for (value, expected) in [ + ("3", Some(Manager)), + ("Manager", Some(Manager)), + ("4", Some(Custom)), + ("Custom", Some(Custom)), + ("5", None), + ] { + assert_eq!(MembershipType::from_str(value), expected); + } + } + + #[test] + fn requested_membership_type() { + for (current, disabled, enabled) in [ + (None, Manager, Custom), + (Some(User), Manager, Custom), + (Some(Manager), Manager, Manager), + (Some(Custom), Custom, Custom), + ] { + let current = current.map(member); + for (flag, expected) in [(false, disabled), (true, enabled)] { + assert_eq!(MembershipType::requested(Custom, current.as_ref(), flag), expected); + } + } + assert_eq!(MembershipType::requested(User, Some(&member(Custom)), true), User); + assert_eq!(MembershipType::requested(User, Some(&member(Manager)), true), User); + for requested in [Owner, Admin, Manager] { + assert_eq!(MembershipType::requested(requested, Some(&member(Custom)), true), requested); + } + } + + #[test] + fn custom_permission_requests() { + let ignored = serde_json::from_value(json!({"manageUsers":false,"manageSso":true})).unwrap(); + assert!(CustomPermission::requested(&ignored).unwrap().is_empty()); + for permission in CustomPermission::ALL { + for value in [json!(true), json!(null), json!("true"), json!(1), json!([true])] { + let request = HashMap::from([(permission.json_key().to_owned(), value.clone())]); + let result = CustomPermission::requested(&request); + if value == json!(true) && SUPPORTED_CUSTOM_PERMISSIONS.contains(&permission) { + assert_eq!(result.unwrap(), HashSet::from([permission])); + } else { + assert!(result.is_err(), "{permission:?}: {value}"); + } + } + } + assert_eq!(SUPPORTED_CUSTOM_PERMISSIONS, []); + } + + #[test] + fn role_changes_drop_custom_permissions() { + for (from, to) in [ + (Custom, Owner), + (Custom, Admin), + (Custom, Manager), + (Custom, User), + (User, Custom), + (Manager, Custom), + (Custom, Custom), + (Admin, User), + ] { + let mut member = member(from); + member.access_all = true; + for permission in CustomPermission::ALL { + *member.custom_permission_mut(permission) = true; + } + member.set_type(to); + for permission in CustomPermission::ALL { + assert_eq!(*member.custom_permission_mut(permission), from == to, "{from:?} -> {to:?}"); + } + assert_eq!(member.access_all, to != Custom); + } } } diff --git a/src/db/schema.rs b/src/db/schema.rs index ae1e3216..e1b9a78d 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -244,6 +244,15 @@ table! { atype -> Integer, reset_password_key -> Nullable, external_id -> Nullable, + manage_users -> Bool, + manage_groups -> Bool, + manage_policies -> Bool, + create_new_collections -> Bool, + edit_any_collection -> Bool, + delete_any_collection -> Bool, + access_event_logs -> Bool, + access_import_export -> Bool, + access_reports -> Bool, } } diff --git a/src/static/scripts/admin_users.js b/src/static/scripts/admin_users.js index 63ee2d7b..63128627 100644 --- a/src/static/scripts/admin_users.js +++ b/src/static/scripts/admin_users.js @@ -173,10 +173,14 @@ const ORG_TYPES = { "name": "User", "bg": "blue" }, - "4": { + "3": { "name": "Manager", "bg": "green" }, + "4": { + "name": "Custom", + "bg": "teal" + }, }; const userOrgTypeDialog = document.getElementById("userOrgTypeDialog"); @@ -187,15 +191,19 @@ userOrgTypeDialog.addEventListener("show.bs.modal", function (event) { const userUuid = event.relatedTarget.parentNode.dataset.vwUserUuid; // Get org specific values const userOrgType = event.relatedTarget.dataset.vwOrgType; - const userOrgTypeName = ORG_TYPES[userOrgType]["name"]; const orgName = event.relatedTarget.dataset.vwOrgName; const orgUuid = event.relatedTarget.dataset.vwOrgUuid; + document.getElementById("userOrgTypeForm").reset(); document.getElementById("userOrgTypeDialogOrgName").textContent = orgName; document.getElementById("userOrgTypeDialogUserEmail").textContent = userEmail; document.getElementById("userOrgTypeUserUuid").value = userUuid; document.getElementById("userOrgTypeOrgUuid").value = orgUuid; - document.getElementById(`userOrgType${userOrgTypeName}`).checked = true; + // The Custom role can only be selected while it is enabled + const userOrgTypeRadio = document.getElementById(`userOrgType${ORG_TYPES[userOrgType]?.name}`); + if (userOrgTypeRadio) { + userOrgTypeRadio.checked = true; + } }, false); // Prevent accidental submission of the form with valid elements after the modal has been hidden. @@ -222,7 +230,7 @@ function updateUserOrgType(event) { function initUserTable() { // Color all the org buttons per type document.querySelectorAll("button[data-vw-org-type]").forEach(function (e) { - const orgType = ORG_TYPES[e.dataset.vwOrgType]; + const orgType = ORG_TYPES[e.dataset.vwOrgType] ?? { "name": "Unknown", "bg": "gray" }; e.style.backgroundColor = orgType.bg; if (orgType.font !== undefined) { e.style.color = orgType.font; diff --git a/src/static/templates/admin/users.hbs b/src/static/templates/admin/users.hbs index b1dfb17d..22d8205c 100644 --- a/src/static/templates/admin/users.hbs +++ b/src/static/templates/admin/users.hbs @@ -135,6 +135,11 @@
+ {{#if custom_roles_enabled}} +
+ +
+ {{/if}}
diff --git a/src/static/templates/scss/vaultwarden.scss.hbs b/src/static/templates/scss/vaultwarden.scss.hbs index d062c026..fc6d22c6 100644 --- a/src/static/templates/scss/vaultwarden.scss.hbs +++ b/src/static/templates/scss/vaultwarden.scss.hbs @@ -98,10 +98,17 @@ app-security > app-two-factor-setup > form { } /* Hide unsupported Custom Role options */ +{{#if custom_roles_enabled}} :is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), :is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { @extend %vw-hide; } +{{else}} +:is(bit-dialog, [bit-dialog]) div.tw-ml-4:has(bit-form-control input), +:is(bit-dialog, [bit-dialog]) div.tw-col-span-4:has(input[formcontrolname*="access"], input[formcontrolname*="manage"]) { + @extend %vw-hide; +} +{{/if}} /* Change collapsed menu icon to Vaultwarden */ bit-nav-logo bit-nav-item a:before { From 17b159beff4b892f07f61f38ce2fe452e55251c1 Mon Sep 17 00:00:00 2001 From: miquno <35491275+miquno@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:17:54 +0100 Subject: [PATCH 43/43] Don't let OpenDAL create a missing config folder (#7935) The Fs operator creates its root folder if it doesn't exist, so loading the config created the data folder (or failed with a misleading invalid config.json error when it couldn't). Skip building the operator if the local config folder doesn't exist, so a missing data folder is reported again like before. --- src/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/config.rs b/src/config.rs index 1d75d342..a565a93e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -261,6 +261,10 @@ macro_rules! make_config { } async fn from_file() -> Result, Error> { + // The Fs operator would create the folder if it doesn't exist, which we never want + if !CONFIG_FILE_PARENT_DIR.starts_with("s3://") && !std::path::Path::new(&*CONFIG_FILE_PARENT_DIR).is_dir() { + return Ok(None); + } let operator = storage::operator_for_path(&CONFIG_FILE_PARENT_DIR)?; let config_bytes = match operator.read(&CONFIG_FILENAME).await { Ok(bytes) => bytes,