From e67fb30c5ffb36bd33e9da817b12d2a799197ccf Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:31:49 +0200 Subject: [PATCH] Fix delete-only collection access in web vault --- src/api/core/organizations.rs | 72 ++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index 63d142e8..bf78e0d8 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -51,6 +51,7 @@ pub fn routes() -> Vec { post_organization_collection_delete, bulk_delete_organization_collections, post_bulk_collections, + get_assigned_org_details, get_org_details, get_org_domain_sso_verified, get_members, @@ -917,6 +918,49 @@ struct OrgIdData { organization_id: OrganizationId, } +fn filter_ciphers_for_organization(ciphers: Vec, org_id: &OrganizationId) -> Vec { + ciphers.into_iter().filter(|cipher| cipher.organization_uuid.as_ref() == Some(org_id)).collect() +} + +// The Admin Console uses this endpoint when the acting member is not allowed to read every +// cipher in the organization. In particular, a Custom member with only DeleteAnyCollection +// needs an empty successful response so the collection list can finish loading and expose its +// collection-only delete controls. +// +// Security: start from the regular user-visible cipher query and then constrain the result to +// the requested organization. DeleteAnyCollection itself must never make cipher contents visible. +#[get("/ciphers/organization-details/assigned?")] +async fn get_assigned_org_details(data: OrgIdData, headers: Headers, conn: DbConn) -> JsonResult { + if Membership::find_confirmed_by_user_and_org(&headers.user.uuid, &data.organization_id, &conn).await.is_none() { + err_code!( + "Resource not found.", + "User is not a confirmed member of the organization", + rocket::http::Status::NotFound.code + ); + } + + let ciphers = filter_ciphers_for_organization( + Cipher::find_by_user_visible(&headers.user.uuid, &conn).await, + &data.organization_id, + ); + let cipher_sync_data = CipherSyncData::new(&headers.user.uuid, CipherSyncType::User, &conn).await; + + let mut ciphers_json = Vec::with_capacity(ciphers.len()); + for cipher in ciphers { + ciphers_json.push( + cipher + .to_json(&headers.host, &headers.user.uuid, Some(&cipher_sync_data), CipherSyncType::User, &conn) + .await?, + ); + } + + Ok(Json(json!({ + "data": ciphers_json, + "object": "list", + "continuationToken": null, + }))) +} + #[get("/ciphers/organization-details?")] async fn get_org_details(data: OrgIdData, headers: ManagerHeadersLoose, conn: DbConn) -> JsonResult { if data.organization_id != headers.membership.org_uuid { @@ -3604,8 +3648,32 @@ mod tests { use serde_json::{Value, json}; - use super::{CustomRolePermissions, may_change_group_membership, may_change_member_type}; - use crate::db::models::{Membership, MembershipStatus, MembershipType}; + use super::{ + CustomRolePermissions, filter_ciphers_for_organization, may_change_group_membership, may_change_member_type, + }; + use crate::db::models::{Cipher, Membership, MembershipStatus, MembershipType, OrganizationId}; + + #[test] + fn assigned_cipher_response_is_scoped_to_requested_organization() { + let requested_org: OrganizationId = "requested-org".to_owned().into(); + let other_org: OrganizationId = "other-org".to_owned().into(); + + let mut requested_cipher = Cipher::new(1, "requested".to_owned()); + requested_cipher.organization_uuid = Some(requested_org.clone()); + let requested_cipher_id = requested_cipher.uuid.clone(); + + let mut other_cipher = Cipher::new(1, "other".to_owned()); + other_cipher.organization_uuid = Some(other_org); + + let personal_cipher = Cipher::new(1, "personal".to_owned()); + + let filtered = + filter_ciphers_for_organization(vec![other_cipher, personal_cipher, requested_cipher], &requested_org); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].uuid, requested_cipher_id); + assert_eq!(filtered[0].organization_uuid.as_ref(), Some(&requested_org)); + } #[test] fn manage_users_caller_cannot_change_member_role() {