From b25f715364946e626c7d5ca299609085ba8e1d47 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:58:34 +0200 Subject: [PATCH 1/9] Fix enforce blocked (#7246) Co-authored-by: Timshel --- src/api/icons.rs | 2 +- src/http_client.rs | 46 ++++++++++++++++++++++++++++++---------------- src/sso_client.rs | 11 ++++++++--- 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/src/api/icons.rs b/src/api/icons.rs index 02a14844..81191e38 100644 --- a/src/api/icons.rs +++ b/src/api/icons.rs @@ -65,7 +65,7 @@ static CLIENT: LazyLock = LazyLock::new(|| { let icon_download_timeout = Duration::from_secs(CONFIG.icon_download_timeout()); let pool_idle_timeout = Duration::from_secs(10); // Reuse the client between requests - get_reqwest_client_builder() + get_reqwest_client_builder(true) .cookie_provider(Arc::clone(&cookie_store)) .timeout(icon_download_timeout) .pool_max_idle_per_host(5) // Configure the Hyper Pool to only have max 5 idle connections diff --git a/src/http_client.rs b/src/http_client.rs index 232ba7da..205b1cc3 100644 --- a/src/http_client.rs +++ b/src/http_client.rs @@ -18,7 +18,7 @@ use crate::{CONFIG, util::is_global}; pub fn make_http_request(method: reqwest::Method, url: &str) -> Result { static INSTANCE: LazyLock = - LazyLock::new(|| get_reqwest_client_builder().build().expect("Failed to build client")); + LazyLock::new(|| get_reqwest_client_builder(true).build().expect("Failed to build client")); let Ok(url) = url::Url::parse(url) else { err!("Invalid URL"); @@ -32,7 +32,7 @@ pub fn make_http_request(method: reqwest::Method, url: &str) -> Result ClientBuilder { +pub fn get_reqwest_client_builder(enforce_block: bool) -> ClientBuilder { let mut headers = header::HeaderMap::new(); headers.insert(header::USER_AGENT, header::HeaderValue::from_static("Vaultwarden")); @@ -55,7 +55,7 @@ pub fn get_reqwest_client_builder() -> ClientBuilder { Client::builder() .default_headers(headers) .redirect(redirect_policy) - .dns_resolver(CustomDnsResolver::instance()) + .dns_resolver(CustomDns::instance(enforce_block)) .timeout(Duration::from_secs(10)) } @@ -210,6 +210,11 @@ impl fmt::Display for CustomHttpClientError { impl std::error::Error for CustomHttpClientError {} +pub struct CustomDns { + enforce_block: bool, + resolver: Arc, +} + #[derive(Debug, Clone)] enum CustomDnsResolver { Default(), @@ -217,12 +222,18 @@ enum CustomDnsResolver { } type BoxError = Box; -impl CustomDnsResolver { - fn instance() -> Arc { +impl CustomDns { + fn instance(enforce_block: bool) -> Self { static INSTANCE: LazyLock> = LazyLock::new(CustomDnsResolver::new); - Arc::clone(&*INSTANCE) - } + CustomDns { + enforce_block, + resolver: Arc::clone(&*INSTANCE), + } + } +} + +impl CustomDnsResolver { fn new() -> Arc { TokioResolver::builder(TokioRuntimeProvider::default()) .and_then(|mut builder| { @@ -239,30 +250,32 @@ impl CustomDnsResolver { } // Note that we get an iterator of addresses, but we only grab the first one for convenience - async fn resolve_domain(&self, name: &str) -> Result, BoxError> { - pre_resolve(name)?; + async fn resolve_domain(&self, name: &str, enforce_block: bool) -> Result, BoxError> { + pre_resolve(name, enforce_block)?; let results: Vec = match self { Self::Default() => tokio::net::lookup_host((name, 0)).await?.collect(), Self::Hickory(r) => r.lookup_ip(name).await?.iter().map(|i| SocketAddr::new(i, 0)).collect(), }; - for addr in &results { - post_resolve(name, addr.ip())?; + if enforce_block { + for addr in &results { + post_resolve(name, addr.ip())?; + } } Ok(results) } } -fn pre_resolve(name: &str) -> Result<(), CustomHttpClientError> { +fn pre_resolve(name: &str, enforce_block: bool) -> Result<(), CustomHttpClientError> { let Ok(host) = get_valid_host(name) else { return Err(CustomHttpClientError::Invalid { domain: name.to_owned(), }); }; - if should_block_host(&host).is_err() { + if enforce_block && should_block_host(&host).is_err() { return Err(CustomHttpClientError::Blocked { domain: name.to_owned(), }); @@ -282,12 +295,13 @@ fn post_resolve(name: &str, ip: IpAddr) -> Result<(), CustomHttpClientError> { } } -impl Resolve for CustomDnsResolver { +impl Resolve for CustomDns { fn resolve(&self, name: Name) -> Resolving { - let this = self.clone(); + let enforce_block = self.enforce_block; + let this = Arc::clone(&self.resolver); Box::pin(async move { let name = name.as_str(); - let results = this.resolve_domain(name).await?; + let results = this.resolve_domain(name, enforce_block).await?; if results.is_empty() { warn!("Unable to resolve {name} to any valid IP address"); } diff --git a/src/sso_client.rs b/src/sso_client.rs index 355fffcb..4f25970e 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -71,7 +71,7 @@ pub struct OidcHttpClient { impl OidcHttpClient { fn new() -> Result { - get_reqwest_client_builder().redirect(reqwest::redirect::Policy::none()).build().map(|client| Self { + get_reqwest_client_builder(false).redirect(reqwest::redirect::Policy::none()).build().map(|client| Self { client, }) } @@ -83,7 +83,10 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient { fn call(&'c self, request: HttpRequest) -> Self::Future { Box::pin(async move { - let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(Box::new)?; + let response = self.client.execute(request.try_into().map_err(Box::new)?).await.map_err(|e| { + debug!("Request failed {e:?}"); + Box::new(e) + })?; let mut builder = http::Response::builder().status(response.status()).version(response.version()); @@ -91,7 +94,9 @@ impl<'c> AsyncHttpClient<'c> for OidcHttpClient { builder = builder.header(name, value); } - builder.body(response.bytes().await.map_err(Box::new)?.to_vec()).map_err(HttpClientError::Http) + let body = response.bytes().await.map_err(Box::new)?; + debug!("Response body {}", String::from_utf8_lossy(&body)); + builder.body(body.to_vec()).map_err(HttpClientError::Http) }) } } From ec7fa137b7afd15ab13af6dcecc530661e62cd45 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:58:41 +0200 Subject: [PATCH 2/9] Admin password recovery endpoint change (#7270) * Admin password recovery endpoint change * Use default to keep compatibility --------- Co-authored-by: Timshel --- playwright/tests/organization.smtp.spec.ts | 34 +++++++++++++++++ src/api/core/organizations.rs | 43 ++++++++++++++++++++-- 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/playwright/tests/organization.smtp.spec.ts b/playwright/tests/organization.smtp.spec.ts index 35dfcdb1..2be5fec1 100644 --- a/playwright/tests/organization.smtp.spec.ts +++ b/playwright/tests/organization.smtp.spec.ts @@ -40,6 +40,16 @@ test('Invite users', async ({ page }) => { await createAccount(test, page, users.user1, mail1Buffer); await orgs.create(test, page, 'Test'); + + await test.step(`Set account recovery`, async () => { + await orgs.policies(test, page, 'Test'); + await page.getByRole('button', { name: 'Account recovery' }).click(); + await page.getByRole('checkbox', { name: 'Turn on' }).check(); + await page.getByRole('checkbox', { name: 'Require new members' }).check(); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'Edited policy Account recovery'); + }); + await orgs.members(test, page, 'Test'); await orgs.invite(test, page, 'Test', users.user2.email); await orgs.invite(test, page, 'Test', users.user3.email, { @@ -117,3 +127,27 @@ test('Organization is visible', async ({ page }) => { await page.getByRole('button', { name: 'vault: Test', exact: true }).click(); await expect(page.getByLabel('Filter: Default collection')).toBeVisible(); }); + +test('Recover user password', async ({ page }) => { + await logUser(test, page, users.user1, mail1Buffer); + + let newPassword = "TotoNewPassword"; + + await orgs.members(test, page, 'Test'); + await test.step(`Rrcover ${users.user2.email}`, async () => { + await expect(page.getByRole('heading', { name: 'Members' })).toBeVisible(); + await page.getByRole('row').filter({hasText: users.user2.email}).getByLabel('Options').click(); + await page.getByRole('menuitem', { name: 'Recover account' }).click(); + await page.getByRole('textbox', { name: 'New master password (required)', exact: true }).fill(newPassword); + await page.getByRole('textbox', { name: 'Confirm new master password (' }).fill(newPassword); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'Password reset success'); + }); + + let user2 = { + email: users.user2.email, + name: users.user2.name, + password: newPassword, + }; + await logUser(test, page, user2, mail2Buffer); +}); diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dd68cd5b..736e687d 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -96,6 +96,7 @@ pub fn routes() -> Vec { put_reset_password_enrollment, get_reset_password_details, put_reset_password, + put_recover_account, get_org_export, post_api_key, rotate_api_key, @@ -2875,9 +2876,14 @@ struct OrganizationUserResetPasswordEnrollmentRequest { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct OrganizationUserResetPasswordRequest { +struct OrganizationUserRecoverAccountRequest { new_master_password_hash: String, key: String, + + #[serde(default)] + reset_master_password: bool, + #[serde(default)] + reset_two_factor: bool, } // Upstream reports this is the renamed endpoint instead of `/keys` @@ -2905,12 +2911,43 @@ async fn get_organization_keys(org_id: OrganizationId, headers: OrgMemberHeaders get_organization_public_key(org_id, headers, conn).await } +// Will allow to reset 2FA too +// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/admin-console/src/common/organization-user/models/requests/organization-user-reset-password.request.ts +#[put("/organizations//users//recover-account", data = "")] +async fn put_recover_account( + org_id: OrganizationId, + member_id: MembershipId, + headers: AdminHeaders, + data: Json, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + let req = data.into_inner(); + if req.reset_master_password && !req.reset_two_factor { + recover_account(org_id, member_id, headers, req, conn, nt).await + } else { + err!("Unsupported operation") + } +} + +// Deprecated since `v2026.4.2` #[put("/organizations//users//reset-password", data = "")] async fn put_reset_password( org_id: OrganizationId, member_id: MembershipId, headers: AdminHeaders, - data: Json, + data: Json, + conn: DbConn, + nt: Notify<'_>, +) -> EmptyResult { + recover_account(org_id, member_id, headers, data.into_inner(), conn, nt).await +} + +async fn recover_account( + org_id: OrganizationId, + member_id: MembershipId, + headers: AdminHeaders, + reset_request: OrganizationUserRecoverAccountRequest, conn: DbConn, nt: Notify<'_>, ) -> EmptyResult { @@ -2944,8 +2981,6 @@ async fn put_reset_password( err!(format!("Error sending user reset password email: {e:#?}")); } - let reset_request = data.into_inner(); - let mut user = user; user.set_password(reset_request.new_master_password_hash.as_str(), Some(reset_request.key), true, None, &conn) .await?; From fddc16d2b87878e938f0dabaede9d728e827fd50 Mon Sep 17 00:00:00 2001 From: kvdb Date: Tue, 7 Jul 2026 15:58:54 +0200 Subject: [PATCH 3/9] fix(sends): emit hideEmail as non-null boolean in sync response (#7283) The /api/sync response serialized a Send hide_email field directly from Option, so a NULL value in the sends table (the column is Nullable with no default) produced "hideEmail": null. The Bitwarden Android client deserializes SyncResponseJson.Send.hideEmail as a non-null Kotlin Boolean and aborts the entire sync with a JsonDecodingException when it encounters null. Web, desktop and CLI clients coerce null to false, so only accounts with at least one Send are affected and only on Android. Default None to false at the serialization boundary, matching the official Bitwarden server where hideEmail is non-nullable. This needs no database migration and fixes both legacy NULL rows and any future NULLs. The hide_email field stays Option internally. --- src/db/models/send.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/models/send.rs b/src/db/models/send.rs index 0a2f1a2a..a35bcf8d 100644 --- a/src/db/models/send.rs +++ b/src/db/models/send.rs @@ -161,7 +161,7 @@ impl Send { "password": self.password_hash.as_deref().map(|h| BASE64URL_NOPAD.encode(h)), "authType": if self.password_hash.is_some() { SendAuthType::Password as i32 } else { SendAuthType::None as i32 }, "disabled": self.disabled, - "hideEmail": self.hide_email, + "hideEmail": self.hide_email.unwrap_or(false), "revisionDate": format_date(&self.revision_date), "expirationDate": self.expiration_date.as_ref().map(format_date), From a16b5afaaa5f9c546566a3d0cc0102f43b3edb95 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:59:06 +0200 Subject: [PATCH 4/9] Org membership delete remove Invitation (#7284) Co-authored-by: Timshel --- src/api/core/organizations.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 736e687d..af2c45e3 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -1090,9 +1090,13 @@ async fn send_invite( err!(format!("User already in organization: {email}")) } - // automatically accept existing users if mail is disabled - if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { - member_status = MembershipStatus::Accepted as i32; + if !CONFIG.mail_enabled() { + if user.password_hash.is_empty() { + Invitation::new(email).save(&conn).await?; + } else { + // automatically accept existing users if mail is disabled + member_status = MembershipStatus::Accepted as i32; + } } user } @@ -1714,6 +1718,15 @@ async fn delete_member_impl( if let Some(user) = User::find_by_uuid(&member_to_delete.user_uuid, conn).await { nt.send_user_update(UpdateType::SyncOrgKeys, &user, headers.device.push_uuid.as_ref(), conn).await; + + if !CONFIG.mail_enabled() + && !Membership::find_invited_by_user(&user.uuid, conn) + .await + .into_iter() + .any(|m| m.uuid != member_to_delete.uuid) + { + Invitation::take(&user.email, conn).await; + } } member_to_delete.delete(conn).await From a058a35ccddf48e77665bcf9b3f5fc2711f63f87 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:59:17 +0200 Subject: [PATCH 5/9] [v2026.5.0] Registration request update (#7295) * Registration request update * Review fix --------- Co-authored-by: Timshel --- src/api/core/accounts.rs | 120 ++++++++++++++++++++++++++++++++++----- 1 file changed, 105 insertions(+), 15 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 954b35bd..623edf24 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -97,14 +97,11 @@ pub struct RegisterData { email: String, #[serde(flatten)] - kdf: KDFData, + compat: RegisterDataCompat, - #[serde(alias = "userSymmetricKey")] - key: String, #[serde(alias = "userAsymmetricKeys")] keys: Option, - master_password_hash: String, master_password_hint: Option, name: Option, @@ -119,6 +116,102 @@ pub struct RegisterData { org_invite_token: Option, } +impl RegisterData { + fn hash(&self) -> String { + self.compat.fold(|rdc| &rdc.master_password_hash, |rdcu| &rdcu.master_password_authentication.hash).to_owned() + } + + fn kdf(&self) -> &KDFData { + self.compat.fold(|rdc| &rdc.kdf, |rdcu| &rdcu.master_password_authentication.kdf) + } + + fn key(&self) -> String { + self.compat.fold(|rdc| &rdc.key, |rdcu| &rdcu.master_password_unlock.key).to_owned() + } + + // When comparing with salt, email need to be normalized: + // - https://github.com/bitwarden/clients/blob/web-v2026.5.0/libs/common/src/key-management/master-password/services/master-password.service.ts#L171 + fn unprocessable(&self) -> bool { + let mut unprocessable = false; + *self.compat.fold( + |_| &false, + |rdcu| { + let email = self.email.trim().to_lowercase(); + unprocessable = rdcu.master_password_authentication.kdf != rdcu.master_password_unlock.kdf + || rdcu.master_password_authentication.salt != email + || rdcu.master_password_unlock.salt != email; + &unprocessable + }, + ) + } +} + +#[derive(Debug, Deserialize)] +struct RegisterDataOld { + #[serde(flatten)] + kdf: KDFData, + + #[serde(alias = "userSymmetricKey")] + key: String, + + #[serde(alias = "masterPasswordHash")] + master_password_hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RegisterDataCur { + master_password_authentication: MasterPasswordAuthentication, + master_password_unlock: MasterPasswordUnlock, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum RegisterDataCompat { + RegisterDataOld(RegisterDataOld), + RegisterDataCur(RegisterDataCur), +} + +impl RegisterDataCompat { + fn fold<'a, T>( + &'a self, + fct: impl FnOnce(&'a RegisterDataOld) -> &'a T, + fcu: impl FnOnce(&'a RegisterDataCur) -> &'a T, + ) -> &'a T { + match self { + RegisterDataCompat::RegisterDataOld(rdc) => fct(rdc), + RegisterDataCompat::RegisterDataCur(rdcu) => fcu(rdcu), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeysData { + encrypted_private_key: String, + public_key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterPasswordAuthentication { + kdf: KDFData, + salt: String, + + #[serde(alias = "masterPasswordAuthenticationHash")] + hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterPasswordUnlock { + kdf: KDFData, + salt: String, + + #[serde(alias = "masterKeyWrappedUserKey")] + key: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SetPasswordData { @@ -132,13 +225,6 @@ pub struct SetPasswordData { org_identifier: Option, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct KeysData { - encrypted_private_key: String, - public_key: String, -} - /// Trims whitespace from password hints, and converts blank password hints to `None`. fn clean_password_hint(password_hint: Option<&String>) -> Option { match password_hint { @@ -177,6 +263,10 @@ pub async fn register(data: Json, email_verification: bool, conn: let mut pending_emergency_access = None; + if data.unprocessable() { + err_code!("Unexpected RegisterData format", Status::UnprocessableEntity.code); + } + // First, validate the provided verification tokens if email_verification { match ( @@ -257,8 +347,8 @@ pub async fn register(data: Json, email_verification: bool, conn: err!("Registration not allowed or user already exists") } - if let Some(token) = data.org_invite_token { - let claims = decode_invite(&token)?; + if let Some(token) = data.org_invite_token.as_ref() { + let claims = decode_invite(token)?; if claims.email == email { // Verify the email address when signing up via a valid invite token email_verified = true; @@ -296,9 +386,9 @@ pub async fn register(data: Json, email_verification: bool, conn: // Make sure we don't leave a lingering invitation. Invitation::take(&email, &conn).await; - set_kdf_data(&mut user, &data.kdf)?; + set_kdf_data(&mut user, data.kdf())?; - user.set_password(&data.master_password_hash, Some(data.key), true, None, &conn).await?; + user.set_password(&data.hash(), Some(data.key()), true, None, &conn).await?; user.password_hint = password_hint; // Add extra fields if present From 7320a1db4b1124d53c2fe316ced8cd3acb2c0a1c Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:59:26 +0200 Subject: [PATCH 6/9] PutPolicy now using vnext format (#7296) Co-authored-by: Timshel --- src/api/core/organizations.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index af2c45e3..68c9c1c5 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -2034,18 +2034,27 @@ struct PolicyData { data: Option, } +#[derive(Deserialize)] +struct PutPolicy { + policy: PolicyData, + // Ignore metadata for now as we do not yet support this + // "metadata": { + // "defaultUserCollectionName": "2.xx|xx==|xx=" + // } +} + #[put("/organizations//policies/", data = "")] async fn put_policy( org_id: OrganizationId, pol_type: i32, - data: Json, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { if org_id != headers.org_id { err!("Organization not found", "Organization id's do not match"); } - let data: PolicyData = data.into_inner(); + let data: PolicyData = data.into_inner().policy; let Some(pol_type_enum) = OrgPolicyType::from_i32(pol_type) else { err!("Invalid or unsupported policy type") @@ -2153,26 +2162,16 @@ async fn put_policy( Ok(Json(policy.to_json())) } -#[derive(Deserialize)] -struct PolicyDataVnext { - policy: PolicyData, - // Ignore metadata for now as we do not yet support this - // "metadata": { - // "defaultUserCollectionName": "2.xx|xx==|xx=" - // } -} - +// Deprecated with client v2026.5.0 #[put("/organizations//policies//vnext", data = "")] async fn put_policy_vnext( org_id: OrganizationId, pol_type: i32, - data: Json, + data: Json, headers: AdminHeaders, conn: DbConn, ) -> JsonResult { - let data: PolicyDataVnext = data.into_inner(); - let policy: PolicyData = data.policy; - put_policy(org_id, pol_type, Json(policy), headers, conn).await + put_policy(org_id, pol_type, data, headers, conn).await } #[get("/plans")] From 5c5e8e1a6ff8ad1fb8d2b170a71f14f8e96d3d35 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:59:36 +0200 Subject: [PATCH 7/9] 2026.6.0 send support (#7346) * 2026.6.0 send support * Prevent creating and editing a Send with email verification * Review fixes --------- Co-authored-by: Timshel --- playwright/tests/send.spec.ts | 72 ++++++++++++++++ src/api/core/sends.rs | 53 ++++++++++-- src/api/identity.rs | 21 ++++- src/auth.rs | 15 ++++ src/auth/send.rs | 156 ++++++++++++++++++++++++++++++++++ src/error.rs | 24 ++++-- 6 files changed, 329 insertions(+), 12 deletions(-) create mode 100644 playwright/tests/send.spec.ts create mode 100644 src/auth/send.rs diff --git a/playwright/tests/send.spec.ts b/playwright/tests/send.spec.ts new file mode 100644 index 00000000..d27c3ffc --- /dev/null +++ b/playwright/tests/send.spec.ts @@ -0,0 +1,72 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; +import * as OTPAuth from "otpauth"; + +import * as utils from "../global-utils"; +import { createAccount } from './setups/user'; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, {}); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +test('Send', async ({ browser, page }) => { + await createAccount(test, page, users.user1); + + const send_url = await test.step('Create', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Test'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('test'); + await page.getByRole('button', { name: 'Save' }).click(); + + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await test.step('View', async () => { + await page2.goto(send_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Test' })).toBeVisible(); + }); + + const pwd_url = await test.step('Create with password', async () => { + await page.getByRole('link', { name: 'Send' }).click(); + await expect(page.locator('#main-content').getByText('Send', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'New', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Text' }).click(); + + await page.getByRole('textbox', { name: 'Send name (required)' }).fill('Password'); + await page.getByRole('textbox', { name: 'Text to share (required)' }).fill('password'); + await page.getByRole('combobox', { name: 'Who can view' }).click(); + await page.getByText('Anyone with a password set by you').click(); + await page.getByRole('textbox', { name: 'Password (required)' }).fill('password'); + + await page.getByRole('button', { name: 'Save' }).click(); + await page.locator('footer').getByRole('button', { name: 'Copy link' }).click(); + + return await page.evaluate(() => navigator.clipboard.readText()); + }); + + await test.step('View with password', async () => { + await page2.goto(pwd_url, { waitUntil: 'domcontentloaded' }); + await expect(page2.getByRole('heading', { name: 'Enter the password to view' })).toBeVisible(); + await page2.getByRole('textbox', { name: 'Password (required)' }).fill('password'); + await page2.getByRole('button', { name: 'Continue' }).click(); + await expect(page2.getByRole('heading', { name: 'View Send' })).toBeVisible(); + await expect(await page2.getByRole('paragraph').filter({ hasText: 'Password' })).toBeVisible(); + }); +}); diff --git a/src/api/core/sends.rs b/src/api/core/sends.rs index 2a7e06c1..fb3ee48f 100644 --- a/src/api/core/sends.rs +++ b/src/api/core/sends.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{ CONFIG, api::{ApiResult, EmptyResult, JsonResult, Notify, UpdateType}, - auth::{ClientIp, Headers, Host}, + auth::{ClientIp, Headers, Host, SendHeaders}, config::PathType, db::{ DbConn, DbPool, @@ -48,7 +48,9 @@ pub fn routes() -> Vec { post_send, post_send_file, post_access, + post_access_legacy, post_access_file, + post_access_file_legacy, put_send, delete_send, put_remove_password, @@ -78,6 +80,7 @@ pub struct SendData { deletion_date: DateTime, disabled: bool, hide_email: Option, + emails: Option, // Data field name: String, @@ -148,6 +151,10 @@ fn create_send(data: SendData, user_id: UserId) -> ApiResult { ); } + if data.emails.is_some() { + err!("Sends with email verification is not supported"); + } + let mut send = Send::new(data.r#type, data.name, data_str, data.key, data.deletion_date.naive_utc()); send.user_uuid = Some(user_id); send.notes = data.notes; @@ -371,7 +378,7 @@ pub struct SendFileData { } // https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/Tools/Controllers/SendsController.cs#L195 -#[post("/sends//file/", format = "multipart/form-data", data = "")] +#[post("/sends//file/", format = "multipart/form-data", data = "", rank = 2)] async fn post_send_file_v2_data( send_id: SendId, file_id: SendFileId, @@ -441,14 +448,23 @@ async fn post_send_file_v2_data( Ok(()) } +#[post("/sends/access")] +async fn post_access(headers: SendHeaders, conn: DbConn, nt: Notify<'_>) -> JsonResult { + let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + err_code!(SEND_INACCESSIBLE_MSG, 404) + }; + process_access(send, conn, nt).await +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendAccessData { pub password: Option, } +// Legacy since web-2026.6.0 #[post("/sends/access/", data = "")] -async fn post_access( +async fn post_access_legacy( access_id: &str, data: Json, conn: DbConn, @@ -494,6 +510,10 @@ async fn post_access( send.save(&conn).await?; + process_access(send, conn, nt).await +} + +async fn process_access(send: Send, conn: DbConn, nt: Notify<'_>) -> JsonResult { nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -506,8 +526,23 @@ async fn post_access( Ok(Json(send.to_json_access(&conn).await)) } -#[post("/sends//access/file/", data = "")] +#[post("/sends/access/file/", rank = 1)] async fn post_access_file( + file_id: SendFileId, + headers: SendHeaders, + host: Host, + conn: DbConn, + nt: Notify<'_>, +) -> JsonResult { + let Some(send) = Send::find_by_uuid(&headers.send_id, &conn).await else { + err_code!(SEND_INACCESSIBLE_MSG, 404) + }; + process_access_file(send, file_id, host, conn, nt).await +} + +// Legacy since web-2026.6.0 +#[post("/sends//access/file/", data = "")] +async fn post_access_file_legacy( send_id: SendId, file_id: SendFileId, data: Json, @@ -551,6 +586,10 @@ async fn post_access_file( send.save(&conn).await?; + process_access_file(send, file_id, host, conn, nt).await +} + +async fn process_access_file(send: Send, file_id: SendFileId, host: Host, conn: DbConn, nt: Notify<'_>) -> JsonResult { nt.send_send_update( UpdateType::SyncSendUpdate, &send, @@ -563,7 +602,7 @@ async fn post_access_file( Ok(Json(json!({ "object": "send-fileDownload", "id": file_id, - "url": download_url(&host, &send_id, &file_id).await?, + "url": download_url(&host, &send.uuid, &file_id).await?, }))) } @@ -601,6 +640,10 @@ async fn put_send(send_id: SendId, data: Json, headers: Headers, conn: err!("Send not found", "Send send_id is invalid or does not belong to user") }; + if data.emails.is_some() { + err!("Sends with email verification is not supported"); + } + update_send_from_data(&mut send, data, &headers, &conn, &nt, UpdateType::SyncSendUpdate).await?; Ok(Json(send.to_json())) diff --git a/src/api/identity.rs b/src/api/identity.rs index 3962827d..1597698f 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -31,8 +31,8 @@ use crate::{ DbConn, models::{ AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeResponseError, - OrganizationApiKey, OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, - UserId, + OrganizationApiKey, OrganizationId, SendId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, + TwoFactorType, User, UserId, }, }, error::MapResult, @@ -108,6 +108,19 @@ async fn login( sso_login(data, &mut user_id, &conn, &client_header.ip, client_version.as_ref()).await } "authorization_code" => err!("SSO sign-in is not available"), + "send_access" => { + check_is_some(data.client_id.as_ref(), "client_id cannot be blank")?; + check_is_some(data.send_id.as_ref(), "send_id cannot be blank")?; + + let tokens = auth::SendTokens::generate_tokens( + data.send_id.as_ref().unwrap(), + data.password_hash_b64, + &client_header.ip, + &conn, + ) + .await?; + Ok(Json(tokens.to_json())) + } t => err!("Invalid type", t), }; @@ -1144,6 +1157,10 @@ struct ConnectData { code: Option, #[field(name = uncased("code_verifier"))] code_verifier: Option, + + // Needed for send access + send_id: Option, + password_hash_b64: Option, } fn check_is_some(value: Option<&T>, msg: &str) -> EmptyResult { if value.is_none() { diff --git a/src/auth.rs b/src/auth.rs index 2ad95036..88a59b4b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,3 +1,8 @@ +#[path = "auth/send.rs"] +pub mod send; +pub type SendTokens = send::SendTokens; +pub type SendHeaders = send::SendHeaders; + use std::{ env, net::IpAddr, @@ -487,6 +492,16 @@ pub struct BasicJwtClaims { pub sub: String, } +impl BasicJwtClaims { + pub fn expires_in(&self) -> i64 { + self.exp - Utc::now().timestamp() + } + + pub fn token(&self) -> String { + encode_jwt(&self) + } +} + pub fn generate_delete_claims(uuid: String) -> BasicJwtClaims { let time_now = Utc::now(); let expire_hours = i64::from(CONFIG.invitation_expiration_hours()); diff --git a/src/auth/send.rs b/src/auth/send.rs new file mode 100644 index 00000000..84500b6a --- /dev/null +++ b/src/auth/send.rs @@ -0,0 +1,156 @@ +use chrono::{TimeDelta, Utc}; + +use rocket::request::{FromRequest, Outcome, Request}; + +use crate::{ + api::ApiResult, + auth, + auth::{BasicJwtClaims, ClientIp}, + db::{ + DbConn, + models::{Send, SendId}, + }, + error::{Error, ErrorKind}, +}; + +fn generate_send_access_claims(send_id: &SendId) -> BasicJwtClaims { + let time_now = Utc::now(); + BasicJwtClaims { + nbf: time_now.timestamp(), + exp: (time_now + TimeDelta::try_minutes(2).unwrap()).timestamp(), + iss: auth::JWT_SEND_ISSUER.to_string(), + sub: format!("{send_id}"), + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendTokens { + pub access_claims: BasicJwtClaims, +} + +impl SendTokens { + pub fn as_send_id(access_id: &str) -> Option { + data_encoding::BASE64URL_NOPAD + .decode(access_id.as_bytes()) + .ok() + .and_then(|uuid_vec| uuid::Uuid::from_slice(&uuid_vec).ok().map(|u| SendId::from(u.to_string()))) + } + + pub fn to_json(&self) -> serde_json::Value { + json!({ + "access_token": self.access_claims.token(), + "expires_in": self.access_claims.expires_in(), + "token_type": "Bearer", + "scope": "api.send.access", + }) + } + + fn expected_error(msg: &str, error_type: &str) -> ApiResult { + let err = json!({ + "kind": "expected_server", + "error": "invalid_request", + "send_access_error_type": error_type, + }); + + Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).silent()) + } + + fn invalid_error(msg: &str, error_type: &str, silent: bool) -> ApiResult { + let err = json!({ + "kind": "expected_server", + "error": "invalid_grant", + "send_access_error_type": error_type, + }); + + Err(Error::new_msg(msg).with_kind(ErrorKind::Json(err)).with_code(404).with_silent(silent)) + } + + pub async fn generate_tokens( + access_id: &str, + password: Option, + ip: &ClientIp, + 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); + }; + + let Some(mut send) = Send::find_by_uuid(&send_id, conn).await else { + return Self::invalid_error(&format!("Can't find {send_id}"), "send_id_invalid", false); + }; + + if let Some(max_access_count) = send.max_access_count + && send.access_count >= max_access_count + { + return Self::invalid_error(&format!("Send {send_id}, max access reached"), "send_id_invalid", true); + } + + if let Some(expiration) = send.expiration_date + && Utc::now().naive_utc() >= expiration + { + return Self::invalid_error(&format!("Send {send_id}, expired"), "send_id_invalid", true); + } + + if Utc::now().naive_utc() >= send.deletion_date { + return Self::invalid_error(&format!("Send {send_id}, past deletion"), "send_id_invalid", true); + } + + if send.disabled { + return Self::invalid_error(&format!("Send {send_id}, disabled"), "send_id_invalid", true); + } + + if send.password_hash.is_some() { + match password { + Some(ref p) if send.check_password(p) => { /* Nothing to do here */ } + Some(_) => { + return Self::invalid_error( + &format!("Send {send_id}, Invalid password from {}", ip.ip), + "password_hash_b64_invalid", + false, + ); + } + None => return Self::expected_error("Password required", "password_hash_b64_required"), + } + } + + send.access_count += 1; + send.save(conn).await?; + + Ok(Self { + access_claims: generate_send_access_claims(&send_id), + }) + } +} + +pub struct SendHeaders { + pub send_id: SendId, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for SendHeaders { + type Error = &'static str; + + async fn from_request(request: &'r Request<'_>) -> Outcome { + let headers = request.headers(); + + // Get access_token + let access_token: &str = if let Some(a) = headers.get_one("Authorization") { + if let Some(split) = a.rsplit("Bearer ").next() { + split + } else { + err_handler!("No access token provided") + } + } else { + err_handler!("No access token provided") + }; + + // Check JWT token is valid and get send_id + let Ok(claims) = auth::decode_send(access_token) else { + err_handler!("Invalid claim") + }; + + Outcome::Success(SendHeaders { + send_id: claims.sub.into(), + }) + } +} diff --git a/src/error.rs b/src/error.rs index d075dfe5..ecbc8199 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,14 +15,14 @@ macro_rules! make_error { #[derive(Debug)] pub struct ErrorEvent { pub event: EventType } - pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option } + pub struct Error { message: String, kind: ErrorKind, code: u16, event: Option, silent: bool } $(impl From<$ty> for Error { fn from(err: $ty) -> Self { Error::from((stringify!($name), err)) } })+ $(impl> From<(S, $ty)> for Error { fn from(val: (S, $ty)) -> Self { - Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None } + Error { message: val.0.into(), kind: ErrorKind::$name(val.1), code: BAD_REQUEST, event: None, silent: false } } })+ impl StdError for Error { @@ -172,6 +172,18 @@ impl Error { pub fn message(&self) -> &str { &self.message } + + #[must_use] + pub fn silent(mut self) -> Self { + self.silent = true; + self + } + + #[must_use] + pub fn with_silent(mut self, silent: bool) -> Self { + self.silent = silent; + self + } } pub trait MapResult { @@ -309,9 +321,11 @@ use rocket::{ impl Responder<'_, 'static> for Error { fn respond_to(self, _: &Request<'_>) -> response::Result<'static> { - match self.kind { - ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation - _ => error!(target: "error", "{self:#?}"), + if !self.silent { + match self.kind { + ErrorKind::Empty(_) | ErrorKind::Simple(_) | ErrorKind::Compact(_) => {} // Don't print the error in this situation + _ => error!(target: "error", "{self:#?}"), + } } let code = Status::from_code(self.code).unwrap_or(Status::BadRequest); From 5447ee6af27b9780e14a7c6ebe7a330820df8f48 Mon Sep 17 00:00:00 2001 From: Timshel Date: Tue, 7 Jul 2026 15:59:48 +0200 Subject: [PATCH 8/9] SSO use ClientSecretPost if ClientSecretBasic is not available (#7357) Co-authored-by: Timshel --- src/sso_client.rs | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/src/sso_client.rs b/src/sso_client.rs index 4f25970e..ff39b0b0 100644 --- a/src/sso_client.rs +++ b/src/sso_client.rs @@ -1,16 +1,16 @@ -use std::{borrow::Cow, future::Future, pin::Pin, sync::LazyLock, time::Duration}; +use std::{borrow::Cow, collections::HashSet, future::Future, pin::Pin, sync::LazyLock, time::Duration}; use openidconnect::{ - AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthenticationFlow, AuthorizationCode, AuthorizationRequest, - ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, EndpointNotSet, EndpointSet, - HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, OAuth2TokenResponse, - PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, + AccessToken, AsyncHttpClient, AuthDisplay, AuthPrompt, AuthType, AuthenticationFlow, AuthorizationCode, + AuthorizationRequest, ClientId, ClientSecret, CsrfToken, EmptyAdditionalClaims, EmptyExtraTokenFields, + EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, HttpResponse, IdTokenClaims, IdTokenFields, Nonce, + OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, RefreshToken, ResponseType, Scope, StandardErrorResponse, StandardTokenResponse, core::{ - CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreErrorResponseType, CoreGenderClaim, CoreIdTokenVerifier, - CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, CoreProviderMetadata, - CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse, - CoreTokenResponse, CoreTokenType, CoreUserInfoClaims, + CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreClientAuthMethod, CoreErrorResponseType, CoreGenderClaim, + CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJwsSigningAlgorithm, + CoreProviderMetadata, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse, + CoreTokenIntrospectionResponse, CoreTokenResponse, CoreTokenType, CoreUserInfoClaims, }, http, url, }; @@ -119,7 +119,21 @@ impl Client { Ok(metadata) => metadata, }; - let base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + let auth_methods: Option> = provider_metadata + .token_endpoint_auth_methods_supported() + .map(|v| v.iter().map(ToOwned::to_owned).collect()); + + let mut base_client = CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret)); + + if let Some(am) = auth_methods { + if am.contains(&CoreClientAuthMethod::ClientSecretBasic) { + base_client = base_client.set_auth_type(AuthType::BasicAuth); // Default + } else if am.contains(&CoreClientAuthMethod::ClientSecretPost) { + base_client = base_client.set_auth_type(AuthType::RequestBody); + } else { + err!(format!("No supported auth_methods (only basic or request body), advertised: {am:?}")); + } + } let token_uri = if let Some(uri) = base_client.token_uri() { uri.clone() From 4720cdbe8660a40b40754046fe3763eb34c735f5 Mon Sep 17 00:00:00 2001 From: pilotstew Date: Tue, 7 Jul 2026 08:59:57 -0500 Subject: [PATCH 9/9] Add `pm-26340-linux-biometrics-v2` feature flag (#7358) Co-authored-by: Claude Opus 4.8 --- .env.template | 1 + src/config.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/.env.template b/.env.template index a12559ad..0d922774 100644 --- a/.env.template +++ b/.env.template @@ -378,6 +378,7 @@ ## - "ssh-agent-v2": Enable newer SSH agent support. (Desktop >= 2026.2.1) ## - "ssh-key-vault-item": Enable the creation and use of SSH key vault items. (Clients >= 2024.12.0) ## - "pm-25373-windows-biometrics-v2": Enable the new implementation of biometrics on Windows. (Desktop >= 2025.11.0) +## - "pm-26340-linux-biometrics-v2": Enable the new implementation of biometrics on Linux. (Desktop >= 2025.11.0) ## - "anon-addy-self-host-alias": Enable configuring self-hosted Anon Addy alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) ## - "simple-login-self-host-alias": Enable configuring self-hosted Simple Login alias generator. (Android >= 2025.3.0, iOS >= 2025.4.0) ## - "mutual-tls": Enable the use of mutual TLS on Android (Clients >= 2025.2.0) diff --git a/src/config.rs b/src/config.rs index 3656d0d9..49281b6c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1404,6 +1404,7 @@ pub const SUPPORTED_FEATURE_FLAGS: &[&str] = &[ // Key Management Team "ssh-key-vault-item", "pm-25373-windows-biometrics-v2", + "pm-26340-linux-biometrics-v2", // Mobile Team "anon-addy-self-host-alias", "simple-login-self-host-alias",