From 2d8728793ca899cb948af513bdf844af9d505910 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 14:15:58 -0700 Subject: [PATCH 1/2] Don't let a group's members share its mailboxes on A group's members reach its mailbox through membership, which counts as owning the account, so every ACL check was skipped: on a scratch server a member gave an outsider read access to the group's Inbox with one Mailbox/set shareWith, with no administrator involved and nothing audited. Who is in a group is an administrator's decision. AccessToken::is_group_member_only names that case (in the account only through a group, without Impersonate). For such a member: - Mailbox/set with a shareWith change, on create or update, is refused as forbidden; - IMAP SETACL and DELETEACL answer NO [NOPERM]; - myRights reports mayShare false, and MYRIGHTS leaves out "a"; every other right stays. Administrators and the account itself are unchanged. The JMAP ACL test's group section now checks all three for a member and that the outsider still has nothing (specs/multi-account.md, MA-D0, G1). jmap_tests and imap_tests pass (RocksDB). The IMAP refusal has no test of its own yet; imap_tests passing shows the rest is unchanged. --- crates/common/src/auth/access_token.rs | 10 +++++++++ crates/imap/src/op/acl.rs | 22 +++++++++++++++---- crates/jmap/src/mailbox/get.rs | 6 ++++++ crates/jmap/src/mailbox/set.rs | 9 ++++++++ tests/src/jmap/mail/acl.rs | 29 ++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 3d75bc0..da38492 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -553,6 +553,16 @@ impl AccessToken { || self.inner.access_to.iter().any(|a| a.account_id == account_id) } + /// inbuxa: MA-D0: in the account only because it is a group this token + /// belongs to. Such a member has the group's mailbox but may not share it + /// on: who is in a group is an administrator's decision, and a share + /// would let anyone in. + pub fn is_group_member_only(&self, account_id: u32) -> bool { + self.inner.account_id != account_id + && self.inner.member_of.contains(&account_id) + && !self.has_permission(Permission::Impersonate) + } + pub fn is_account_id(&self, account_id: u32) -> bool { self.inner.account_id == account_id } diff --git a/crates/imap/src/op/acl.rs b/crates/imap/src/op/acl.rs index 56d3824..b738ad2 100644 --- a/crates/imap/src/op/acl.rs +++ b/crates/imap/src/op/acl.rs @@ -212,7 +212,7 @@ impl Session { } rights } else { - vec![ + let mut rights = vec![ Rights::Read, Rights::Lookup, Rights::Insert, @@ -223,8 +223,12 @@ impl Session { Rights::CreateMailbox, Rights::DeleteMailbox, Rights::Post, - Rights::Administer, - ] + ]; + // inbuxa: MA-D0: a group's members don't share its mailboxes on. + if !access_token.is_group_member_only(mailbox_id.account_id) { + rights.push(Rights::Administer); + } + rights }; trc::event!( @@ -266,10 +270,20 @@ impl Session { spawn_op!(data, { // Validate mailbox - let (mailbox_id, current_mailbox, _) = data + let (mailbox_id, current_mailbox, access_token) = data .get_acl_mailbox(&arguments, true) .await .imap_ctx(&arguments.tag, trc::location!())?; + + // inbuxa: MA-D0: a group's members don't share its mailboxes on. + if access_token.is_group_member_only(mailbox_id.account_id) { + return Err(trc::ImapEvent::Error + .into_err() + .details("This mailbox belongs to a group. Only an administrator can change who has it.") + .code(ResponseCode::NoPerm) + .id(arguments.tag.to_string())); + } + let current_mailbox = current_mailbox .into_deserialized::() .imap_ctx(&arguments.tag, trc::location!())?; diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index c5e4062..a984bde 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/get.rs @@ -14,6 +14,7 @@ use jmap_tools::{Map, Value}; use std::future::Future; use store::ahash::AHashSet; use types::{acl::Acl, collection::Collection, keyword::Keyword, special_use::SpecialUse}; +use utils::map::bitmap::Bitmap; use crate::{api::acl::JmapRights, changes::state::JmapCacheState}; @@ -138,6 +139,11 @@ impl MailboxGet for Server { JmapRights::rights::( cached_mailbox.acls.as_slice().effective_acl(access_token), ) + } else if access_token.is_group_member_only(account_id) { + // inbuxa: MA-D0: everything but sharing it on. + let mut acl = Bitmap::::all(); + acl.remove(Acl::Share); + JmapRights::rights::(acl) } else { JmapRights::all_rights::() } diff --git a/crates/jmap/src/mailbox/set.rs b/crates/jmap/src/mailbox/set.rs index 14bc642..94b92fe 100644 --- a/crates/jmap/src/mailbox/set.rs +++ b/crates/jmap/src/mailbox/set.rs @@ -613,6 +613,15 @@ impl MailboxSet for Server { // Refresh ACLs let current = update.map(|(_, current)| current); if has_acl_changes { + // inbuxa: MA-D0: a group's members don't share its mailboxes on. + if ctx.access_token.is_group_member_only(ctx.account_id) { + return Ok(Err(SetError::forbidden() + .with_property(MailboxProperty::ShareWith) + .with_description( + "This mailbox belongs to a group. Only an administrator can change who has it.", + ))); + } + if !changes.acls.is_empty() && let Err(err) = self.acl_validate(ctx.account_id, &changes.acls).await { diff --git a/tests/src/jmap/mail/acl.rs b/tests/src/jmap/mail/acl.rs index 0ab4b4c..7ae2868 100644 --- a/tests/src/jmap/mail/acl.rs +++ b/tests/src/jmap/mail/acl.rs @@ -713,6 +713,35 @@ pub async fn test(test: &TestServer) { .await, ); + // inbuxa: MA-D0: a member can't share the group's mailbox on, and isn't + // told it may. Who is in a group is an administrator's decision. + assert_forbidden( + john_client + .set_default_account_id(sales.id_string()) + .mailbox_update_acl(&inbox_id, bill.id_string(), [ACL::ReadItems]) + .await, + ); + assert!( + !john_client + .set_default_account_id(sales.id_string()) + .mailbox_get(&inbox_id, [mailbox::Property::MyRights].into()) + .await + .unwrap() + .unwrap() + .my_rights() + .unwrap() + .acl_list() + .contains(&ACL::Administer) + ); + bill_client.refresh_session().await.unwrap(); + assert!(bill_client.session().account(sales.id_string()).is_none()); + assert_forbidden( + bill_client + .set_default_account_id(sales.id_string()) + .email_get(&email_id, [Property::Subject].into()) + .await, + ); + // Remove John from the sales group admin .registry_update_object( -- 2.54.0 From 5c1c4c62486204e5b207658d768e32c69773f1d5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 14:20:38 -0700 Subject: [PATCH 2/2] Add the modification notice to the files this changes --- crates/jmap/src/mailbox/get.rs | 2 ++ tests/src/jmap/mail/acl.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/jmap/src/mailbox/get.rs b/crates/jmap/src/mailbox/get.rs index a984bde..cf42481 100644 --- a/crates/jmap/src/mailbox/get.rs +++ b/crates/jmap/src/mailbox/get.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; diff --git a/tests/src/jmap/mail/acl.rs b/tests/src/jmap/mail/acl.rs index 7ae2868..23a607f 100644 --- a/tests/src/jmap/mail/acl.rs +++ b/tests/src/jmap/mail/acl.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::utils::server::TestServer; -- 2.54.0