From 57069605ca5465550047cba6d3a0a11a8ac95158 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:06:32 -0700 Subject: [PATCH 01/35] IMAP and ManageSieve: hold requests before sign-in to 64 KiB Before a client signed in, IMAP and ManageSieve accepted requests and literals up to the full configured request size (50 MB by default), and reserved room for a literal as soon as its size was announced. Each argument was also copied when it was handed on, and the grown buffer was kept for the rest of the session, so a large literal cost twice its size and stayed allocated. Anyone who could connect could make the server hold about 100 MB per connection without signing in. Requests before sign-in are now limited to 64 KiB, which is more than any login, SASL response or bearer token needs. The configured size applies once the client has signed in, and stops applying again after UNAUTHENTICATE. Arguments are handed over without a copy, and a literal's buffer is sized to the literal. Test: AUTHENTICATE PLAIN {100000} before sign-in got "+ Ready for 100000 bytes." on main and now gets BAD; a receiver unit test that found a 2 MB buffer kept after a 1 MB literal now passes. --- crates/imap-proto/src/receiver.rs | 36 +++++++++++++++++++---- crates/imap/src/core/session.rs | 24 +++++++++++++-- crates/imap/src/op/authenticate.rs | 10 ++++++- crates/managesieve/src/core/session.rs | 29 ++++++++++++++---- crates/managesieve/src/op/authenticate.rs | 10 ++++++- tests/src/imap/basic.rs | 6 ++++ tests/src/imap/managesieve.rs | 14 +++++++++ tests/src/utils/sieve.rs | 10 +++++++ 8 files changed, 124 insertions(+), 15 deletions(-) diff --git a/crates/imap-proto/src/receiver.rs b/crates/imap-proto/src/receiver.rs index 945003a5..1edd9bdb 100644 --- a/crates/imap-proto/src/receiver.rs +++ b/crates/imap-proto/src/receiver.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 super::{ResponseCode, ResponseType}; @@ -74,6 +76,11 @@ pub struct Receiver { const ARG_MAX_LEN: usize = 8000; +/// inbuxa: the largest request accepted before sign-in. Nothing sent before +/// sign-in (a login, a SASL response, a bearer token) comes near it, so the +/// configured request size only applies once the client is signed in. +pub const PRE_AUTH_MAX_REQUEST_SIZE: usize = 64 * 1024; + struct ArgumentBuffer { buf: Vec, } @@ -325,10 +332,10 @@ impl Receiver { ))); } self.state = State::LiteralSeek { size, non_sync }; + self.buf.clear(); if !oversize { self.buf.resize_buffer(size as usize); } - self.buf.clear(); } else { return Err(self.error_reset("Invalid empty literal.")); } @@ -422,8 +429,9 @@ impl ArgumentBuffer { } pub fn resize_buffer(&mut self, size: usize) { + // inbuxa: room for exactly the literal (the buffer is empty here) if self.buf.capacity() < size { - self.buf.reserve(size - self.buf.capacity()); + self.buf.reserve_exact(size - self.buf.len()); } } @@ -443,9 +451,9 @@ impl ArgumentBuffer { } pub fn take(&mut self) -> Vec { - let buf = self.buf.clone(); - self.buf.clear(); - buf + // inbuxa: hand the argument over instead of copying it, so a large + // literal is neither held twice nor kept for the rest of the session + std::mem::replace(&mut self.buf, Vec::with_capacity(10)) } #[inline(always)] @@ -1127,6 +1135,24 @@ mod tests { } } + #[test] + fn receiver_does_not_keep_literal_buffer() { + let mut receiver = Receiver::::with_max_request_size(2 * 1024 * 1024); + let literal = "A".repeat(1024 * 1024); + let frame = format!("a1 APPEND inbox {{{}+}}\r\n{literal}\r\n", literal.len()); + let request = receiver.parse(&mut frame.as_bytes().iter()).unwrap(); + let Some(Token::Argument(argument)) = request.tokens.last() else { + panic!("literal missing from {request:?}"); + }; + assert_eq!(argument, literal.as_bytes()); + assert!(argument.capacity() <= literal.len() + 16); + assert!( + receiver.buf.buf.capacity() < 1024, + "receiver kept a {} byte buffer after the literal", + receiver.buf.buf.capacity() + ); + } + #[test] fn receiver_discard_oversized_non_sync_literal() { for frames in [ diff --git a/crates/imap/src/core/session.rs b/crates/imap/src/core/session.rs index 3ba49489..e3ed12ae 100644 --- a/crates/imap/src/core/session.rs +++ b/crates/imap/src/core/session.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 super::{ImapSessionManager, Session, State}; @@ -15,7 +17,7 @@ use common::{ }; use imap_proto::{ protocol::{ProtocolVersion, SerializeResponse}, - receiver::Receiver, + receiver::{PRE_AUTH_MAX_REQUEST_SIZE, Receiver}, }; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -146,7 +148,14 @@ impl Session { let (stream_rx, stream_tx) = tokio::io::split(session.stream); Ok(Session { - receiver: Receiver::with_max_request_size(server.core.imap.max_request_size), + // inbuxa: the full request size only applies after sign-in + receiver: Receiver::with_max_request_size( + server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE), + ), version: ProtocolVersion::Rev1, state: State::NotAuthenticated { auth_failures: 0 }, is_tls, @@ -201,7 +210,16 @@ impl Session { let (stream_rx, stream_tx) = tokio::io::split(self.instance.tls_accept(stream, self.session_id).await?); let stream_tx = Arc::new(tokio::sync::Mutex::new(stream_tx)); - let receiver = Receiver::with_max_request_size(self.server.core.imap.max_request_size); + let receiver = + Receiver::with_max_request_size(if matches!(state, State::NotAuthenticated { .. }) { + self.server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE) + } else { + self.server.core.imap.max_request_size + }); Ok(Session { server: self.server, diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index 58c42b33..b540b266 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -15,7 +15,7 @@ use directory::Credentials; use imap_proto::{ Command, ResponseCode, StatusResponse, protocol::{authenticate::Mechanism, capability::Capability}, - receiver::{self, Request}, + receiver::{self, PRE_AUTH_MAX_REQUEST_SIZE, Request}, }; use mail_parser::decoders::base64::base64_decode; use registry::schema::enums::Permission; @@ -129,6 +129,8 @@ impl Session { .map_err(|err| err.id(tag.clone()))?, ), }; + // inbuxa: signed in, so the configured request size now applies + self.receiver.max_request_size = self.server.core.imap.max_request_size; self.write_bytes( StatusResponse::ok("Authentication successful") .with_code(ResponseCode::Capability { @@ -148,6 +150,12 @@ impl Session { pub async fn handle_unauthenticate(&mut self, request: Request) -> trc::Result<()> { self.state = State::NotAuthenticated { auth_failures: 0 }; + self.receiver.max_request_size = self + .server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE); self.is_condstore = false; self.is_qresync = false; self.is_utf8 = false; diff --git a/crates/managesieve/src/core/session.rs b/crates/managesieve/src/core/session.rs index 4de2f932..ad3fbc11 100644 --- a/crates/managesieve/src/core/session.rs +++ b/crates/managesieve/src/core/session.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 super::{ManageSieveSessionManager, Session, State}; @@ -10,7 +12,7 @@ use common::{ BuildServer, network::{SessionData, SessionManager, SessionResult, SessionStream}, }; -use imap_proto::receiver::{self, Receiver}; +use imap_proto::receiver::{self, PRE_AUTH_MAX_REQUEST_SIZE, Receiver}; use tokio_rustls::server::TlsStream; impl SessionManager for ManageSieveSessionManager { @@ -23,8 +25,15 @@ impl SessionManager for ManageSieveSessionManager { // Create session let server = self.inner.build_server(); let mut session = Session { - receiver: Receiver::with_max_request_size(server.core.imap.max_request_size) - .with_start_state(receiver::State::Command { is_uid: false }), + // inbuxa: the full request size only applies after sign-in + receiver: Receiver::with_max_request_size( + server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE), + ) + .with_start_state(receiver::State::Command { is_uid: false }), server, instance: session.instance, state: State::NotAuthenticated { auth_failures: 0 }, @@ -131,8 +140,18 @@ impl Session { } pub async fn into_tls(self) -> Result>, ()> { - let receiver = Receiver::with_max_request_size(self.server.core.imap.max_request_size) - .with_start_state(receiver::State::Command { is_uid: false }); + let receiver = Receiver::with_max_request_size( + if matches!(self.state, State::NotAuthenticated { .. }) { + self.server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE) + } else { + self.server.core.imap.max_request_size + }, + ) + .with_start_state(receiver::State::Command { is_uid: false }); Ok(Session { stream: self diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index b2f71ad8..259778c1 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -14,7 +14,7 @@ use common::{ use directory::Credentials; use imap_proto::{ protocol::authenticate::Mechanism, - receiver::{self, Request}, + receiver::{self, PRE_AUTH_MAX_REQUEST_SIZE, Request}, }; use mail_parser::decoders::base64::base64_decode; use registry::schema::enums::Permission; @@ -121,12 +121,20 @@ impl Session { access_token, in_flight, }; + // inbuxa: signed in, so the configured request size now applies + self.receiver.max_request_size = self.server.core.imap.max_request_size; Ok(StatusResponse::ok("Authentication successful").into_bytes()) } pub async fn handle_unauthenticate(&mut self) -> trc::Result> { self.state = State::NotAuthenticated { auth_failures: 0 }; + self.receiver.max_request_size = self + .server + .core + .imap + .max_request_size + .min(PRE_AUTH_MAX_REQUEST_SIZE); trc::event!( ManageSieve(trc::ManageSieveEvent::Unauthenticate), diff --git a/tests/src/imap/basic.rs b/tests/src/imap/basic.rs index d56a1527..014431b7 100644 --- a/tests/src/imap/basic.rs +++ b/tests/src/imap/basic.rs @@ -58,4 +58,10 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection) { imap.assert_read(Type::Continuation, ResponseType::Ok).await; imap.send_untagged("AGJvYXR5AG1jYm9hdGZhY2U=").await; imap.assert_read(Type::Tagged, ResponseType::No).await; + + // inbuxa: before sign-in a literal may only be as large as a sign-in needs + imap.send("AUTHENTICATE PLAIN {100000}").await; + imap.assert_read(Type::Tagged, ResponseType::Bad) + .await + .assert_contains("maximum request size"); } diff --git a/tests/src/imap/managesieve.rs b/tests/src/imap/managesieve.rs index 88ef3c26..6e46d34a 100644 --- a/tests/src/imap/managesieve.rs +++ b/tests/src/imap/managesieve.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 super::AssertResult; @@ -11,6 +13,18 @@ use imap_proto::ResponseType; pub async fn test(test: &TestServer) { println!("Running ManageSieve tests..."); + // inbuxa: before sign-in a literal may only be as large as a sign-in needs + let mut sieve = SieveConnection::connect().await; + sieve.assert_read(ResponseType::Ok).await; + sieve + .send_literal("AUTHENTICATE \"PLAIN\" ", &"A".repeat(100000)) + .await; + let line = sieve.read_line().await; + assert!( + line.contains("maximum request size"), + "oversized pre-auth literal was read: {line}" + ); + // Connect to ManageSieve let mut sieve = SieveConnection::connect().await; sieve diff --git a/tests/src/utils/sieve.rs b/tests/src/utils/sieve.rs index d9877b97..7bd91206 100644 --- a/tests/src/utils/sieve.rs +++ b/tests/src/utils/sieve.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 base64::{Engine, engine::general_purpose}; @@ -87,6 +89,14 @@ impl SieveConnection { } } + pub async fn read_line(&mut self) -> String { + tokio::time::timeout(Duration::from_millis(1500), self.reader.next_line()) + .await + .expect("Timeout while waiting for server response") + .unwrap() + .expect("Connection closed") + } + pub async fn send(&mut self, text: &str) { //println!("-> {:?}", text); self.writer.write_all(text.as_bytes()).await.unwrap(); -- 2.54.0 From b4e035a45d8b2c85854632a075e236a70dd32f08 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:06:32 -0700 Subject: [PATCH 02/35] JMAP and IMAP: keep keywords to valid names so they can't inject responses Email/set, Email/copy and Email/import took any string up to 128 characters as a keyword, spaces, parentheses and line breaks included, and IMAP wrote keywords into FETCH FLAGS byte for byte. A signed-in user could store a keyword such as "x)\r\n* 1 EXPUNGE" and forge untagged responses in the IMAP session of everyone who reads the message, other users of a shared mailbox included. A keyword stored from an IMAP literal or a Sieve addflag action did the same. JMAP now refuses, with invalidProperties, a keyword that is not 1 to 255 printable ASCII characters without ( ) { ] % * " \ (RFC 8621 section 4.1.1). IMAP leaves out of FLAGS any stored keyword that is not an atom, so keywords already stored can't inject either. Test: on main, a keyword stored from a literal came back as "* 1 FETCH (FLAGS (... x)" followed by a separate "* 99 EXPUNGE) ..." line, and Email/set accepted "x)\r\n* 1 EXPUNGE"; both are refused or left out now, and a unit test covers the name rules. --- crates/imap-proto/src/protocol/fetch.rs | 4 +- crates/imap-proto/src/protocol/mod.rs | 12 +++++ crates/jmap-proto/src/object/email.rs | 5 ++- crates/jmap/src/email/copy.rs | 9 +++- crates/jmap/src/email/import.rs | 12 ++++- crates/jmap/src/email/mod.rs | 10 +++++ crates/jmap/src/email/set.rs | 16 ++++++- crates/types/src/keyword.rs | 58 +++++++++++++++++++++++++ tests/src/imap/store.rs | 20 +++++++++ tests/src/jmap/mail/query.rs | 9 ++-- tests/src/jmap/mail/set.rs | 46 ++++++++++++++++++++ 11 files changed, 193 insertions(+), 8 deletions(-) diff --git a/crates/imap-proto/src/protocol/fetch.rs b/crates/imap-proto/src/protocol/fetch.rs index 8058b039..faff38ac 100644 --- a/crates/imap-proto/src/protocol/fetch.rs +++ b/crates/imap-proto/src/protocol/fetch.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 super::{ @@ -788,7 +790,7 @@ impl DataItem<'_> { } DataItem::Flags { flags } => { buf.extend_from_slice(b"FLAGS ("); - for (pos, flag) in flags.iter().enumerate() { + for (pos, flag) in flags.iter().filter(|f| f.is_serializable()).enumerate() { if pos > 0 { buf.push(b' '); } diff --git a/crates/imap-proto/src/protocol/mod.rs b/crates/imap-proto/src/protocol/mod.rs index a31c4afe..399027f4 100644 --- a/crates/imap-proto/src/protocol/mod.rs +++ b/crates/imap-proto/src/protocol/mod.rs @@ -330,6 +330,18 @@ pub enum Flag { } impl Flag { + /// inbuxa: a keyword is only written to a response when it is an atom + /// (or a backslash and an atom), so a keyword stored through JMAP, Sieve + /// or an IMAP literal cannot break out of a FLAGS list + pub fn is_serializable(&self) -> bool { + match self { + Flag::Keyword(keyword) => { + Keyword::is_valid_name(keyword.strip_prefix('\\').unwrap_or(keyword)) + } + _ => true, + } + } + pub fn serialize(&self, buf: &mut Vec) { buf.extend_from_slice(match self { Flag::Seen => b"\\Seen", diff --git a/crates/jmap-proto/src/object/email.rs b/crates/jmap-proto/src/object/email.rs index c4651efd..112f7735 100644 --- a/crates/jmap-proto/src/object/email.rs +++ b/crates/jmap-proto/src/object/email.rs @@ -114,7 +114,10 @@ impl Property for EmailProperty { let allow_patch = key.is_none(); if let Some(Key::Property(key)) = key { match key.patch_or_prop() { - EmailProperty::Keywords => EmailProperty::Keyword(Keyword::parse(value)).into(), + // inbuxa: a keyword that is not a valid name is not taken + EmailProperty::Keywords => Some(Keyword::parse(value)) + .filter(Keyword::is_valid) + .map(EmailProperty::Keyword), EmailProperty::MailboxIds => match parse_ref(value) { MaybeReference::Value(v) => Some(EmailProperty::IdValue(v)), MaybeReference::Reference(v) => Some(EmailProperty::IdReference(v)), diff --git a/crates/jmap/src/email/copy.rs b/crates/jmap/src/email/copy.rs index ca4b07d8..8971a434 100644 --- a/crates/jmap/src/email/copy.rs +++ b/crates/jmap/src/email/copy.rs @@ -8,7 +8,7 @@ use crate::{ changes::state::JmapCacheState, - email::{PatchResult, handle_email_patch, ingested_into_object}, + email::{PatchResult, handle_email_patch, ingested_into_object, invalid_keyword}, }; use common::{Server, auth::AccessToken, ipc::PushNotification}; use email::{ @@ -122,6 +122,13 @@ impl JmapEmailCopy for Server { .collect(); } (Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => { + // inbuxa: a keyword that is not a valid name is refused + if keywords_.iter().any(|(keyword, _)| { + !matches!(keyword, Key::Property(EmailProperty::Keyword(_))) + }) { + response.not_created.append(id, invalid_keyword()); + continue 'create; + } keywords = keywords_ .into_expanded_boolean_set() .filter_map(|id| id.try_into_property()?.try_into_keyword()) diff --git a/crates/jmap/src/email/import.rs b/crates/jmap/src/email/import.rs index 3a6a41db..777d66ef 100644 --- a/crates/jmap/src/email/import.rs +++ b/crates/jmap/src/email/import.rs @@ -2,10 +2,14 @@ * 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::{ - blob::download::BlobDownload, changes::state::JmapCacheState, email::ingested_into_object, + blob::download::BlobDownload, + changes::state::JmapCacheState, + email::{ingested_into_object, invalid_keyword}, }; use common::{Server, auth::AccessToken, ipc::PushNotification}; use email::{ @@ -128,6 +132,12 @@ impl EmailImport for Server { } } + // inbuxa: a keyword that is not a valid name is refused + if email.keywords.iter().any(|keyword| !keyword.is_valid()) { + response.not_created.append(id, invalid_keyword()); + continue; + } + let MaybeIdReference::Id(blob_id) = email.blob_id else { response.not_created.append( id, diff --git a/crates/jmap/src/email/mod.rs b/crates/jmap/src/email/mod.rs index ccd4b559..30b2a3e5 100644 --- a/crates/jmap/src/email/mod.rs +++ b/crates/jmap/src/email/mod.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 email::message::ingest::IngestedEmail; @@ -39,6 +41,14 @@ pub(crate) enum PatchResult<'x> { Invalid(SetError), } +/// inbuxa: the error for a keyword that is not a valid name (RFC 8621 +/// section 4.1.1), which would otherwise reach IMAP clients unescaped +pub(crate) fn invalid_keyword() -> SetError { + SetError::invalid_properties() + .with_property(EmailProperty::Keywords) + .with_description("Keywords must be 1 to 255 printable ASCII characters without spaces or ( ) { ] % * \" \\.") +} + pub(crate) fn handle_email_patch<'x>( pointer: &'x JsonPointer, value: Value<'_, EmailProperty, EmailValue>, diff --git a/crates/jmap/src/email/set.rs b/crates/jmap/src/email/set.rs index 083234c9..bb84aa14 100644 --- a/crates/jmap/src/email/set.rs +++ b/crates/jmap/src/email/set.rs @@ -9,7 +9,7 @@ use crate::{ blob::download::BlobDownload, changes::state::JmapCacheState, - email::{PatchResult, handle_email_patch, ingested_into_object}, + email::{PatchResult, handle_email_patch, ingested_into_object, invalid_keyword}, }; use common::{ Server, auth::AccessToken, ipc::PushNotification, storage::index::ObjectIndexBuilder, @@ -180,6 +180,13 @@ impl EmailSet for Server { .collect(); } (EmailProperty::Keywords, Value::Object(keywords_)) => { + // inbuxa: a keyword that is not a valid name is refused + if keywords_.iter().any(|(keyword, _)| { + !matches!(keyword, Key::Property(EmailProperty::Keyword(_))) + }) { + response.not_created.append(id, invalid_keyword()); + continue 'create; + } keywords = keywords_ .into_expanded_boolean_set() .filter_map(|id| id.try_into_property()?.try_into_keyword()) @@ -876,6 +883,13 @@ impl EmailSet for Server { ); } (Key::Property(EmailProperty::Keywords), Value::Object(keywords_)) => { + // inbuxa: a keyword that is not a valid name is refused + if keywords_.iter().any(|(keyword, _)| { + !matches!(keyword, Key::Property(EmailProperty::Keyword(_))) + }) { + response.not_updated.append(id, invalid_keyword()); + continue 'update; + } new_data.set_keywords( keywords_ .into_expanded_boolean_set() diff --git a/crates/types/src/keyword.rs b/crates/types/src/keyword.rs index 88427e97..0acfc7c6 100644 --- a/crates/types/src/keyword.rs +++ b/crates/types/src/keyword.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 jmap_tools::{Element, Property, Value}; @@ -120,6 +122,25 @@ pub enum Keyword { impl Keyword { pub const MAX_LENGTH: usize = 128; + /// inbuxa: RFC 8621 section 4.1.1: 1 to 255 characters from %x21-%x7E + /// except ( ) { ] % * " \, which also makes the keyword an IMAP atom. + /// Anything else could break out of an IMAP FLAGS list. + pub fn is_valid_name(name: &str) -> bool { + (1..=255).contains(&name.len()) + && name.bytes().all(|ch| { + matches!(ch, 0x21..=0x7e) + && !matches!(ch, b'(' | b')' | b'{' | b']' | b'%' | b'*' | b'"' | b'\\') + }) + } + + /// inbuxa: whether the keyword may be stored from JMAP or written to IMAP + pub fn is_valid(&self) -> bool { + match self { + Keyword::Other(name) => Self::is_valid_name(name), + _ => true, + } + } + pub fn parse(value: &str) -> Self { Self::try_parse(value) .unwrap_or_else(|| Keyword::Other(value.chars().take(Keyword::MAX_LENGTH).collect())) @@ -531,6 +552,43 @@ impl<'de> serde::Deserialize<'de> for Keyword { } } +#[cfg(test)] +mod tests { + use super::Keyword; + + #[test] + fn keyword_names() { + for name in [ + "$seen", + "$Junk", + "custom", + "x-y.z_1", + "a!#&'+-/:;<=>?@[^`|}~", + ] { + assert!(Keyword::is_valid_name(name), "{name:?} should be valid"); + } + for name in [ + "", + "two words", + "x)", + "x)\r\n* 1 EXPUNGE", + "tab\there", + "{5}", + "100%", + "a*", + "quo\"te", + "back\\slash", + "close]", + "caf\u{e9}", + &"a".repeat(256), + ] { + assert!(!Keyword::is_valid_name(name), "{name:?} should be invalid"); + } + assert!(Keyword::Seen.is_valid()); + assert!(!Keyword::parse("x y").is_valid()); + } +} + impl<'x, P: Property, E: Element + From> From for Value<'x, P, E> { fn from(id: Keyword) -> Self { Value::Element(E::from(id)) diff --git a/tests/src/imap/store.rs b/tests/src/imap/store.rs index 2b361715..b64f72e3 100644 --- a/tests/src/imap/store.rs +++ b/tests/src/imap/store.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 super::{AssertResult, ImapConnection, Type}; @@ -72,4 +74,22 @@ pub async fn test(imap: &mut ImapConnection, _imap_check: &mut ImapConnection, t .await .assert_count("FLAGS", 3) .assert_count("Answered", 0); + + // inbuxa: a keyword that is not an atom (here stored from a literal, as + // JMAP or Sieve could) is never written into FLAGS, so it cannot inject + // a response + for op in ["+", "-"] { + imap.send(&format!("UID STORE 1 {op}FLAGS (custom {{16}}")) + .await; + imap.assert_read(Type::Continuation, ResponseType::Ok).await; + imap.send_untagged("x)\r\n* 99 EXPUNGE)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("99 EXPUNGE", 0); + imap.send("UID FETCH 1 (FLAGS)").await; + imap.assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_count("99 EXPUNGE", 0) + .assert_count("custom", if op == "+" { 1 } else { 0 }); + } } diff --git a/tests/src/jmap/mail/query.rs b/tests/src/jmap/mail/query.rs index 27eefcc0..4f3edf24 100644 --- a/tests/src/jmap/mail/query.rs +++ b/tests/src/jmap/mail/query.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::store::{deflate_test_resource, query::FIELDS}; @@ -335,7 +337,7 @@ pub async fn query(client: &Client, can_stem: bool) { (email::query::Filter::has_keyword("T")), ]), vec![ - email::query::Comparator::has_keyword("attributed to"), + email::query::Comparator::has_keyword("attributed_to"), email::query::Comparator::from(), email::query::Comparator::sent_at(), ], @@ -355,7 +357,7 @@ pub async fn query(client: &Client, can_stem: bool) { (email::query::Filter::has_keyword("T")), ]), vec![ - email::query::Comparator::has_keyword("attributed to").descending(), + email::query::Comparator::has_keyword("attributed_to").descending(), email::query::Comparator::from(), email::query::Comparator::sent_at(), ], @@ -813,7 +815,8 @@ pub async fn create(test: &TestServer, account: &Account) { || keyword == "artist" || keyword == "Bronze" { - keywords.push(keyword); + // inbuxa: a keyword can't hold a space (RFC 8621 section 4.1.1) + keywords.push(keyword.replace(' ', "_")); } } diff --git a/tests/src/jmap/mail/set.rs b/tests/src/jmap/mail/set.rs index 0080382e..67e67abb 100644 --- a/tests/src/jmap/mail/set.rs +++ b/tests/src/jmap/mail/set.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::{ @@ -236,6 +238,50 @@ async fn update(client: &Client, root_mailbox_id: &str) { ) .await; + // inbuxa: a keyword must be a valid name (RFC 8621 section 4.1.1), + // whether the keywords are set whole or patched one at a time + for keywords in [&["test1", "x)\r\n* 1 EXPUNGE"][..], &["two words"]] { + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .keywords(keywords.iter().copied()); + assert!(matches!( + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + } + let mut request = client.build(); + request + .set_email() + .update(mailbox.id(0)) + .keyword("x)\r\n* 1 EXPUNGE", true); + assert!(matches!( + request + .send_set_email() + .await + .unwrap() + .updated(mailbox.id(0)), + Err(Error::Set(SetError { + type_: SetErrorType::InvalidProperties, + .. + })) + )); + assert_email_properties( + client, + mailbox.id(0), + &[&test_mailbox2_id], + &["test1", "test3"], + ) + .await; + // Orphan messages should not be permitted let mut request = client.build(); request -- 2.54.0 From 096a7320784055b4f9b5eac642df375348fb2bbf Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:06:32 -0700 Subject: [PATCH 03/35] IMAP: setting \Deleted needs the right to remove messages STORE checked only the right to modify messages, and only on the selected mailbox. A user a mailbox was shared with for flagging ("w") could also set or clear \Deleted, which needs the right to remove messages ("t", RFC 4314), so the owner's client would expunge a message the user wasn't allowed to delete. And because keywords belong to the message, a user with rights on one shared mailbox could change the flags of a message that the owner also keeps in a mailbox the user can't touch. On a shared account, \Deleted now needs the right to remove messages and every other flag the right to modify them, held on every mailbox the message is in. Flags the user may not change are left as they are and the rest of the STORE still applies; a STORE that asks only for changes the user has no right to make on the selected mailbox gets NO [NOPERM] as before. Test: with the right to modify but not remove messages in another user's mailbox, UID STORE 1 +FLAGS (\Deleted) set the flag on main and now gets NO; +FLAGS (\Deleted \Flagged) now sets only \Flagged. --- crates/imap/src/op/store.rs | 84 ++++++++++++++++++++++++++++--------- tests/src/imap/acl.rs | 20 +++++++++ 2 files changed, 85 insertions(+), 19 deletions(-) diff --git a/crates/imap/src/op/store.rs b/crates/imap/src/op/store.rs index 7491871b..56078301 100644 --- a/crates/imap/src/op/store.rs +++ b/crates/imap/src/op/store.rs @@ -14,6 +14,7 @@ use crate::{ use ahash::AHashSet; use common::{network::SessionStream, storage::index::ObjectIndexBuilder}; use email::{ + cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, mailbox::TRASH_ID, message::{ingest::EmailIngest, metadata::MessageData}, }; @@ -126,14 +127,33 @@ impl SessionData { } // Verify that the user can modify messages in this mailbox. - if !self - .check_mailbox_acl( - mailbox.id.account_id, - mailbox.id.mailbox_id, - Acl::ModifyItems, - ) + // inbuxa: \Deleted needs the right to remove messages and every other + // flag the right to modify them (RFC 4314 "t" and "w"). Keywords + // belong to the message, so on a shared account the right has to + // hold on every mailbox the message is in, not only the selected one. + let access_token = self + .refresh_access_token() .await - .imap_ctx(&arguments.tag, trc::location!())? + .imap_ctx(&arguments.tag, trc::location!())?; + let shared_rights = if !access_token.is_member(account_id) { + let cache = self + .server + .get_cached_messages(account_id) + .await + .imap_ctx(&arguments.tag, trc::location!())?; + Some(( + cache.shared_mailboxes(&access_token, Acl::ModifyItems), + cache.shared_mailboxes(&access_token, Acl::RemoveItems), + )) + } else { + None + }; + let is_replace = matches!(arguments.operation, Operation::Set); + let wants_modify = is_replace || arguments.keywords.iter().any(|f| *f != Flag::Deleted); + let wants_remove = is_replace || arguments.keywords.contains(&Flag::Deleted); + if let Some((modify_ids, remove_ids)) = &shared_rights + && !(wants_modify && modify_ids.contains(mailbox.id.mailbox_id) + || wants_remove && remove_ids.contains(mailbox.id.mailbox_id)) { return Err(trc::ImapEvent::Error .into_err() @@ -283,29 +303,55 @@ impl SessionData { .imap_ctx(response.tag.as_ref().unwrap(), trc::location!())?; let mut new_data = data.inner.to_builder(); + // inbuxa: the rights this message allows (see above); flags + // the user may not change are left as they are + let (may_modify, may_remove) = match &shared_rights { + Some((modify_ids, remove_ids)) => ( + new_data + .mailboxes + .iter() + .all(|m| modify_ids.contains(m.mailbox_id)), + new_data + .mailboxes + .iter() + .all(|m| remove_ids.contains(m.mailbox_id)), + ), + None => (true, true), + }; + let may_change = |keyword: &Keyword| { + if keyword == &Keyword::Deleted { + may_remove + } else { + may_modify + } + }; + // Apply changes - let mut seen_changed = false; match arguments.operation { - Operation::Set => { - seen_changed = set_keywords.contains(&Keyword::Seen) - != new_data.has_keyword(&Keyword::Seen); + Operation::Set if may_modify && may_remove => { new_data.set_keywords(set_keywords.clone()); } + Operation::Set => { + new_data.keywords.retain(|keyword| { + !may_change(keyword) || set_keywords.contains(keyword) + }); + for keyword in set_keywords.iter().filter(|k| may_change(k)) { + new_data.add_keyword(keyword.clone()); + } + } Operation::Add => { - for keyword in &set_keywords { - if new_data.add_keyword(keyword.clone()) && keyword == &Keyword::Seen { - seen_changed = true; - } + for keyword in set_keywords.iter().filter(|k| may_change(k)) { + new_data.add_keyword(keyword.clone()); } } Operation::Clear => { - for keyword in &set_keywords { - if new_data.remove_keyword(keyword) && keyword == &Keyword::Seen { - seen_changed = true; - } + for keyword in set_keywords.iter().filter(|k| may_change(k)) { + new_data.remove_keyword(keyword); } } } + let seen_changed = new_data.has_keyword(&Keyword::Seen) + != data.inner.keywords.iter().any(|k| k == &Keyword::Seen); if !new_data.has_keyword_changes(data.inner) { continue; diff --git a/tests/src/imap/acl.rs b/tests/src/imap/acl.rs index 9675cf87..32e68cf3 100644 --- a/tests/src/imap/acl.rs +++ b/tests/src/imap/acl.rs @@ -185,6 +185,26 @@ pub async fn test( imap_john.send("UID STORE 1 +FLAGS (\\Deleted)").await; imap_john.assert_read(Type::Tagged, ResponseType::No).await; + // inbuxa: the right to modify messages ("w") does not cover \Deleted, + // which needs the right to remove them ("t"); the other flags in the + // same STORE still apply + imap_jane.send("SETACL INBOX jdoe@example.com +w").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_john.send("UID STORE 1 +FLAGS (\\Deleted)").await; + imap_john.assert_read(Type::Tagged, ResponseType::No).await; + imap_john + .send("UID STORE 1 +FLAGS (\\Deleted \\Flagged)") + .await; + imap_john.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_john.send("UID FETCH 1 (FLAGS)").await; + imap_john + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("\\Flagged") + .assert_count("\\Deleted", 0); + imap_jane.send("SETACL INBOX jdoe@example.com -w").await; + imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_bill.send("UID STORE 1 +FLAGS (\\Deleted)").await; imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; -- 2.54.0 From e2bac51aabe043750729490c5231ce4a5a63ab7a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:06:32 -0700 Subject: [PATCH 04/35] IMAP: stop serving a shared mailbox once the share is revoked The right to read a shared mailbox was checked only at SELECT. A user who had another account's mailbox selected kept reading its messages, flags and counts through FETCH, SEARCH, SORT, THREAD, NOOP and IDLE for as long as the connection stayed open, after the owner stopped sharing it. Commands on a selected mailbox in another account now check the right to read it first, and get NO [NOPERM] once it's gone; CLOSE and UNSELECT still work. NOOP and IDLE no longer report changes in a mailbox that can't be read. Mailboxes in the user's own account are not checked. Test: after the owner ran DELETEACL, UID FETCH 1:* (PREVIEW) on the still-selected mailbox returned the message on main and now gets NO [NOPERM]; UID SEARCH ALL too. --- crates/imap/src/core/client.rs | 19 ++++++++++++++++++- crates/imap/src/core/mailbox.rs | 19 +++++++++++++++++++ crates/imap/src/op/idle.rs | 11 ++++++++++- tests/src/imap/acl.rs | 23 +++++++++++++++++++++++ 4 files changed, 70 insertions(+), 2 deletions(-) diff --git a/crates/imap/src/core/client.rs b/crates/imap/src/core/client.rs index b0ef6ea1..b0c105fe 100644 --- a/crates/imap/src/core/client.rs +++ b/crates/imap/src/core/client.rs @@ -444,7 +444,24 @@ impl Session { | Command::Sort(_) | Command::Thread(_) | Command::UidBatches => match state { - State::Selected { mailbox, .. } => { + State::Selected { data, mailbox } => { + // inbuxa: a shared mailbox whose share was revoked while + // it was selected can only be closed + if !matches!( + request.command, + Command::Close | Command::Unselect | Command::Check + ) && !data + .is_mailbox_readable(&mailbox.id) + .await + .map_err(|err| err.id(request.tag.clone()))? + { + return Err(trc::ImapEvent::Error + .into_err() + .details("You no longer have access to this mailbox.") + .code(ResponseCode::NoPerm) + .id(request.tag)); + } + // RFC 9586 forbids message numbers once UIDONLY is enabled if self.is_uidonly && request.command.requires_uid() { Err(trc::ImapEvent::Error diff --git a/crates/imap/src/core/mailbox.rs b/crates/imap/src/core/mailbox.rs index 60836b90..a8e6c6af 100644 --- a/crates/imap/src/core/mailbox.rs +++ b/crates/imap/src/core/mailbox.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 super::{Account, MailboxId, MailboxSync, Session, SessionData}; @@ -435,4 +437,21 @@ impl SessionData { .details("Mailbox no longer exists.") })?) } + + /// inbuxa: whether a selected mailbox can still be read. A mailbox in + /// another account stops being readable while selected when the share + /// is revoked (or the mailbox is deleted), and from then on none of its + /// messages, flags or counts are served. + pub async fn is_mailbox_readable(&self, mailbox: &MailboxId) -> trc::Result { + if mailbox.account_id == self.account_id { + return Ok(true); + } + match self + .check_mailbox_acl(mailbox.account_id, mailbox.mailbox_id, Acl::ReadItems) + .await + { + Err(err) if err.matches(trc::EventType::Imap(trc::ImapEvent::Error)) => Ok(false), + result => result, + } + } } diff --git a/crates/imap/src/op/idle.rs b/crates/imap/src/op/idle.rs index a1a0eb7a..23b1b1b7 100644 --- a/crates/imap/src/op/idle.rs +++ b/crates/imap/src/op/idle.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::{ @@ -199,7 +201,14 @@ impl SessionData { // Fetch selected mailbox changes if check_emails { // Synchronize emails - if let Some(mailbox) = mailbox { + // inbuxa: nothing is reported for a shared mailbox that can no + // longer be read + if let Some(mailbox) = mailbox + && self + .is_mailbox_readable(&mailbox.id) + .await + .caused_by(trc::location!())? + { // Obtain changes since last sync let modseq = mailbox.state.lock().modseq; let new_state = self diff --git a/tests/src/imap/acl.rs b/tests/src/imap/acl.rs index 32e68cf3..18ca57f4 100644 --- a/tests/src/imap/acl.rs +++ b/tests/src/imap/acl.rs @@ -406,6 +406,17 @@ pub async fn test( .await .assert_count("dedup test", 0); + // inbuxa: Bill has Jane's Inbox selected when she stops sharing it + imap_bill + .send("SELECT \"Shared Folders/jane.smith@example.com/INBOX\"") + .await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + imap_bill.send("UID FETCH 1:* (PREVIEW)").await; + imap_bill + .assert_read(Type::Tagged, ResponseType::Ok) + .await + .assert_contains("dedup test"); + // Jane stops sharing with Bill, and removes Insert access to John imap_jane.send("DELETEACL INBOX foobar@example.com").await; imap_jane.assert_read(Type::Tagged, ResponseType::Ok).await; @@ -420,6 +431,18 @@ pub async fn test( .assert_contains("\"jdoe@example.com\" rl") .assert_count("foobar@example.com", 0); + // inbuxa: from then on Bill gets nothing more from the selected mailbox + for command in ["UID FETCH 1:* (PREVIEW)", "UID SEARCH ALL"] { + imap_bill.send(command).await; + imap_bill + .assert_read(Type::Tagged, ResponseType::No) + .await + .assert_contains("NOPERM") + .assert_count("dedup test", 0); + } + imap_bill.send("UNSELECT").await; + imap_bill.assert_read(Type::Tagged, ResponseType::Ok).await; + // Bill should not have access to Jane's Inbox anymore imap_bill.send("LIST \"\" \"*\"").await; imap_bill -- 2.54.0 From cfe9420eb8efb543391eae87f512806c6ec3744b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:06:32 -0700 Subject: [PATCH 05/35] POP3: refuse every sign-in mechanism over clear text when it's off With plain-text sign-in turned off, POP3 refused USER, PASS, APOP and AUTH PLAIN on an unencrypted connection, but still offered and accepted AUTH OAUTHBEARER and AUTH XOAUTH2. A client would send its bearer token in the clear, where anyone on the network path could read it and use it until it expired. Every AUTH mechanism now follows the plain-text setting, as in IMAP and ManageSieve, and CAPA offers no mechanism on a clear-text connection where none would be accepted. Test: on a clear-text connection with plain-text sign-in off, CAPA listed "SASL OAUTHBEARER XOAUTH2" on main; it lists none now, and AUTH OAUTHBEARER, XOAUTH2 and PLAIN are each refused. --- crates/pop3/src/client.rs | 16 ++-------- crates/pop3/src/op/mod.rs | 5 +++- tests/src/imap/mod.rs | 6 +++- tests/src/imap/pop.rs | 62 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/crates/pop3/src/client.rs b/crates/pop3/src/client.rs index f2ae8342..4f5bef50 100644 --- a/crates/pop3/src/client.rs +++ b/crates/pop3/src/client.rs @@ -205,10 +205,9 @@ impl Session { ) -> trc::Result> { match &command { Command::Capa | Command::Quit | Command::Noop => Ok(command), - Command::Auth { - mechanism: Mechanism::Plain, - .. - } + // inbuxa: every mechanism sends a credential (a bearer token is + // one too), so all of them follow the plain-text policy + Command::Auth { .. } | Command::User { .. } | Command::Pass { .. } | Command::Apop { .. } => { @@ -232,15 +231,6 @@ impl Session { .details("Already authenticated.")) } } - Command::Auth { .. } => { - if let State::NotAuthenticated { .. } = &self.state { - Ok(command) - } else { - Err(trc::Pop3Event::Error - .into_err() - .details("Already authenticated.")) - } - } Command::Stls => { if !self.stream.is_tls() { Ok(command) diff --git a/crates/pop3/src/op/mod.rs b/crates/pop3/src/op/mod.rs index 6086f169..59d35576 100644 --- a/crates/pop3/src/op/mod.rs +++ b/crates/pop3/src/op/mod.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::network::SessionStream; @@ -21,7 +23,8 @@ impl Session { let mechanisms = if self.stream.is_tls() || self.server.core.imap.allow_plain_auth { vec![Mechanism::Plain, Mechanism::OAuthBearer, Mechanism::XOauth2] } else { - vec![Mechanism::OAuthBearer, Mechanism::XOauth2] + // inbuxa: no mechanism is offered where none would be accepted + vec![] }; trc::event!( diff --git a/tests/src/imap/mod.rs b/tests/src/imap/mod.rs index 5652bb62..32065a8c 100644 --- a/tests/src/imap/mod.rs +++ b/tests/src/imap/mod.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. */ pub mod acl; @@ -32,7 +34,7 @@ use ahash::AHashSet; use imap_proto::ResponseType; use registry::{ schema::{ - enums::{Permission, SpecialUse}, + enums::{NetworkListenerProtocol, Permission, SpecialUse}, prelude::ObjectType, structs::{ Email, EmailFolder, Expression, Imap, MemoryLookupKey, MtaStageAuth, MtaStageData, @@ -51,6 +53,8 @@ pub async fn imap_tests() { .await .with_default_listeners() .await + .with_listener(NetworkListenerProtocol::Pop3, "pop3-plain", 4111, false) + .await .build() .await; diff --git a/tests/src/imap/pop.rs b/tests/src/imap/pop.rs index b86faa69..936441d9 100644 --- a/tests/src/imap/pop.rs +++ b/tests/src/imap/pop.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::{ @@ -10,6 +12,11 @@ use crate::utils::{ server::TestServer, smtp::SmtpConnection, }; +use registry::schema::{prelude::Property, structs::Imap}; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpStream, +}; pub async fn test(test: &TestServer) { println!("Running POP3 tests..."); @@ -197,4 +204,59 @@ pub async fn test(test: &TestServer) { .await .assert_contains("+OK 0 0"); pop3.send("QUIT").await; + + // inbuxa: with plain-text sign-in turned off, a clear-text connection is + // offered no mechanism and accepts none, bearer tokens included + let admin = test.account("admin@example.com"); + admin + .registry_update_setting( + Imap { + allow_plain_text_auth: false, + ..Default::default() + }, + &[Property::AllowPlainTextAuth], + ) + .await; + admin.reload_settings().await; + + let (rx, mut tx) = TcpStream::connect("127.0.0.1:4111") + .await + .unwrap() + .into_split(); + let mut rx = BufReader::new(rx).lines(); + assert!(rx.next_line().await.unwrap().unwrap().starts_with("+OK")); + tx.write_all(b"CAPA\r\n").await.unwrap(); + let mut capa = Vec::new(); + loop { + let line = rx.next_line().await.unwrap().unwrap(); + if line == "." { + break; + } + capa.push(line); + } + assert!( + !capa.iter().any(|l| l.starts_with("SASL") || l == "USER"), + "clear-text CAPA offered sign-in: {capa:?}" + ); + for mechanism in ["OAUTHBEARER", "XOAUTH2", "PLAIN"] { + tx.write_all(format!("AUTH {mechanism}\r\n").as_bytes()) + .await + .unwrap(); + let line = rx.next_line().await.unwrap().unwrap(); + assert!( + line.starts_with("-ERR") && line.contains("plain-text"), + "clear-text AUTH {mechanism} was not refused: {line}" + ); + } + + admin + .registry_update_setting( + Imap { + allow_plain_text_auth: true, + ..Default::default() + }, + &[Property::AllowPlainTextAuth], + ) + .await; + admin.reload_settings().await; } -- 2.54.0 From 40523d4852b0b6e437f0c57ed94adc223c589d2a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:55:30 -0700 Subject: [PATCH 06/35] Security: ban an address again when it fails again after its ban ended An address that tripped the sign-in ban a second time after its first ban had run out was not banned again. The server's list of banned addresses kept the first, expired entry instead of the new one, and the new ban couldn't be recorded because the expired ban's record was still there (it is removed some time later), so no other server in a cluster heard of it either. Anyone whose ban had ended could go back to guessing passwords at full speed. A new ban now replaces an earlier one that ends sooner, both in memory and on record, and is announced to the cluster as before. A longer ban already in place, such as one an administrator set, is kept. Test: after the first ban of 10.0.0.2 ended, 105 more failed sign-ins from it and then a correct password signed in on main; the address is now banned again and its record runs from the second ban. --- crates/common/src/cache/reload.rs | 4 +- crates/common/src/network/security.rs | 72 +++++++++++++++++++++------ tests/src/system/security.rs | 38 ++++++++++++++ 3 files changed, 98 insertions(+), 16 deletions(-) diff --git a/crates/common/src/cache/reload.rs b/crates/common/src/cache/reload.rs index 6e20d490..fa2038cb 100644 --- a/crates/common/src/cache/reload.rs +++ b/crates/common/src/cache/reload.rs @@ -54,8 +54,8 @@ impl Server { if expires_at > now() { let mut ips = self.inner.data.blocked_ips.write(); if let Some(ip) = ip.address.try_to_ip() { - ips.blocked_ip_addresses - .insert(IpWithTtl::new(ip, expires_at)); + // inbuxa: also when an earlier ban is remembered + ips.block(IpWithTtl::new(ip, expires_at)); } else { ips.blocked_ip_networks .push(IpWithTtl::new(ip.address, expires_at)); diff --git a/crates/common/src/network/security.rs b/crates/common/src/network/security.rs index 86e0eebf..b710d9b6 100644 --- a/crates/common/src/network/security.rs +++ b/crates/common/src/network/security.rs @@ -333,27 +333,56 @@ impl Server { .data .blocked_ips .write() - .blocked_ip_addresses - .insert(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); + .block(IpWithTtl::new(ip, expires_at.unwrap_or(u64::MAX))); // Write blocked IP to config // inbuxa: AU-1.10: recorded as the server's automatic ban - let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system( + let blocked_ip = Object::from(BlockedIp { + address: IpAddrOrMask::from_ip(ip), + created_at: UTCDateTime::from_timestamp(now as i64), + expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)), + reason, + }); + let id = match inbuxa_features::audit::scope::system( "auto-ban", - self.registry().write(RegistryWrite::insert( - &BlockedIp { - address: IpAddrOrMask::from_ip(ip), - created_at: UTCDateTime::from_timestamp(now as i64), - expires_at: expires_at.map(|ts| UTCDateTime::from_timestamp(ts as i64)), - reason, - } - .into(), - )), + self.registry().write(RegistryWrite::insert(&blocked_ip)), ) .await .caused_by(trc::location!())? - else { - return Ok(()); + { + RegistryWriteResult::Success(id) => id, + // inbuxa: the address's earlier ban is still on record (it is + // removed some time after it ends): the new ban replaces it + // unless that one lasts longer + RegistryWriteResult::PrimaryKeyConflict { existing_id, .. } + if existing_id.object() == ObjectType::BlockedIp => + { + let Some(existing) = self.registry().get(existing_id).await? else { + return Ok(()); + }; + let existing_expires_at = BlockedIp::from(existing.clone()) + .expires_at + .map(|dt| dt.timestamp() as u64) + .unwrap_or(u64::MAX); + if existing_expires_at >= expires_at.unwrap_or(u64::MAX) { + return Ok(()); + } + let RegistryWriteResult::Success(id) = inbuxa_features::audit::scope::system( + "auto-ban", + self.registry().write(RegistryWrite::update( + existing_id.id(), + &blocked_ip, + &existing, + )), + ) + .await + .caused_by(trc::location!())? + else { + return Ok(()); + }; + id + } + _ => return Ok(()), }; // Increment version @@ -530,6 +559,21 @@ impl BlockedIps { } } +impl BlockedIps { + /// inbuxa: bans an address. A set keeps the entry it already has, so an + /// address whose earlier ban has ended would otherwise stay unbanned; + /// the later of the two bans is kept. + pub fn block(&mut self, ip: IpWithTtl) { + if self + .blocked_ip_addresses + .get(&ip) + .is_none_or(|existing| existing.expires_at < ip.expires_at) + { + self.blocked_ip_addresses.replace(ip); + } + } +} + impl Hash for IpWithTtl { fn hash(&self, state: &mut H) { self.ip.hash(state); diff --git a/tests/src/system/security.rs b/tests/src/system/security.rs index dbae92c8..c4f9b4f6 100644 --- a/tests/src/system/security.rs +++ b/tests/src/system/security.rs @@ -221,6 +221,44 @@ pub async fn test(test: &mut TestServer) { ) .await; + // inbuxa: an address that fails again after its ban ran out is banned + // again, although the expired ban is still remembered and its record + // not yet cleaned up + for _ in 0..105 { + validate_password_with_ip("unknown@example.org", "wrong password", "10.0.0.2", false).await; + } + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + false, + ) + .await; + let blocked_ids = admin + .registry_query_ids( + ObjectType::BlockedIp, + [(Property::Address, "10.0.0.2")], + Vec::<&str>::new(), + ) + .await; + assert_eq!(blocked_ids.len(), 1); + assert!( + admin + .registry_get::(blocked_ids[0]) + .await + .expires_at + .is_some_and(|at| at.timestamp() > store::write::now() as i64), + "the second ban's record runs from the second ban" + ); + tokio::time::sleep(Duration::from_secs(6)).await; + validate_password_with_ip( + "user@example.org", + "this is a very strong password", + "10.0.0.2", + true, + ) + .await; + // inbuxa: personal-data catalog, D2: the daily clean-up removes the // expired ban's record, without waiting for settings to reload test.server.purge_expired_blocked_ips().await.unwrap(); -- 2.54.0 From 19760b215db6b4e60a53de8c6f7559df132c962f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:55:30 -0700 Subject: [PATCH 07/35] HTTP: stop accepting a bearer token once it expires The HTTP sign-in cache remembered every successful sign-in for the OAuth access-token lifetime (an hour by default) from the moment it first saw the credentials. An OAuth access token first used shortly before it expired kept working for up to that long afterwards, and so did a token from an external OpenID Connect provider, whose lifetime is often a few minutes. Anyone holding such a token, a leaked one included, could use it past its expiry. A bearer token's sign-in is now remembered only until the token expires: the server's own tokens carry their expiry, and a provider's JWT its exp claim. Other sign-ins are remembered as before. Test: a 5-second access token used once 3 seconds in was still accepted 6 seconds in on main; it is now refused. --- crates/common/src/auth/access_token.rs | 20 ++++++++++++ crates/common/src/auth/authentication.rs | 40 +++++++++++++++++------- crates/common/src/auth/mod.rs | 3 ++ crates/common/src/lib.rs | 2 ++ crates/http/src/auth/authenticate.rs | 39 +++++++++++++++-------- tests/src/system/oidc.rs | 12 ++++++- 6 files changed, 91 insertions(+), 25 deletions(-) diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 6780804e..1b4352c7 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -496,6 +496,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner, } .assert_is_valid(remote_ip) @@ -505,6 +506,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner, } } @@ -529,6 +531,7 @@ impl AccessToken { scope_idx, inner, origin: None, + expires_at: None, }) .and_then(|token| token.assert_is_valid(remote_ip)) } @@ -544,6 +547,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner, } .assert_is_valid(remote_ip) @@ -747,6 +751,7 @@ impl AccessToken { access_token = AccessToken { scope_idx: access_token.scope_idx, origin: access_token.origin.clone(), + expires_at: access_token.expires_at, inner: Arc::new(inner), }; } @@ -990,10 +995,24 @@ impl AccessToken { self } + /// inbuxa: when the bearer token this session signed in with expires, + /// in seconds since the Unix epoch. None when it signed in otherwise. + pub fn expires_at(&self) -> Option { + self.expires_at + } + + /// inbuxa: records when the bearer token this session signed in with + /// expires. + pub fn with_expires_at(mut self, expires_at: Option) -> Self { + self.expires_at = expires_at; + self + } + pub fn new_admin() -> AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner: Arc::new(AccessTokenInner::new_admin()), } } @@ -1009,6 +1028,7 @@ impl AccessToken { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner: Arc::new(AccessTokenInner { account_id, tenant_id: Default::default(), diff --git a/crates/common/src/auth/authentication.rs b/crates/common/src/auth/authentication.rs index 3e160bcf..90df12be 100644 --- a/crates/common/src/auth/authentication.rs +++ b/crates/common/src/auth/authentication.rs @@ -415,6 +415,8 @@ impl Server { } _ => false, }; + // inbuxa: the sign-in lasts no longer than the token + let expires_at = JwtClaims::expires_at(token); let token = self .build_directory_token(directory, result, req.remote_ip) .await?; @@ -426,7 +428,9 @@ impl Server { .reason("Authenticated using an email alias but account does not have AuthenticateAlias permission")); } // inbuxa: AU-5 - return Ok(token.with_origin(Via::Directory)); + return Ok(token + .with_origin(Via::Directory) + .with_expires_at(expires_at)); } Err(err) => { external_error = Some(err); @@ -445,16 +449,19 @@ impl Server { .and_then(|token| AccessToken::new(token, req.remote_ip)) // inbuxa: AU-5 .map(|token| { - token.with_origin(Via::OAuth { - client: token_info - .claims - .as_deref() - .filter(|claims| !claims.is_empty()) - .unwrap_or("unknown") - .chars() - .take(200) - .collect(), - }) + token + .with_origin(Via::OAuth { + client: token_info + .claims + .as_deref() + .filter(|claims| !claims.is_empty()) + .unwrap_or("unknown") + .chars() + .take(200) + .collect(), + }) + // inbuxa: the sign-in lasts no longer than the token + .with_expires_at(Some(token_info.expiry)) }), Err(err) => { if let Some(external_error) = external_error { @@ -747,6 +754,17 @@ impl<'x> JwtClaims<'x> { serde_json::from_slice(payload).ok() } + /// inbuxa: when a JWT expires, from its `exp` claim. Read only after the + /// directory has verified the token. + fn expires_at(token: &str) -> Option { + serde_json::from_slice::(&Self::decode_payload(token)?) + .ok()? + .get("exp")? + .as_f64() + .filter(|exp| *exp > 0.0) + .map(|exp| exp as u64) + } + fn domain(&self) -> Option<&str> { [&self.email, &self.preferred_username, &self.upn] .into_iter() diff --git a/crates/common/src/auth/mod.rs b/crates/common/src/auth/mod.rs index 648d5d6f..6f9e5c2f 100644 --- a/crates/common/src/auth/mod.rs +++ b/crates/common/src/auth/mod.rs @@ -134,6 +134,8 @@ pub struct AccessToken { inner: Arc, // inbuxa: how this session signed in, for the audit log (AU-5) origin: Option>, + // inbuxa: when the bearer token this session signed in with expires + expires_at: Option, } #[derive(Debug, Default, Clone)] @@ -320,6 +322,7 @@ impl BuildAccessToken for Arc { AccessToken { scope_idx: 0, origin: None, + expires_at: None, inner: self, } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f31cc37b..d3ede7e1 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -303,6 +303,8 @@ pub struct HttpAuthCache { pub expires: Instant, // inbuxa: how the cached credentials signed in (AU-5) pub origin: Option>, + // inbuxa: when the cached bearer token expires + pub expires_at: Option, } pub struct Ipc { diff --git a/crates/http/src/auth/authenticate.rs b/crates/http/src/auth/authenticate.rs index e15d3971..48047d4a 100644 --- a/crates/http/src/auth/authenticate.rs +++ b/crates/http/src/auth/authenticate.rs @@ -13,7 +13,7 @@ use http_proto::{HttpRequest, HttpSessionData}; use hyper::header; use mail_parser::decoders::base64::base64_decode; use std::future::Future; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub trait Authenticator: Sync + Send { fn authenticate_headers( @@ -40,7 +40,8 @@ impl Authenticator for Server { session.remote_ip, )? // inbuxa: AU-5 - .with_origin_arc(http_cache.origin.clone()); + .with_origin_arc(http_cache.origin.clone()) + .with_expires_at(http_cache.expires_at); if access_token.revision() == http_cache.revision { // Enforce authenticated rate limit @@ -95,17 +96,29 @@ impl Authenticator for Server { .await?; // Cache credentials - self.inner.cache.http_auth.insert( - token.into(), - HttpAuthCache { - account_id: access_token.account_id(), - revision: access_token.revision(), - credential_id: access_token.credential_id(), - expires: Instant::now() - + Duration::from_secs(self.core.oauth.oauth_expiry_token), - origin: access_token.origin_arc(), - }, - ); + // inbuxa: a bearer token's sign-in is remembered no longer than + // the token is valid + let lifetime = Duration::from_secs(self.core.oauth.oauth_expiry_token); + let lifetime = match access_token.expires_at() { + Some(expires_at) => (UNIX_EPOCH + Duration::from_secs(expires_at)) + .duration_since(SystemTime::now()) + .unwrap_or_default() + .min(lifetime), + None => lifetime, + }; + if !lifetime.is_zero() { + self.inner.cache.http_auth.insert( + token.into(), + HttpAuthCache { + account_id: access_token.account_id(), + revision: access_token.revision(), + credential_id: access_token.credential_id(), + expires: Instant::now() + lifetime, + origin: access_token.origin_arc(), + expires_at: access_token.expires_at(), + }, + ); + } // Enforce authenticated rate limit self.is_http_authenticated_request_allowed(&access_token, session.remote_ip) diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index bcbb56b8..16600a88 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -893,7 +893,17 @@ pub async fn test(test: &mut TestServer) { ); // Wait 1 second and make sure the access token expired - tokio::time::sleep(Duration::from_secs(6)).await; // inbuxa: past the 5s token + // inbuxa: used once 3s in, the token must still stop working when it + // expires, not when its cached sign-in would + tokio::time::sleep(Duration::from_secs(3)).await; + Client::new() + .credentials(Credentials::bearer(&token)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect("https://127.0.0.1:8899") + .await + .unwrap(); + tokio::time::sleep(Duration::from_secs(3)).await; // inbuxa: past the 5s token assert_unauthorized("https://127.0.0.1:8899", &token).await; // Wait another second for the refresh token to be about to expire -- 2.54.0 From 3caf479c3c4232d167b1c8f821281704456a0137 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:55:30 -0700 Subject: [PATCH 08/35] JMAP: end a WebSocket session when its sign-in stops being valid A JMAP WebSocket kept the access token it was opened with for as long as the socket stayed open. An expired OAuth token, a locked or deleted account, a changed password or a removed app password or API key all kept working on an open socket, and requests on it saw the permissions and shares the account had when it connected. Requests over the socket also skipped the per-account request limits. Before each request, and before sending queued push notifications, the session is now checked as it stands: when the bearer token has expired, the account is locked or gone, the password has changed, or the app password or API key it used is gone, the server answers with an unauthorized error and closes the socket. Requests use the account's current permissions and count toward its request limits like any other HTTP request. Test: a WebSocket opened with a 5-second OAuth token still answered Mailbox/get 6 seconds later on main; it is now refused. --- crates/common/src/auth/access_token.rs | 6 ++ crates/jmap/src/websocket/stream.rs | 114 +++++++++++++++++++++++-- tests/src/system/oidc.rs | 26 ++++++ 3 files changed, 139 insertions(+), 7 deletions(-) diff --git a/crates/common/src/auth/access_token.rs b/crates/common/src/auth/access_token.rs index 1b4352c7..8e527546 100644 --- a/crates/common/src/auth/access_token.rs +++ b/crates/common/src/auth/access_token.rs @@ -1008,6 +1008,12 @@ impl AccessToken { self } + /// inbuxa: the version of the account's password this session's + /// credentials go with; it changes with the password. + pub fn credential_version(&self) -> u64 { + self.inner.credential_version + } + pub fn new_admin() -> AccessToken { AccessToken { scope_idx: 0, diff --git a/crates/jmap/src/websocket/stream.rs b/crates/jmap/src/websocket/stream.rs index 234de7d7..8e1413b1 100644 --- a/crates/jmap/src/websocket/stream.rs +++ b/crates/jmap/src/websocket/stream.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::api::{ToRequestError, notifications_into_push_objects, request::RequestHandler}; @@ -17,7 +19,9 @@ use jmap_proto::{ }, }; use std::future::Future; +use std::net::IpAddr; use std::time::Instant; +use store::write::now; use tokio_tungstenite::WebSocketStream; use trc::JmapEvent; use tungstenite::Message; @@ -94,15 +98,44 @@ impl WebSocketHandler for Server { self.core.jmap.request_max_size, ) { Ok(WebSocketMessage::Request(request)) => { - let response = self - .handle_jmap_request( - request.request, + // inbuxa: each request is served as the + // session stands now, and counts toward + // the account's request limits + let access_token = match current_websocket_token( + self, + &access_token, + session.remote_ip, + ) + .await + { + Ok(access_token) => access_token, + Err(err) => { + end_websocket(&mut stream, err, session.session_id).await; + break; + } + }; + match self + .is_http_authenticated_request_allowed( &access_token, - &session, + session.remote_ip, ) - .await; - WebSocketResponse::from_response(response, request.id) - .to_json() + .await + { + Ok(_in_flight) => { + let response = self + .handle_jmap_request( + request.request, + &access_token, + &session, + ) + .await; + WebSocketResponse::from_response(response, request.id) + .to_json() + } + Err(err) => { + WebSocketRequestError::from(err.to_request_error()).to_json() + } + } } Ok(WebSocketMessage::PushEnable(push_enable)) => { change_types = if !push_enable.data_types.is_empty() { @@ -229,6 +262,13 @@ impl WebSocketHandler for Server { // Send any queued changes let elapsed = last_changes_sent.elapsed(); if elapsed >= throttle { + // inbuxa: only while the session is still valid + if let Err(err) = + current_websocket_token(self, &access_token, session.remote_ip).await + { + end_websocket(&mut stream, err, session.session_id).await; + break; + } for push in notifications_into_push_objects(std::mem::take(&mut notifications)) { let payload = WebSocketPushObject { @@ -267,3 +307,63 @@ impl WebSocketHandler for Server { } } } + +/// inbuxa: the WebSocket's access token as it stands now. The socket +/// outlives the request that opened it, so it ends when the bearer token it +/// signed in with expires, the account is locked or removed, the password +/// changes, or the app password or API key it used goes; and each request +/// sees the account's current permissions and shares. +async fn current_websocket_token( + server: &Server, + signed_in: &AccessToken, + remote_ip: IpAddr, +) -> trc::Result { + if signed_in + .expires_at() + .is_some_and(|expires_at| expires_at <= now()) + { + return Err(trc::AuthEvent::TokenExpired.into_err()); + } + let inner = server.access_token(signed_in.account_id()).await?; + if inner.is_locked() { + return Err(trc::AuthEvent::Failed + .into_err() + .details("Account is locked")); + } + // A session that signed in with an app password or API key ends when + // that credential goes, which renewing it below checks + if signed_in.credential_id().is_none() + && inner.credential_version() != signed_in.credential_version() + { + return Err(trc::AuthEvent::TokenExpired + .into_err() + .details("Credentials changed")); + } + AccessToken::renew(inner, signed_in.credential_id(), remote_ip).map(|access_token| { + access_token + .with_origin_arc(signed_in.origin_arc()) + .with_expires_at(signed_in.expires_at()) + }) +} + +/// inbuxa: tells the client its session is over and closes the socket. +async fn end_websocket( + stream: &mut WebSocketStream>, + err: trc::Error, + session_id: u64, +) { + trc::event!( + Jmap(JmapEvent::WebsocketStop), + SpanId = session_id, + Reason = "Session no longer authorized", + CausedBy = err, + ); + let _ = stream + .send(Message::Text( + WebSocketRequestError::from(RequestError::unauthorized()) + .to_json() + .into(), + )) + .await; + let _ = stream.close(None).await; +} diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index 16600a88..63c19a1e 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -23,6 +23,7 @@ use common::auth::oauth::{ ClientRegistrationRequest, ClientRegistrationResponse, TokenEndpointAuthMethod, }, }; +use futures::StreamExt; use http::auth::oauth::{ DeviceAuthResponse, ErrorType, TokenResponse, auth::{LoginRequest, LoginResponse}, @@ -30,6 +31,7 @@ use http::auth::oauth::{ use imap_proto::ResponseType; use jmap_client::{ client::{Client, Credentials}, + client_ws::WebSocketMessage, mailbox::query::Filter, }; use registry::schema::{ @@ -686,6 +688,30 @@ pub async fn test(test: &mut TestServer) { user_id.to_string() ); + // inbuxa: a WebSocket opened with the token serves it only while the + // token is valid + let mut ws = confidential_client.connect_ws().await.unwrap(); + let mut request = confidential_client.build(); + request.get_mailbox(); + request.send_ws().await.unwrap(); + assert!( + matches!( + tokio::time::timeout(Duration::from_secs(5), ws.next()).await, + Ok(Some(Ok(WebSocketMessage::Response(_)))) + ), + "the WebSocket serves a valid token" + ); + tokio::time::sleep(Duration::from_secs(6)).await; // past the 5s token + let mut request = confidential_client.build(); + request.get_mailbox(); + request.send_ws().await.unwrap(); + let after_expiry = tokio::time::timeout(Duration::from_secs(5), ws.next()).await; + assert!( + !matches!(after_expiry, Ok(Some(Ok(WebSocketMessage::Response(_))))), + "the WebSocket must stop serving an expired token, got {after_expiry:?}" + ); + drop(ws); + // The correct secret in the Authorization header (client_secret_basic) also works let mut params = base_params(); params.remove("client_id"); -- 2.54.0 From 6713258d2314bc9e4383f44c7e36589b20eb94af Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:55:30 -0700 Subject: [PATCH 09/35] OAuth: redeem a refresh token only for its own client, authenticated The refresh-token grant didn't compare the client making the request with the client the refresh token was issued to, and checked a client secret only when the request named a client. A refresh token issued to a confidential client could be redeemed with no client authentication at all, or under another client's id. Anyone who obtained a refresh token (from a log, a backup or a compromised app) could keep minting access tokens without the client's secret. The refresh token names the client it was issued to. Only that client can now redeem it, and a confidential client has to authenticate to do so; a request that names no client stands for the token's own client and is authenticated as it. A request naming another client gets invalid_grant. A refresh token issued before tokens named their client goes to the client that authenticates now. Test: a confidential client's refresh token was redeemed with no client id or secret on main; it now gets invalid_client, as does the client's id without its secret, and another client's id gets invalid_grant. --- crates/http/src/auth/oauth/token.rs | 54 ++++++++++++++++++++--------- tests/src/system/oidc.rs | 39 ++++++++++++++++++++- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/crates/http/src/auth/oauth/token.rs b/crates/http/src/auth/oauth/token.rs index be57fb9a..c2c64e3a 100644 --- a/crates/http/src/auth/oauth/token.rs +++ b/crates/http/src/auth/oauth/token.rs @@ -224,17 +224,6 @@ impl TokenHandler for Server { } } else if grant_type.eq_ignore_ascii_case("refresh_token") { if let Some(refresh_token) = params.get("refresh_token") { - if let Some(client_id) = client_id_cred.as_deref() - && let Some(error) = self - .verify_client_secret(client_id, client_secret_cred.as_deref()) - .await? - { - return Ok(JsonResponse::with_status( - StatusCode::BAD_REQUEST, - TokenResponse::error(error), - ) - .into_http_response()); - } response = match self .validate_access_token(GrantType::RefreshToken.into(), refresh_token) .await @@ -248,11 +237,43 @@ impl TokenHandler for Server { { TokenResponse::error(ErrorType::InvalidGrant) } - Ok(token_info) => self - .issue_token( + Ok(token_info) => { + // inbuxa: the refresh token names the client it was + // issued to (AU-5). Only that client redeems it, and a + // confidential one authenticates to do so; naming no + // client stands for the token's own. A token from + // before tokens named their client goes to the client + // that authenticates now. + let issued_to = token_info + .claims + .as_deref() + .filter(|client_id| !client_id.is_empty()); + let client_id = match (issued_to, client_id_cred.as_deref()) { + (Some(issued_to), Some(client_id)) if issued_to != client_id => { + return Ok(JsonResponse::with_status( + StatusCode::BAD_REQUEST, + TokenResponse::error(ErrorType::InvalidGrant), + ) + .into_http_response()); + } + (Some(client_id), _) | (None, Some(client_id)) => client_id, + (None, None) => "", + }; + if !client_id.is_empty() + && let Some(error) = self + .verify_client_secret(client_id, client_secret_cred.as_deref()) + .await? + { + return Ok(JsonResponse::with_status( + StatusCode::BAD_REQUEST, + TokenResponse::error(error), + ) + .into_http_response()); + } + + self.issue_token( token_info.account_id, - // inbuxa: AU-5: the client travels in the refresh token - token_info.claims.as_deref().unwrap_or_default(), + client_id, issuer, None, None, @@ -267,7 +288,8 @@ impl TokenHandler for Server { .into_err() .details(err) .caused_by(trc::location!()) - })?, + })? + } Err(err) => { trc::error!( err.caused_by(trc::location!()) diff --git a/tests/src/system/oidc.rs b/tests/src/system/oidc.rs index 63c19a1e..a22795e8 100644 --- a/tests/src/system/oidc.rs +++ b/tests/src/system/oidc.rs @@ -675,7 +675,8 @@ pub async fn test(test: &mut TestServer) { obtain_auth_code(&http, &confidential_id, confidential_redirect).await, ); params.insert("client_secret".to_string(), confidential_secret.to_string()); - let (token, _, _) = unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); + let (token, confidential_refresh, _) = + unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); let confidential_client = Client::new() .credentials(Credentials::bearer(&token)) .accept_invalid_certs(true) @@ -712,6 +713,42 @@ pub async fn test(test: &mut TestServer) { ); drop(ws); + // inbuxa: a refresh token is redeemed only by the client it was issued + // to, and a confidential client has to authenticate to redeem it + let mut params = AHashMap::from_iter([ + ("grant_type".to_string(), "refresh_token".to_string()), + ( + "refresh_token".to_string(), + confidential_refresh.expect("a refresh token"), + ), + ]); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "a confidential client's refresh token needs the client's secret" + ); + params.insert("client_id".to_string(), confidential_id.to_string()); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidClient + }, + "naming the client is not authenticating it" + ); + params.insert("client_id".to_string(), client_id.to_string()); + assert_eq!( + post::(&metadata.token_endpoint, ¶ms).await, + TokenResponse::Error { + error: ErrorType::InvalidGrant + }, + "another client can't redeem the refresh token" + ); + params.insert("client_id".to_string(), confidential_id.to_string()); + params.insert("client_secret".to_string(), confidential_secret.to_string()); + unwrap_token_response(post(&metadata.token_endpoint, ¶ms).await); + // The correct secret in the Authorization header (client_secret_basic) also works let mut params = base_params(); params.remove("client_id"); -- 2.54.0 From 192204000a0ffca91330e89b5f3bd2554563e6a5 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:55:30 -0700 Subject: [PATCH 10/35] Directory: check password hashes off the async workers, a few at a time Checking a password against a bcrypt, SHA-crypt, MD5-crypt or DES-crypt hash ran on the async worker threads that serve every connection, and Argon2, PBKDF2 and scrypt hashes ran on the blocking pool with no limit on how many at once. A flood of sign-in attempts against accounts with such hashes, which needs no account of one's own, could stall every protocol on the server or start hundreds of threads, each holding a memory-hard hash. Every slow hash is now checked or computed on the blocking pool, at most one per CPU at a time; the rest wait their turn. Plain digests (SHA, SSHA, MD5) are cheap and still checked inline. Test: on a single-threaded runtime, another task made no progress while a bcrypt hash was checked on main; it now runs during every slow hash. --- crates/directory/src/core/secret.rs | 160 ++++++++++++++++++++-------- 1 file changed, 113 insertions(+), 47 deletions(-) diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index f0d2c8fb..11933b53 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.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 argon2::Argon2; @@ -18,7 +20,8 @@ use sha1::Digest; use sha1::Sha1; use sha2::Sha256; use sha2::Sha512; -use tokio::sync::oneshot; +use std::sync::{Arc, LazyLock}; +use tokio::sync::Semaphore; use totp_rs::Totp; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,17 +75,58 @@ pub async fn verify_mfa_secret_hash( } } +/// inbuxa: slow password hashes run on the blocking pool, at most one per +/// CPU at a time, and the rest wait their turn. A flood of sign-ins then +/// neither stalls the async workers every connection is served on nor +/// starts hundreds of threads each holding a memory-hard hash. +static HASHERS: LazyLock> = LazyLock::new(|| { + Arc::new(Semaphore::new( + std::thread::available_parallelism().map_or(4, |n| n.get()), + )) +}); + +async fn run_hasher( + hasher: impl FnOnce() -> T + Send + 'static, +) -> trc::Result { + let thread_error = |err: String| { + trc::EventType::Server(trc::ServerEvent::ThreadError) + .caused_by(trc::location!()) + .reason(err) + }; + let permit = HASHERS + .clone() + .acquire_owned() + .await + .map_err(|err| thread_error(err.to_string()))?; + tokio::task::spawn_blocking(move || { + let _permit = permit; + hasher() + }) + .await + .map_err(|err| thread_error(err.to_string())) +} + +/// inbuxa: checks a crypt(3)-style hash on the hashing pool. +async fn verify_crypt( + verify: fn(&[u8], &str) -> bool, + hashed_secret: &str, + secret: &[u8], +) -> trc::Result { + let secret = secret.to_vec(); + let hashed_secret = hashed_secret.to_string(); + run_hasher(move || verify(&secret, &hashed_secret)).await +} + async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { let is_argon = hashed_secret.starts_with("$argon2"); let is_pbkdf2 = !is_argon && hashed_secret.starts_with("$pbkdf2"); let is_scrypt = !is_argon && !is_pbkdf2 && hashed_secret.starts_with("$scrypt"); if is_argon || is_pbkdf2 || is_scrypt { - let (tx, rx) = oneshot::channel(); let secret = secret.to_vec(); let hashed_secret = hashed_secret.to_string(); - tokio::task::spawn_blocking(move || match PasswordHash::new(&hashed_secret) { + run_hasher(move || match PasswordHash::new(&hashed_secret) { Ok(hash) => { let result = if is_argon { Argon2::default().verify_password(&secret, &hash) @@ -92,37 +136,26 @@ async fn verify_hash_prefix(hashed_secret: &str, secret: &[u8]) -> trc::Result { - tx.send(Err(trc::AuthEvent::Error - .reason(err) - .details(hashed_secret))) - .ok(); - } - }); - - match rx.await { - Ok(result) => result, - Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) - .caused_by(trc::location!()) - .reason(err)), - } + Err(err) => Err(trc::AuthEvent::Error.reason(err).details(hashed_secret)), + }) + .await? } else if hashed_secret.starts_with("$2") { // Blowfish crypt - Ok(bcrypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| bcrypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$6$") { // SHA-512 crypt - Ok(sha512_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| sha512_crypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$5$") { // SHA-256 crypt - Ok(sha256_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| sha256_crypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$sha1") { // SHA-1 crypt - Ok(sha1_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| sha1_crypt::verify(s, h), hashed_secret, secret).await } else if hashed_secret.starts_with("$1") { // MD5 based hash - Ok(md5_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| md5_crypt::verify(s, h), hashed_secret, secret).await } else { Err(trc::AuthEvent::Error .into_err() @@ -135,7 +168,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu verify_hash_prefix(hashed_secret, secret).await } else if hashed_secret.starts_with('_') { // Enhanced DES-based hash - Ok(bsdi_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| bsdi_crypt::verify(s, h), hashed_secret, secret).await } else if let Some(hashed_secret) = hashed_secret.strip_prefix('{') { if let Some((algo, hashed_secret)) = hashed_secret.split_once('}') { match algo.to_ascii_uppercase().as_str() { @@ -222,7 +255,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu verify_hash_prefix(hashed_secret, secret).await } else { // Unix crypt - Ok(unix_crypt::verify(secret, hashed_secret)) + verify_crypt(|s, h| unix_crypt::verify(s, h), hashed_secret, secret).await } } "PLAIN" | "CLEAR" => Ok(hashed_secret.as_bytes() == secret), @@ -243,9 +276,7 @@ pub async fn verify_secret_hash(hashed_secret: &str, secret: &[u8]) -> trc::Resu } pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> trc::Result { - let (tx, rx) = oneshot::channel(); - - tokio::task::spawn_blocking(move || { + run_hasher(move || { let result = match algorithm { PasswordHashAlgorithm::Argon2id => { let hasher = Argon2::default(); @@ -254,14 +285,11 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> t .map(|h| h.to_string()) } PasswordHashAlgorithm::Bcrypt => { - return tx - .send(bcrypt::hash(secret.as_slice()).map_err(|err| { - trc::AuthEvent::Error - .reason(err) - .details("Bcrypt hash failed") - })) - .ok() - .unwrap_or(()); + return bcrypt::hash(secret.as_slice()).map_err(|err| { + trc::AuthEvent::Error + .reason(err) + .details("Bcrypt hash failed") + }); } PasswordHashAlgorithm::Scrypt => Scrypt::default() .hash_password(secret.as_slice()) @@ -271,20 +299,13 @@ pub async fn hash_secret(algorithm: PasswordHashAlgorithm, secret: Vec) -> t .map(|h| h.to_string()), }; - tx.send(result.map_err(|err| { + result.map_err(|err| { trc::AuthEvent::Error .reason(err) .details("Password hash failed") - })) - .ok(); - }); - - match rx.await { - Ok(result) => result, - Err(err) => Err(trc::EventType::Server(trc::ServerEvent::ThreadError) - .caused_by(trc::location!()) - .reason(err)), - } + }) + }) + .await? } pub fn is_password_hash(s: &str) -> bool { @@ -447,6 +468,51 @@ mod tests { String::from_utf8(Base64Encoder::new().encode(bytes).unwrap()).unwrap() } + // inbuxa: a slow hash is checked off the async workers, so a flood of + // sign-ins can't stall every other connection served on them + #[tokio::test(flavor = "current_thread")] + #[allow(deprecated)] // old schemes, as imported accounts still have them + async fn slow_hashes_do_not_block_the_executor() { + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + + let hashes = [ + bcrypt::hash_with("$2b$04$abcdefghijklmnopqrstuu", "secret").unwrap(), + sha512_crypt::hash("secret").unwrap(), + sha256_crypt::hash("secret").unwrap(), + sha1_crypt::hash("secret").unwrap(), + md5_crypt::hash("secret").unwrap(), + bsdi_crypt::hash("secret").unwrap(), + format!("{{CRYPT}}{}", unix_crypt::hash("secret").unwrap()), + Argon2::default() + .hash_password(b"secret") + .unwrap() + .to_string(), + ]; + + let ticks = Arc::new(AtomicUsize::new(0)); + let ticker = tokio::spawn({ + let ticks = ticks.clone(); + async move { + loop { + ticks.fetch_add(1, Ordering::Relaxed); + tokio::task::yield_now().await; + } + } + }); + for hash in &hashes { + let before = ticks.load(Ordering::Relaxed); + assert!(verify_secret_hash(hash, b"secret").await.unwrap(), "{hash}"); + assert!( + ticks.load(Ordering::Relaxed) > before, + "checking {hash} blocked the executor" + ); + } + ticker.abort(); + } + #[test] fn is_password_hash_detects_phc_strings() { let argon = Argon2::default() -- 2.54.0 From dadcc369443d3f4cb2afcbef4fc0167bdab94554 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 17:55:30 -0700 Subject: [PATCH 11/35] OIDC: sign in only with access tokens, not ID or refresh tokens The OpenID Connect directory accepted any JWT the provider signed. An ID token is minted for whichever client the user signed in to, so anyone who signed in to another application at the same provider could present that application's ID token here and be signed in as the user it names. A refresh token presented as a bearer token was likewise not told apart. A JWT the provider marks as an ID, refresh, offline or logout token (Keycloak's typ claim, Amazon Cognito's token_use) is now refused. Unmarked tokens are still accepted; requiring an audience is what stops an unmarked ID token, and stays the administrator's setting. Test: a signed JWT with typ "ID" (or "Refresh", "Offline", or token_use "id" or "refresh") signed in on main; it is now refused, and access tokens still sign in. --- crates/directory/src/backend/oidc/lookup.rs | 146 ++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/directory/src/backend/oidc/lookup.rs b/crates/directory/src/backend/oidc/lookup.rs index 464c9dad..1f78b4fb 100644 --- a/crates/directory/src/backend/oidc/lookup.rs +++ b/crates/directory/src/backend/oidc/lookup.rs @@ -84,6 +84,7 @@ impl OpenIdDirectory { continue; } + check_token_type(&token_data.claims)?; self.validate_scopes(&token_data.claims)?; let mut claims = token_data.claims; @@ -431,6 +432,37 @@ fn extract_string_list(value: &serde_json::Value) -> Option> { } } +/// inbuxa: only an access token signs in. A token the provider marks as an +/// ID token was minted for whichever client the user signed in to, and a +/// refresh token is never presented as a bearer token. Keycloak marks its +/// tokens with `typ`, Amazon Cognito with `token_use`. +fn check_token_type(claims: &serde_json::Value) -> Result<(), OidcError> { + let marker = |claim: &str| claims.get(claim).and_then(|v| v.as_str()); + let refused = match (marker("typ"), marker("token_use")) { + (Some(typ), _) + if ["id", "refresh", "offline", "logout"] + .iter() + .any(|kind| typ.eq_ignore_ascii_case(kind)) => + { + Some(typ) + } + (_, Some(token_use)) + if ["id", "refresh"] + .iter() + .any(|kind| token_use.eq_ignore_ascii_case(kind)) => + { + Some(token_use) + } + _ => None, + }; + match refused { + Some(kind) => Err(OidcError::TokenValidation(format!( + "Not an access token (marked {kind:?})" + ))), + None => Ok(()), + } +} + #[inline(always)] fn is_claim_missing(claims: &serde_json::Value, claim: Option<&String>) -> bool { claim.is_some_and(|claim| claims.get(claim).is_none_or(serde_json::Value::is_null)) @@ -441,6 +473,120 @@ mod tests { use super::*; use serde_json::json; + // A key for these tests only + const TEST_KEY: &str = "-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIGtahTxccpNyO2c/+k3fa0Opl10EO4gp0Iw8XAM2hUeL +-----END PRIVATE KEY-----"; + const TEST_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAXNXjqSjw780GFXxFztdFBUMpxRoFqsYWN55FXnvncCE= +-----END PUBLIC KEY-----"; + + fn test_directory() -> OpenIdDirectory { + let mut keys = AHashMap::new(); + keys.insert( + "test".to_string(), + Arc::new(CachedKey { + decoding_key: DecodingKey::from_ed_pem(TEST_PUBLIC_KEY.as_bytes()).unwrap(), + algorithm: Algorithm::EdDSA, + }), + ); + OpenIdDirectory { + config: crate::backend::oidc::OidcConfig { + issue_url: "https://idp.example.org".to_string(), + require_aud: None, + require_scopes: Vec::new(), + claim_email: "email".to_string(), + claim_name: None, + claim_groups: None, + default_domain: None, + }, + discovery: crate::backend::oidc::OidcDiscovery { + url: "https://idp.example.org".to_string(), + document: crate::backend::oidc::DiscoveryDocument { + issuer: "https://idp.example.org".to_string(), + jwks_uri: "https://idp.example.org/jwks".to_string(), + userinfo_endpoint: "https://idp.example.org/userinfo".to_string(), + token_endpoint: "https://idp.example.org/token".to_string(), + authorization_endpoint: "https://idp.example.org/auth".to_string(), + end_session_endpoint: None, + scopes_supported: None, + claims_supported: None, + code_challenge_methods_supported: None, + }, + }, + http: Client::new(), + cache: tokio::sync::RwLock::new(crate::backend::oidc::JwksCache { + keys, + last_updated: Instant::now(), + }), + } + } + + fn signed(claims: serde_json::Value) -> String { + let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA); + header.kid = Some("test".to_string()); + let mut claims = claims; + claims["iss"] = json!("https://idp.example.org"); + claims["exp"] = json!( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 300 + ); + claims["email"] = json!("john@example.org"); + jsonwebtoken::encode( + &header, + &claims, + &jsonwebtoken::EncodingKey::from_ed_pem(TEST_KEY.as_bytes()).unwrap(), + ) + .unwrap() + } + + async fn sign_in(directory: &OpenIdDirectory, token: String) -> trc::Result { + directory + .authenticate(&Credentials::Bearer { + username: None, + token, + }) + .await + } + + // inbuxa: a token the provider marks as an ID or refresh token is not an + // access token: an ID token is minted for whichever client signed in + #[tokio::test] + async fn only_access_tokens_sign_in() { + let directory = test_directory(); + + for claims in [ + json!({}), + json!({"typ": "Bearer"}), + json!({"token_use": "access"}), + ] { + assert_eq!( + sign_in(&directory, signed(claims.clone())) + .await + .map(|account| account.email) + .ok() + .as_deref(), + Some("john@example.org"), + "{claims}" + ); + } + for claims in [ + json!({"typ": "ID"}), + json!({"typ": "Refresh"}), + json!({"typ": "Offline"}), + json!({"token_use": "id"}), + json!({"token_use": "refresh"}), + ] { + assert!( + sign_in(&directory, signed(claims.clone())).await.is_err(), + "{claims} must not sign in" + ); + } + } + #[test] fn extract_string_list_distinguishes_absent_from_empty() { assert_eq!( -- 2.54.0 From 9511ce2a0a657f12e3c5c5d60f3571592ce76d28 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:11:56 -0700 Subject: [PATCH 12/35] JMAP: deliver web push only to public addresses A push subscription URL was refused only when its host was a local or reserved IP literal. A host name that resolved to 127.0.0.1, a private range or 169.254.169.254 passed, so any signed-in user could make the server send the verification request, and every later push, to a service inside its own network. Push requests now connect only to public addresses. Host names are checked when they resolve, which also covers redirects and a name that changes its answer after the subscription is created, and push never goes through a configured proxy. The shared check is for every request whose destination outsiders choose. Test: a subscription to https://localhost:/push got its verification request on the local listener on main; now nothing connects. --- crates/common/src/network/mod.rs | 1 + crates/common/src/network/public_only.rs | 125 ++++++++++++++++++++++ crates/services/src/state_manager/http.rs | 9 +- tests/src/jmap/core/push_subscription.rs | 25 +++++ 4 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 crates/common/src/network/public_only.rs diff --git a/crates/common/src/network/mod.rs b/crates/common/src/network/mod.rs index 1d61319e..23194074 100644 --- a/crates/common/src/network/mod.rs +++ b/crates/common/src/network/mod.rs @@ -42,6 +42,7 @@ pub mod legacy; pub mod limiter; pub mod listen; pub mod mta; +pub mod public_only; pub mod security; pub mod stream; pub mod tls; diff --git a/crates/common/src/network/public_only.rs b/crates/common/src/network/public_only.rs new file mode 100644 index 00000000..567be0a7 --- /dev/null +++ b/crates/common/src/network/public_only.rs @@ -0,0 +1,125 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! HTTP requests to URLs that outsiders choose (push subscription endpoints, +//! TLS-RPT `rua=` URIs, MTA-STS policy hosts, URL shorteners found in mail) +//! may only reach public addresses. A host name is checked when it resolves, +//! so a name that answers with a loopback, private or link-local address +//! can't reach inside the network, and neither can a redirect or a later +//! re-resolution. IP literals don't resolve, so callers check them with +//! [`has_public_host`] before sending. + +use super::is_global_ip; +use reqwest::{ + ClientBuilder, Url, + dns::{Addrs, Name, Resolve, Resolving}, +}; +use std::net::IpAddr; + +pub trait PublicOnly { + /// Connect only to public addresses, and never through a proxy (which + /// would resolve the name itself, out of our sight). + fn public_only(self) -> Self; +} + +impl PublicOnly for ClientBuilder { + fn public_only(self) -> Self { + self.no_proxy().dns_resolver(PublicResolver) + } +} + +struct PublicResolver; + +impl Resolve for PublicResolver { + fn resolve(&self, name: Name) -> Resolving { + Box::pin(async move { + let addrs = tokio::net::lookup_host((name.as_str(), 0)) + .await? + .filter(|addr| is_global_ip(&addr.ip())) + .collect::>(); + + if !addrs.is_empty() { + Ok(Box::new(addrs.into_iter()) as Addrs) + } else { + Err(format!("{} has no public address", name.as_str()).into()) + } + }) + } +} + +/// False when the URL's host is an IP literal that isn't public, or is +/// missing. Host names pass here and are checked when they resolve. +pub fn has_public_host(url: &str) -> bool { + let Some(host) = Url::parse(url) + .ok() + .and_then(|url| url.host_str().map(str::to_string)) + else { + return false; + }; + let host = host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(&host); + + match host.parse::() { + // The test harness serves push and reports on 127.0.0.1; + // loopback host names are still refused when they resolve. + #[cfg(feature = "test_mode")] + Ok(ip) if ip.is_loopback() => true, + Ok(ip) => is_global_ip(&ip), + Err(_) => true, + } +} + +#[cfg(test)] +mod tests { + use super::{PublicOnly, has_public_host}; + use std::time::Duration; + + #[test] + fn ip_literal_hosts() { + for url in [ + "https://127.0.0.1/push", + "https://10.1.2.3:8443/", + "https://169.254.169.254/latest/meta-data/", + "https://[::1]/", + "https://[::ffff:192.168.0.1]/", + "https://[fd00::1]/", + "not a url", + ] { + assert!(!has_public_host(url), "{url}"); + } + + for url in [ + "https://8.8.8.8/", + "https://[2606:4700::1111]/", + "https://push.example.com/", + "https://localhost/", + ] { + assert!(has_public_host(url), "{url}"); + } + } + + #[tokio::test] + async fn names_resolving_to_local_addresses_are_not_reached() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let client = reqwest::Client::builder().public_only().build().unwrap(); + + let result = client + .get(format!("http://localhost:{port}/")) + .timeout(Duration::from_secs(5)) + .send() + .await; + assert!(result.is_err(), "{result:?}"); + assert!( + tokio::time::timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err(), + "a connection reached the loopback listener" + ); + } +} diff --git a/crates/services/src/state_manager/http.rs b/crates/services/src/state_manager/http.rs index 39987ba1..d0c261c1 100644 --- a/crates/services/src/state_manager/http.rs +++ b/crates/services/src/state_manager/http.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 super::{ @@ -11,7 +13,11 @@ use super::{ }; use crate::state_manager::{PushBatch, PushRegistration}; use calcard::jscalendar::JSCalendarDateTime; -use common::{Server, ipc::PushNotification, network::webpush::Vapid}; +use common::{ + Server, + ipc::PushNotification, + network::{public_only::PublicOnly, webpush::Vapid}, +}; use email::push::{PushSubscription, Urgency}; use jmap_proto::{ object::email::{EmailProperty, EmailValue}, @@ -265,6 +271,7 @@ pub(crate) fn build_push_client() -> Client { } _ => attempt.stop(), })) + .public_only() .build() .unwrap_or_default() } diff --git a/tests/src/jmap/core/push_subscription.rs b/tests/src/jmap/core/push_subscription.rs index 538e4f0f..10d21bd0 100644 --- a/tests/src/jmap/core/push_subscription.rs +++ b/tests/src/jmap/core/push_subscription.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::{ @@ -377,6 +379,29 @@ pub async fn test(test: &TestServer) { ) .await; + // inbuxa: a host name that resolves to a local address is never + // contacted, not even for the verification request. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local_id = client + .push_subscription_create( + "local", + &format!( + "https://localhost:{}/push?skip_checks=true", + listener.local_addr().unwrap().port() + ), + None, + ) + .await + .unwrap() + .take_id(); + assert!( + tokio::time::timeout(Duration::from_secs(2), listener.accept()) + .await + .is_err(), + "the push verification was sent to a local address" + ); + client.push_subscription_destroy(&local_id).await.unwrap(); + // Test the EmailPush object builder (filters and size limits) directly test_email_push_object(test).await; -- 2.54.0 From 8ff158c76c71a15b20515619835007f63f04f65e Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:12:06 -0700 Subject: [PATCH 13/35] SMTP: send TLS reports and fetch MTA-STS policies only from public addresses The https address in a domain's TLS-RPT record was posted to as given, following up to ten redirects, and the MTA-STS policy host was fetched wherever its name resolved. Anyone who controls DNS for a domain this server delivers to could point either at loopback or a private range and make the server send requests into its own network, and a 307 or 308 redirect turned the report into a POST to any URL. Both now connect only to public addresses, checked when the name resolves, and follow no redirects. A report address that is a local IP literal is skipped and the report goes to the record's next address. A domain whose MTA-STS host resolves only to private addresses now has its policy fetch fail, as if the host were unreachable. Test: a TLS-RPT record with rua=https://localhost:/tls reached the local listener on main; now nothing connects and the report goes to the mailto address. --- crates/common/src/config/smtp/mod.rs | 15 ++++++++++-- crates/smtp/src/reporting/tls.rs | 12 ++++++++++ tests/src/smtp/reporting/tls.rs | 36 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/crates/common/src/config/smtp/mod.rs b/crates/common/src/config/smtp/mod.rs index 3268d084..78673614 100644 --- a/crates/common/src/config/smtp/mod.rs +++ b/crates/common/src/config/smtp/mod.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. */ pub mod auth; @@ -14,7 +16,9 @@ use self::{ auth::MailAuthConfig, queue::QueueConfig, report::ReportConfig, resolver::Resolvers, session::SessionConfig, }; -use crate::{config::smtp::queue::RequireOptional, expr::if_block::IfBlock}; +use crate::{ + config::smtp::queue::RequireOptional, expr::if_block::IfBlock, network::public_only::PublicOnly, +}; use registry::{ schema::{properties::ObjectType, structs::Rate}, types::id::ObjectId, @@ -60,13 +64,20 @@ impl SmtpConfig { resolvers: Resolvers::parse(bp).await, mail_auth: MailAuthConfig::parse(bp).await, report: ReportConfig::parse(bp).await, + // Both fetch from hosts the remote domain names in DNS mta_sts_client: utils::http::http_client_builder(false) .pool_max_idle_per_host(0) .user_agent(crate::USER_AGENT) .redirect(reqwest::redirect::Policy::none()) + .public_only() + .build() + .unwrap_or_default(), + tls_report_client: utils::http::http_client_builder(false) + .pool_max_idle_per_host(0) + .redirect(reqwest::redirect::Policy::none()) + .public_only() .build() .unwrap_or_default(), - tls_report_client: utils::http::unpooled_http_client(false), }; if !config.resolvers.dnssec_available diff --git a/crates/smtp/src/reporting/tls.rs b/crates/smtp/src/reporting/tls.rs index f489e438..1cb4b102 100644 --- a/crates/smtp/src/reporting/tls.rs +++ b/crates/smtp/src/reporting/tls.rs @@ -19,6 +19,7 @@ use common::{ resolver::{Mode, MxPattern, TlsaMatching}, }, ipc::{TlsEvent, ToHash}, + network::public_only::has_public_host, }; use mail_auth::{ flate2::{Compression, write::GzEncoder}, @@ -151,6 +152,17 @@ impl TlsReporting for Server { return Ok(()); } + // inbuxa: host names are checked when they resolve + if !has_public_host(uri) { + trc::event!( + OutgoingReport(OutgoingReportEvent::SubmissionError), + SpanId = span_id, + Url = uri.to_string(), + Details = "Report URI is not a public address" + ); + continue; + } + match self .core .smtp diff --git a/tests/src/smtp/reporting/tls.rs b/tests/src/smtp/reporting/tls.rs index 742b2d5e..fa0ef1ca 100644 --- a/tests/src/smtp/reporting/tls.rs +++ b/tests/src/smtp/reporting/tls.rs @@ -294,5 +294,41 @@ async fn report_tls() { ["reports@foobar.org"] ); + // inbuxa: a rua host name that resolves to a local address is never + // contacted; the report goes to the mailto address instead. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let tls_record = Arc::new( + TlsRpt::parse( + format!("v=TLSRPTv1;rua=https://localhost:{port}/tls,mailto:reports@foobar.org") + .as_bytes(), + ) + .unwrap(), + ); + test.server + .schedule_tls(Box::new(TlsEvent { + domain: "foobar.org".to_string(), + policy: common::ipc::PolicyType::None, + failure: None, + tls_record, + interval: AggregateFrequency::Daily, + span_id: 0, + })) + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + let reports = test.read_report_events::().await; + assert_eq!(reports.len(), 1); + test.server + .send_tls_aggregate_report(reports.first().unwrap().0.id()) + .await + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(200), listener.accept()) + .await + .is_err(), + "the TLS report was sent to a local address" + ); + test.expect_message().await; + test.assert_report_is_empty::().await; } -- 2.54.0 From 45c83f2b1ed8bb3ce5c79b00792128131e80c8a7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:12:06 -0700 Subject: [PATCH 14/35] Spam filter: look up URL redirectors over verified https, ten per message For every link to a known URL shortener in a message, the spam filter made up to three requests to follow it, with no limit per message, so one message full of shortener links made the server issue hundreds of sequential requests with a 5 second timeout each. The lookups skipped certificate checks and followed plain http links as given. A message now causes at most ten redirector lookups. Lookups are made over https with the certificate verified, a plain http link is looked up at its https address, and requests reach only public addresses. Test: a message with fifteen shortener links resolved all fifteen on main (30 URLs analyzed); now ten are resolved (25), and a plain http shortener link is still followed. --- .../common/src/config/mailstore/spamfilter.rs | 7 +++- crates/spam-filter/src/analysis/url.rs | 35 +++++++++++++++++-- tests/resources/smtp/antispam/url.test | 27 ++++++++++++++ tests/src/smtp/inbound/antispam.rs | 8 +++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/crates/common/src/config/mailstore/spamfilter.rs b/crates/common/src/config/mailstore/spamfilter.rs index 01c1989a..e8194acc 100644 --- a/crates/common/src/config/mailstore/spamfilter.rs +++ b/crates/common/src/config/mailstore/spamfilter.rs @@ -11,6 +11,7 @@ use crate::expr::{ functions::ResolveVariable, if_block::{BootstrapExprExt, IfBlock}, }; +use crate::network::public_only::PublicOnly; use ahash::AHashSet; use mail_auth::common::resolver::ToReverseName; use nlp::classifier::model::{CcfhClassifier, FhClassifier}; @@ -248,9 +249,13 @@ impl SpamFilterConfig { grey_list_expiry: spam.greylist_for.map(|d| d.into_inner().as_secs()), // inbuxa: unset, empty or upstream's old default means the bundled rules spam_rules_url: crate::manager::spam_rules::rules_url(spam.spam_filter_rules_url), - url_client: utils::http::http_client_builder(true) + // inbuxa: redirectors are looked up over verified https, at + // public addresses only + url_client: utils::http::http_client_builder(false) .pool_max_idle_per_host(0) .redirect(reqwest::redirect::Policy::none()) + .https_only(true) + .public_only() .user_agent("Mozilla/5.0 (X11; Linux i686; rv:109.0) Gecko/20100101 Firefox/118.0") .build() .unwrap_or_default(), diff --git a/crates/spam-filter/src/analysis/url.rs b/crates/spam-filter/src/analysis/url.rs index b55e7c60..8d65f9ca 100644 --- a/crates/spam-filter/src/analysis/url.rs +++ b/crates/spam-filter/src/analysis/url.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 super::{ElementLocation, is_trusted_domain, is_url_redirector}; @@ -14,6 +16,7 @@ use crate::{ }; use common::Server; use common::config::mailstore::spamfilter::{Element, IpResolver, Location}; +use common::network::public_only::has_public_host; use common::scripts::IsMixedCharset; use common::scripts::functions::unicode::CharUtils; use hyper::{Uri, header::LOCATION}; @@ -24,6 +27,9 @@ use std::{borrow::Cow, future::Future, time::Duration}; const HTTPS_SCHEME: &str = "https://"; +// inbuxa: the most redirector lookups one message can cause +const MAX_REDIRECT_LOOKUPS: usize = 10; + pub trait SpamFilterAnalyzeUrl: Sync + Send { fn spam_filter_analyze_url( &self, @@ -148,6 +154,7 @@ impl SpamFilterAnalyzeUrl for Server { if !ctx.input.is_train { let mut redirected_urls = HashSet::new(); let mut trusted_domains: HashSet = HashSet::new(); + let mut redirect_lookups = 0; { let mut checked_domains: HashSet<&str> = HashSet::new(); @@ -205,10 +212,11 @@ impl SpamFilterAnalyzeUrl for Server { let mut redirect_count = 1; let mut url_redirect = url.element.request_url(); - while redirect_count <= 3 { + while redirect_count <= 3 && redirect_lookups < MAX_REDIRECT_LOOKUPS { + redirect_lookups += 1; match http_get_header( self, - url_redirect.as_ref(), + &https_url(&url_redirect), LOCATION, Duration::from_secs(5), ) @@ -333,12 +341,24 @@ async fn http_get_header( ) -> trc::Result> { #[cfg(feature = "test_mode")] { + // Like the real client, which only speaks https + if !url.starts_with(HTTPS_SCHEME) { + return Err(trc::SieveEvent::RuntimeError + .into_err() + .details("Redirector lookups are https only")); + } + return if url.contains("redirect.") { Ok(url.split_once("/?").unwrap().1.to_string().into()) } else { Ok(None) }; } + // inbuxa: host names are checked when they resolve + if !has_public_host(url) { + return Ok(None); + } + server .core .spam @@ -362,6 +382,17 @@ async fn http_get_header( }) } +/// Redirectors are asked over https only, so a plain http link is looked up +/// at its https address. +fn https_url(url: &str) -> Cow<'_, str> { + match url.get(..7) { + Some(scheme) if scheme.eq_ignore_ascii_case("http://") => { + Cow::Owned([HTTPS_SCHEME, &url[7..]].concat()) + } + _ => Cow::Borrowed(url), + } +} + fn collect_url( urls: &mut HashSet>>, inferred_urls: &mut HashSet>>, diff --git a/tests/resources/smtp/antispam/url.test b/tests/resources/smtp/antispam/url.test index 9aa2dc2b..b9b0b8c0 100644 --- a/tests/resources/smtp/antispam/url.test +++ b/tests/resources/smtp/antispam/url.test @@ -46,6 +46,33 @@ Subject: redirect to omograph login to https://www.redirect.com/?https://xn--twiter-507b.com +expect REDIRECTOR_URL HOMOGRAPH_URL + +Subject: plain http redirect to omograph + +login to http://www.redirect.com/?https://xn--twiter-507b.com + +expect REDIRECTOR_URL +expect_urls 25 + +Subject: many redirects + +https://redirect.com/?https://site1.org/ +https://redirect.com/?https://site2.org/ +https://redirect.com/?https://site3.org/ +https://redirect.com/?https://site4.org/ +https://redirect.com/?https://site5.org/ +https://redirect.com/?https://site6.org/ +https://redirect.com/?https://site7.org/ +https://redirect.com/?https://site8.org/ +https://redirect.com/?https://site9.org/ +https://redirect.com/?https://site10.org/ +https://redirect.com/?https://site11.org/ +https://redirect.com/?https://site12.org/ +https://redirect.com/?https://site13.org/ +https://redirect.com/?https://site14.org/ +https://redirect.com/?https://site15.org/ + expect HAS_ONION_URI HAS_ANON_DOMAIN Subject: url in title darkweb.onion/login diff --git a/tests/src/smtp/inbound/antispam.rs b/tests/src/smtp/inbound/antispam.rs index 39ad6219..7a2a3a1b 100644 --- a/tests/src/smtp/inbound/antispam.rs +++ b/tests/src/smtp/inbound/antispam.rs @@ -376,6 +376,7 @@ async fn antispam() { let mut expect_headers = String::new(); let mut body_params = 0; let mut is_tls = false; + let mut expect_urls = None; for line in lines.by_ref() { if in_params { @@ -489,6 +490,9 @@ async fn antispam() { "tls.version" => { is_tls = true; } + "expect_urls" => { + expect_urls = Some(value.parse::().unwrap()); + } _ => panic!("Invalid parameter {param:?}"), } } else { @@ -679,6 +683,10 @@ async fn antispam() { _ => panic!("Invalid test {test_name:?}"), } + if let Some(expect_urls) = expect_urls { + assert_eq!(spam_ctx.output.urls.len(), expect_urls); + } + // Compare tags if spam_ctx.result.tags != expected_tags { for tag in &spam_ctx.result.tags { -- 2.54.0 From ae844c4ccc045666d467a9186dbb6d905f1d65b4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:42:54 -0700 Subject: [PATCH 15/35] SMTP: grow a BDAT chunk's buffer as its bytes arrive A BDAT command made the server reserve memory for the whole chunk size the client declared, before any of the chunk had arrived. Any client that got as far as RCPT could send "BDAT " and stall, holding that much memory per connection, and with the message size limit turned off a huge size could abort the process when the allocation failed. Only the bytes already received are reserved now, and the buffer grows as the rest of the chunk comes in, still within the message size limit. Test: "BDAT 268435456" with ten bytes sent reserved 268435456 bytes on main; now the buffer stays under 1 MiB. --- crates/smtp/src/inbound/session.rs | 8 +++----- tests/src/smtp/inbound/data.rs | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/smtp/src/inbound/session.rs b/crates/smtp/src/inbound/session.rs index 084fba6b..cae37532 100644 --- a/crates/smtp/src/inbound/session.rs +++ b/crates/smtp/src/inbound/session.rs @@ -79,11 +79,9 @@ impl Session { } else if chunk_size.saturating_add(self.data.message.len()) < self.params.max_message_size { - if self.data.message.is_empty() { - self.data.message = Vec::with_capacity(chunk_size); - } else { - self.data.message.reserve(chunk_size); - } + // Reserve only what has already arrived, the buffer + // grows with the rest of the chunk as it is received + self.data.message.reserve(chunk_size.min(iter.len())); State::Bdat(BdatReceiver::new(chunk_size, is_last)) } else { trc::event!( diff --git a/tests/src/smtp/inbound/data.rs b/tests/src/smtp/inbound/data.rs index a9596b21..350359a7 100644 --- a/tests/src/smtp/inbound/data.rs +++ b/tests/src/smtp/inbound/data.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::{ @@ -255,6 +257,21 @@ async fn data() { .await; test.expect_message().await; + // A BDAT chunk size is not allocated before its bytes arrive + size_session.mail_from("bill@doe.org", "250").await; + size_session.rcpt_to("mike@test.com", "250").await; + size_session + .ingest(b"BDAT 268435456\r\n0123456789") + .await + .unwrap(); + assert!( + size_session.data.message.capacity() < 1024 * 1024, + "BDAT reserved {} bytes before they arrived", + size_session.data.message.capacity() + ); + size_session.reset(); + size_session.state = Default::default(); + // Send broken message session .send_message("john@doe.org", &["bill@foobar.org"], "invalid", "550 5.7.7") -- 2.54.0 From b880df6a052ec4ed6177c7dfc92c310f6d86cf07 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:42:54 -0700 Subject: [PATCH 16/35] SMTP: check DMARC for every domain in a From header naming several A From header listing addresses in more than one domain skipped DMARC entirely, as if no domain had a policy. Anyone sending to this server could get a message from a domain with p=reject accepted by adding a second address in another domain to its From header. Each distinct author domain is now checked, and the strictest outcome applies: a failure under the strictest policy wins, then any failure, then no result, then a pass. So the message is rejected or quarantined when any of its author domains asks for that, and reports and Authentication-Results follow the same domain. A From header naming one domain is checked as before. Test: a message with "From: joe@test.net, bill@example.com" failing example.com's p=reject was queued (250) on main; now it is rejected with 550 5.7.1. --- crates/smtp/src/inbound/data.rs | 72 ++++++++++++++++++++++++++++++--- tests/src/smtp/inbound/dmarc.rs | 26 ++++++++++++ 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/crates/smtp/src/inbound/data.rs b/crates/smtp/src/inbound/data.rs index 0e2bce08..c78f258d 100644 --- a/crates/smtp/src/inbound/data.rs +++ b/crates/smtp/src/inbound/data.rs @@ -30,8 +30,9 @@ use common::{ scripts::ScriptModification, }; use mail_auth::{ - AuthenticatedMessage, AuthenticationResults, Dkim2Result, DkimResult, DmarcResult, ReceivedSpf, - SpfOutput, SpfResult, + AuthenticatedMessage, AuthenticationResults, Dkim2Result, DkimResult, DmarcOutput, DmarcResult, + ReceivedSpf, SpfOutput, SpfResult, + common::to_a_label, common::{ crypto::Algorithm, headers::{Header, HeaderWriter}, @@ -373,15 +374,38 @@ impl Session { } }; let time = Instant::now(); - let dmarc_output = - self.server + + // inbuxa: a From header naming several domains is checked + // against each of them and the strictest result applies + // (RFC 7489, Section 6.6.1) + let author_domains = dmarc_author_domains(&auth_message); + let author_messages = if author_domains.len() > 1 { + author_domains + .iter() + .map(|domain| { + let mut message = auth_message.clone(); + message.from.retain(|address| { + address.rsplit_once('@').is_some_and(|(_, addr_domain)| { + to_a_label(addr_domain) == domain.as_str() + }) + }); + Cow::Owned(message) + }) + .collect::>() + } else { + vec![Cow::Borrowed(&auth_message)] + }; + let mut dmarc_output: Option = None; + for author_message in &author_messages { + let output = self + .server .core .smtp .resolvers .dns .verify_dmarc(self.server.inner.cache.build_auth_parameters( DmarcParameters { - message: &auth_message, + message: author_message.as_ref(), dkim_output: &dkim_output, dkim2_output: dkim2_output.as_ref(), rfc5321_mail_from_domain: if !mail_from.domain.is_empty() { @@ -393,6 +417,14 @@ impl Session { }, )) .await; + if dmarc_output + .as_ref() + .is_none_or(|current| dmarc_strictness(&output) > dmarc_strictness(current)) + { + dmarc_output = Some(output); + } + } + let dmarc_output = dmarc_output.unwrap_or_default(); let dmarc_result = dmarc_output.result(); let pass = dmarc_result == DmarcResult::Pass; @@ -1220,3 +1252,33 @@ fn parse_dkim2_dsn<'x, 'r>( None } } + +// inbuxa: the distinct author domains of the From header, as DMARC compares them +fn dmarc_author_domains(message: &AuthenticatedMessage<'_>) -> Vec { + let mut domains: Vec = Vec::new(); + for address in &message.from { + if let Some((_, domain)) = address.rsplit_once('@') { + let domain = to_a_label(domain); + if !domains.iter().any(|d| d.as_str() == domain) { + domains.push(domain.into_owned()); + } + } + } + domains +} + +// inbuxa: orders DMARC results so that a failure under the strictest policy +// wins, then a failure, then no result, then a pass +fn dmarc_strictness(output: &DmarcOutput) -> (u8, u8, u8) { + let policy = match output.policy() { + dmarc::Policy::Reject => 2, + dmarc::Policy::Quarantine => 1, + dmarc::Policy::None | dmarc::Policy::Unspecified => 0, + }; + match output.result() { + DmarcResult::Fail(_) => (2, policy, 1), + DmarcResult::TempError(_) => (2, policy, 0), + DmarcResult::PermError(_) | DmarcResult::None => (1, 0, 0), + DmarcResult::Pass => (0, 0, 0), + } +} diff --git a/tests/src/smtp/inbound/dmarc.rs b/tests/src/smtp/inbound/dmarc.rs index f29c9503..407331ba 100644 --- a/tests/src/smtp/inbound/dmarc.rs +++ b/tests/src/smtp/inbound/dmarc.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::{ @@ -480,4 +482,28 @@ async fn dmarc() { ) .await; test.assert_no_events(); + + // A From header naming several domains is checked against each of them + test.server.txt_add( + "test.net", + Spf::parse(b"v=spf1 -all").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + test.server.txt_add( + "_dmarc.example.com", + Dmarc::parse(b"v=DMARC1; p=reject; aspf=s; adkim=s;").unwrap(), + Instant::now() + Duration::from_secs(5), + ); + session + .send_message( + "joe@test.net", + &["jdoe@localdomain.org"], + concat!( + "From: joe@test.net, bill@example.com\r\n", + "To: jdoe@localdomain.org\r\n", + "Subject: test\r\n\r\ntest" + ), + "550 5.7.1", + ) + .await; } -- 2.54.0 From c142f95edf485d04f238daa52f19147968696dc4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:42:54 -0700 Subject: [PATCH 17/35] SMTP: keep a cached MTA-STS policy when a new one can't be fetched When a domain's MTA-STS TXT record announced a new policy id, the cached policy was dropped before the new one was fetched. If the fetch failed, delivery went ahead with no policy at all. An attacker on the network path who could spoof the TXT record and block the policy host could turn off MTA-STS for a domain and downgrade delivery to it. A policy still in the cache now stays in force until it expires when the new policy can't be fetched or parsed, as RFC 8461 asks. Test: after the TXT id changed and the policy fetch failed, the lookup returned an error on main; now it returns the cached policy. --- crates/smtp/src/outbound/mta_sts/lookup.rs | 88 +++++++++++++--------- tests/src/smtp/outbound/mta_sts.rs | 21 +++++- 2 files changed, 73 insertions(+), 36 deletions(-) diff --git a/crates/smtp/src/outbound/mta_sts/lookup.rs b/crates/smtp/src/outbound/mta_sts/lookup.rs index 73503d64..e9f3fd15 100644 --- a/crates/smtp/src/outbound/mta_sts/lookup.rs +++ b/crates/smtp/src/outbound/mta_sts/lookup.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 std::{fmt::Display, sync::Arc, time::Duration}; @@ -59,48 +61,64 @@ impl MtaStsLookup for Server { }; // Check if the policy has been cached - if let Some(value) = self.inner.cache.dns_mta_sts.get(domain) + let cached = self.inner.cache.dns_mta_sts.get(domain); + if let Some(value) = &cached && value.id == record.id { - return Ok(value); + return Ok(value.clone()); } - // Fetch policy - #[cfg(not(feature = "test_mode"))] - let bytes = self - .core - .smtp - .mta_sts_client - .get(format!("https://mta-sts.{domain}/.well-known/mta-sts.txt")) - .timeout(timeout) - .send() - .await? - .bytes_with_limit(MAX_POLICY_SIZE) - .await? - .ok_or_else(|| Error::InvalidPolicy("Policy too large".to_string()))?; - #[cfg(feature = "test_mode")] - let bytes = STS_TEST_POLICY.lock().clone(); - - // Parse policy - let policy = Arc::new(Policy::parse( - std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?, - record.id.clone(), - )?); - - self.inner.cache.dns_mta_sts.insert( - domain.into(), - policy.clone(), - Duration::from_secs(if (3600..31557600).contains(&policy.max_age) { - policy.max_age - } else { - 86400 - }), - ); - - Ok(policy) + // A new policy id whose policy can't be fetched keeps the cached + // policy in force until it expires (RFC 8461, Section 5.1) + match fetch_policy(self, domain, &record, timeout).await { + Ok(policy) => Ok(policy), + Err(err) => cached.ok_or(err), + } } } +#[allow(unused_variables)] +async fn fetch_policy( + server: &Server, + domain: &str, + record: &MtaSts, + timeout: Duration, +) -> Result, Error> { + // Fetch policy + #[cfg(not(feature = "test_mode"))] + let bytes = server + .core + .smtp + .mta_sts_client + .get(format!("https://mta-sts.{domain}/.well-known/mta-sts.txt")) + .timeout(timeout) + .send() + .await? + .bytes_with_limit(MAX_POLICY_SIZE) + .await? + .ok_or_else(|| Error::InvalidPolicy("Policy too large".to_string()))?; + #[cfg(feature = "test_mode")] + let bytes = STS_TEST_POLICY.lock().clone(); + + // Parse policy + let policy = Arc::new(Policy::parse( + std::str::from_utf8(&bytes).map_err(|err| Error::InvalidPolicy(err.to_string()))?, + record.id.clone(), + )?); + + server.inner.cache.dns_mta_sts.insert( + domain.into(), + policy.clone(), + Duration::from_secs(if (3600..31557600).contains(&policy.max_age) { + policy.max_age + } else { + 86400 + }), + ); + + Ok(policy) +} + impl From<&Error> for ResultType { fn from(err: &Error) -> Self { match &err { diff --git a/tests/src/smtp/outbound/mta_sts.rs b/tests/src/smtp/outbound/mta_sts.rs index 48feaa3c..a62da033 100644 --- a/tests/src/smtp/outbound/mta_sts.rs +++ b/tests/src/smtp/outbound/mta_sts.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::{ @@ -26,7 +28,10 @@ use registry::{ }, types::{map::Map, socketaddr::SocketAddr}, }; -use smtp::outbound::mta_sts::{lookup::STS_TEST_POLICY, parse::ParsePolicy}; +use smtp::outbound::mta_sts::{ + lookup::{MtaStsLookup, STS_TEST_POLICY}, + parse::ParsePolicy, +}; use std::{ str::FromStr, sync::Arc, @@ -264,6 +269,20 @@ async fn mta_sts_verify() { ) ); assert!(report.failure.is_none()); + + // A new policy id whose policy can't be fetched keeps the cached policy + local.server.txt_add( + "_mta-sts.foobar.org", + MtaSts::parse(b"v=STSv1; id=policy_rotated;").unwrap(), + Instant::now() + Duration::from_secs(10), + ); + STS_TEST_POLICY.lock().clear(); + let cached = local + .server + .lookup_mta_sts_policy("foobar.org", Duration::from_secs(5)) + .await + .expect("cached MTA-STS policy was dropped after a failed fetch"); + assert_eq!(cached.id, "policy_will_work"); } #[tokio::test] -- 2.54.0 From 30f214f9d107f9a43db2a1ad979902110454cddf Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:42:54 -0700 Subject: [PATCH 18/35] JMAP: remove every Bcc header from a submitted message EmailSubmission removed a single Bcc header before sending: the last one when the recipients came from the headers, the first one when the envelope named them. Its position was also stored in 16 bits, so a Bcc header longer than 64 KiB was only partly cut. A draft with two Bcc headers (from Email/import or IMAP APPEND) or a very long one sent its blind copy addresses to every recipient. Every Bcc header of the message is now located on a fresh parse of the message being sent and removed, whatever its size. Test: a message with two Bcc headers delivered "Bcc: hidden_one" to the recipients on main, and one with a Bcc header over 64 KiB delivered the tail of its address list; now neither has any Bcc address. --- crates/jmap/src/submission/set.rs | 51 +++++++++++++++-------- tests/src/jmap/mail/submission.rs | 69 +++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 17 deletions(-) diff --git a/crates/jmap/src/submission/set.rs b/crates/jmap/src/submission/set.rs index 81eed3b2..420f7f41 100644 --- a/crates/jmap/src/submission/set.rs +++ b/crates/jmap/src/submission/set.rs @@ -31,6 +31,7 @@ use jmap_proto::{ types::{date::UTCDate, state::State}, }; use jmap_tools::{Key, Map, Value}; +use mail_parser::{HeaderName, MessageParser}; use smtp::{ core::{Session, SessionData}, queue::spool::SmtpSpool, @@ -678,7 +679,6 @@ impl EmailSubmissionSet for Server { } // Add recipients to envelope if missing - let mut bcc_header = None; if rcpt_to.is_empty() { for header in metadata.contents[0].parts[0].headers.iter() { if matches!( @@ -687,9 +687,6 @@ impl EmailSubmissionSet for Server { | ArchivedMetadataHeaderName::Cc | ArchivedMetadataHeaderName::Bcc ) { - if matches!(header.name, ArchivedMetadataHeaderName::Bcc) { - bcc_header = Some(header); - } match &header.value { ArchivedMetadataHeaderValue::AddressList(addr) => { for address in addr.iter() { @@ -742,11 +739,6 @@ impl EmailSubmissionSet for Server { return Ok(Err(SetError::new(SetErrorType::NoRecipients) .with_description("No recipients found in email."))); } - } else { - bcc_header = metadata.contents[0].parts[0] - .headers - .iter() - .find(|header| matches!(header.name, ArchivedMetadataHeaderName::Bcc)); } // Update sendAt @@ -779,14 +771,8 @@ impl EmailSubmissionSet for Server { .with_description("Blob for email not found."))); }; - // Remove BCC header if present - if let Some(bcc_header) = bcc_header { - let mut new_message = Vec::with_capacity(message.len()); - let range = bcc_header.name_value_range(); - new_message.extend_from_slice(&message[..range.start]); - new_message.extend_from_slice(&message[range.end..]); - message = new_message; - } + // Remove BCC headers if present + message = strip_bcc_headers(message); // Begin local SMTP session let mut session = Session::::local( @@ -1003,3 +989,34 @@ fn rcpt_into_static(rcpt: RcptTo>) -> RcptTo> { flags: rcpt.flags, } } + +// inbuxa: removes every Bcc header of the message root, found on a fresh parse +// so that the offsets are exact whatever the size of the header +fn strip_bcc_headers(message: Vec) -> Vec { + let ranges = MessageParser::new() + .parse_headers(&message) + .and_then(|parsed| { + parsed.parts.first().map(|root| { + root.headers + .iter() + .filter(|header| header.name == HeaderName::Bcc) + .map(|header| header.offset_field as usize..header.offset_end as usize) + .collect::>() + }) + }) + .unwrap_or_default(); + if ranges.is_empty() { + return message; + } + + let mut stripped = Vec::with_capacity(message.len()); + let mut pos = 0; + for range in ranges { + if range.start >= pos && range.start <= range.end && range.end <= message.len() { + stripped.extend_from_slice(&message[pos..range.start]); + pos = range.end; + } + } + stripped.extend_from_slice(&message[pos..]); + stripped +} diff --git a/tests/src/jmap/mail/submission.rs b/tests/src/jmap/mail/submission.rs index 52827cd7..8c6f05ab 100644 --- a/tests/src/jmap/mail/submission.rs +++ b/tests/src/jmap/mail/submission.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::{ @@ -235,6 +237,73 @@ pub async fn test(test: &TestServer) { ) .await; + // Every Bcc header is removed, including repeated and very long ones + let long_bcc = (0..3500) + .map(|i| format!("hidden_{i}@remote.org")) + .collect::>() + .join(",\r\n "); + let bcc_body = format!( + "From: jdoe@example.com\r\nTo: jane_smith@remote.org\r\nBcc: {long_bcc}\r\nSubject: long bcc\r\n\r\ntest" + ); + assert!(long_bcc.len() > u16::MAX as usize); + let bcc_email_id = client + .email_import( + bcc_body.into_bytes(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + client + .email_submission_create_envelope( + &bcc_email_id, + &identity_id, + "jdoe@example.com", + ["jane_smith@remote.org"], + ) + .await + .unwrap(); + let delivered = expect_message_delivery(&mut smtp_rx).await; + if let Some(pos) = delivered.message.find("hidden_") { + panic!( + "Bcc leaked: [...{}...]", + &delivered.message[pos.saturating_sub(100)..(pos + 100).min(delivered.message.len())] + ); + } + assert!(delivered.message.contains("Subject: long bcc")); + + let bcc_body = concat!( + "From: jdoe@example.com\r\n", + "Bcc: hidden_one@remote.org\r\n", + "To: jane_smith@remote.org\r\n", + "Bcc: hidden_two@remote.org\r\n", + "Subject: two bccs\r\n\r\n", + "test" + ); + let bcc_email_id = client + .email_import( + bcc_body.as_bytes().to_vec(), + [&mailbox_id], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + client + .email_submission_create(&bcc_email_id, &identity_id) + .await + .unwrap(); + let delivered = expect_message_delivery(&mut smtp_rx).await; + assert!( + !delivered.message.contains("hidden_"), + "Bcc leaked: [{}]", + delivered.message + ); + assert!(delivered.message.contains("Subject: two bccs")); + // Manually add recipients to the envelope and confirm submission let email_submission_id = client .email_submission_create_envelope( -- 2.54.0 From 1c8c442c464f0050355dc0ad765d59f22b671df8 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 18:42:54 -0700 Subject: [PATCH 19/35] Calendar: take an iMIP sender from the signed-in account, not the From header Scheduling messages received by email were credited to the first From address whenever the sender was trusted, and a sender who had signed in to send was trusted whatever the From header said. Sign-in only binds the envelope sender to the account, so any local user could send an invitation, reply or cancellation in another person's name and change the recipient's calendar. The same From address also decided whether a message marked as spam came from a known contact. A message from a signed-in sender is now credited to its envelope sender, which the server already limits to the account's own addresses. The From header is used only when it passed DMARC, and otherwise nothing is processed as before. Test: a signed-in user sending an invitation with another user's From and organizer added the event to the recipient's calendar on main; now it is refused and the calendar is unchanged. --- crates/email/src/message/delivery.rs | 19 ++++- crates/email/src/message/ingest.rs | 105 ++++++++++++--------------- crates/email/src/sieve/ingest.rs | 9 ++- crates/http/src/form/mod.rs | 8 +- crates/smtp/src/outbound/local.rs | 18 ++++- tests/src/jmap/mail/thread_merge.rs | 10 ++- tests/src/webdav/cal_scheduling.rs | 65 +++++++++++++++++ 7 files changed, 162 insertions(+), 72 deletions(-) diff --git a/crates/email/src/message/delivery.rs b/crates/email/src/message/delivery.rs index 371e38ea..164a70be 100644 --- a/crates/email/src/message/delivery.rs +++ b/crates/email/src/message/delivery.rs @@ -24,10 +24,22 @@ use types::blob_hash::BlobHash; pub const ORCPT_ADDR_TYPE: &str = "rfc822;"; +// inbuxa: which sender identity of a delivered message has been verified +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VerifiedSender { + /// Neither the envelope sender nor the From header was verified + #[default] + None, + /// The From header domain passed DMARC + FromHeader, + /// The envelope sender signed in to submit the message + Envelope, +} + #[derive(Debug)] pub struct IngestMessage { pub sender_address: String, - pub sender_authenticated: bool, + pub verified_sender: VerifiedSender, pub recipients: Vec, pub message_blob: BlobHash, pub message_size: u64, @@ -266,7 +278,8 @@ impl MailDelivery for Server { received_at: None, source: IngestSource::Smtp { deliver_to: &rcpt.address, - is_sender_authenticated: message.sender_authenticated, + envelope_from: &message.sender_address, + verified_sender: message.verified_sender, is_spam: rcpt.is_spam(), }, session_id: message.session_id, @@ -279,7 +292,7 @@ impl MailDelivery for Server { message_blob, raw_message, &message.sender_address, - message.sender_authenticated, + message.verified_sender, &rcpt, message.session_id, active_script, diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index 2d2bc6ce..fd45befb 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.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 super::crypto::{EncryptMessage, EncryptMessageError}; @@ -10,6 +12,7 @@ use crate::{ mailbox::{INBOX_ID, JUNK_ID, SENT_ID, TRASH_ID, UidMailbox}, message::{ crypto::EncryptionFlags, + delivery::VerifiedSender, index::{IndexMessage, extractors::VisitText}, metadata::{MessageData, MessageMetadata}, }, @@ -80,7 +83,8 @@ pub struct IngestEmail<'x> { pub enum IngestSource<'x> { Smtp { deliver_to: &'x str, - is_sender_authenticated: bool, + envelope_from: &'x str, + verified_sender: VerifiedSender, is_spam: bool, }, Jmap { @@ -244,9 +248,23 @@ impl EmailIngest for Server { let is_spam = match params.source { IngestSource::Smtp { deliver_to, - is_sender_authenticated, + envelope_from, + verified_sender, mut is_spam, } => { + // inbuxa: a signed-in sender is known by its envelope address, + // which submission checks against the account; the From header + // is trusted only when it passed DMARC + let sender = match verified_sender { + VerifiedSender::Envelope => sanitize_email(envelope_from), + VerifiedSender::FromHeader => message + .from() + .and_then(|s| s.first()) + .and_then(|s| s.address()) + .and_then(sanitize_email), + VerifiedSender::None => None, + }; + // Add delivered to header if self.core.smtp.session.data.add_delivered_to { extra_headers = format!("Delivered-To: {deliver_to}\r\n"); @@ -266,13 +284,8 @@ impl EmailIngest for Server { // sender address is present in the user's address book. if is_spam && self.core.spam.card_is_ham - && let Some(sender) = message - .from() - .and_then(|s| s.first()) - .and_then(|s| s.address()) - .and_then(sanitize_email) + && let Some(sender) = &sender && sender != deliver_to - && is_sender_authenticated && self .document_exists( account_id, @@ -363,7 +376,7 @@ impl EmailIngest for Server { // iMIP processing if self.core.groupware.itip_enabled && !is_spam - && is_sender_authenticated + && let Some(sender) = &sender && params .access_token .has_permission(Permission::CalendarSchedulingReceive) @@ -372,7 +385,6 @@ impl EmailIngest for Server { .build_account_info(account.clone()) .await .caused_by(trc::location!())?; - let mut sender = None; for part in &message.parts { if part.content_type().is_some_and(|ct| { ct.ctype().eq_ignore_ascii_case("text") @@ -383,64 +395,43 @@ impl EmailIngest for Server { }) && let Some(itip_message) = part.text_contents() { if itip_message.len() < self.core.groupware.itip_inbound_max_ical_size { - if let Some(sender) = sender.get_or_insert_with(|| { - message - .from() - .and_then(|s| s.first()) - .and_then(|s| s.address()) - .and_then(sanitize_email) - }) { - match self - .itip_ingest( - &account_info, - sender, - deliver_to, - itip_message, - ) - .await - { - Ok(message) => { - if let Some(message) = message { - itip_messages.push(message); - } + match self + .itip_ingest(&account_info, sender, deliver_to, itip_message) + .await + { + Ok(message) => { + if let Some(message) = message { + itip_messages.push(message); + } + trc::event!( + Calendar(trc::CalendarEvent::ItipMessageReceived), + SpanId = params.session_id, + From = sender.to_string(), + AccountId = account_id, + ); + } + Err(ItipIngestError::Message(itip_error)) => match itip_error { + ItipError::NothingToSend + | ItipError::OtherSchedulingAgent => (), + err => { trc::event!( - Calendar(trc::CalendarEvent::ItipMessageReceived), + Calendar(trc::CalendarEvent::ItipMessageError), SpanId = params.session_id, From = sender.to_string(), AccountId = account_id, - ); - } - Err(ItipIngestError::Message(itip_error)) => { - match itip_error { - ItipError::NothingToSend - | ItipError::OtherSchedulingAgent => (), - err => { - trc::event!( - Calendar( - trc::CalendarEvent::ItipMessageError - ), - SpanId = params.session_id, - From = sender.to_string(), - AccountId = account_id, - Details = err.to_string(), - ) - } - } - } - Err(ItipIngestError::Internal(err)) => { - trc::error!(err.caused_by(trc::location!())); + Details = err.to_string(), + ) } + }, + Err(ItipIngestError::Internal(err)) => { + trc::error!(err.caused_by(trc::location!())); } } } else { trc::event!( Calendar(trc::CalendarEvent::ItipMessageError), SpanId = params.session_id, - From = message - .from() - .and_then(|a| a.first()) - .and_then(|a| a.address()) - .map(|a| a.to_string()), + From = sender.to_string(), AccountId = account_id, Details = "iMIP message too large", Limit = self.core.groupware.itip_inbound_max_ical_size, diff --git a/crates/email/src/sieve/ingest.rs b/crates/email/src/sieve/ingest.rs index 0980898a..a931c3df 100644 --- a/crates/email/src/sieve/ingest.rs +++ b/crates/email/src/sieve/ingest.rs @@ -11,7 +11,7 @@ use crate::{ cache::{MessageCacheFetch, mailbox::MailboxCacheAccess}, mailbox::{INBOX_ID, TRASH_ID, manage::MailboxFnc}, message::{ - delivery::{AutogeneratedMessage, IngestRecipient}, + delivery::{AutogeneratedMessage, IngestRecipient, VerifiedSender}, ingest::{EmailIngest, IngestEmail, IngestSource, IngestedEmail}, }, }; @@ -57,7 +57,7 @@ pub trait SieveScriptIngest: Sync + Send { blob_hash: &BlobHash, raw_message: &[u8], envelope_from: &str, - envelope_from_authenticated: bool, + verified_sender: VerifiedSender, envelope_to: &IngestRecipient, session_id: u64, active_script: ActiveScript, @@ -95,7 +95,7 @@ impl SieveScriptIngest for Server { blob_hash: &BlobHash, raw_message: &[u8], envelope_from: &str, - envelope_from_authenticated: bool, + verified_sender: VerifiedSender, envelope_to: &IngestRecipient, session_id: u64, active_script: ActiveScript, @@ -587,7 +587,8 @@ impl SieveScriptIngest for Server { received_at: None, source: IngestSource::Smtp { deliver_to: envelope_to.address.as_str(), - is_sender_authenticated: envelope_from_authenticated, + envelope_from, + verified_sender, is_spam: envelope_to.is_spam() && !sieve_message.did_file_into, }, session_id, diff --git a/crates/http/src/form/mod.rs b/crates/http/src/form/mod.rs index d727dfe9..cb5754a5 100644 --- a/crates/http/src/form/mod.rs +++ b/crates/http/src/form/mod.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::auth::oauth::FormData; @@ -12,7 +14,9 @@ use common::{ network::ip_to_bytes, psl, }; -use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use email::message::delivery::{ + IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery, VerifiedSender, +}; use http_proto::*; use hyper::StatusCode; use mail_auth::common::cache::NoCache; @@ -176,7 +180,7 @@ impl FormHandler for Server { for result in self .deliver_message(IngestMessage { sender_address: from_email, - sender_authenticated: false, + verified_sender: VerifiedSender::None, recipients: form .rcpt_to .iter() diff --git a/crates/smtp/src/outbound/local.rs b/crates/smtp/src/outbound/local.rs index bd38fc62..baff88ba 100644 --- a/crates/smtp/src/outbound/local.rs +++ b/crates/smtp/src/outbound/local.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::{ @@ -16,7 +18,9 @@ use crate::{ }, }; use common::Server; -use email::message::delivery::{IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery}; +use email::message::delivery::{ + IngestMessage, IngestRecipient, LocalDeliveryStatus, MailDelivery, VerifiedSender, +}; use smtp_proto::Response; use trc::SieveEvent; @@ -45,9 +49,15 @@ impl MessageWrapper { let delivery_result = server .deliver_message(IngestMessage { sender_address: self.message.return_path.to_string(), - sender_authenticated: self.message.flags - & (FROM_UNAUTHENTICATED_DMARC | FROM_AUTHENTICATED) - != 0, + // inbuxa: a signed-in sender is known by its envelope + // address, any other only by a From header that passed DMARC + verified_sender: if self.message.flags & FROM_AUTHENTICATED != 0 { + VerifiedSender::Envelope + } else if self.message.flags & FROM_UNAUTHENTICATED_DMARC != 0 { + VerifiedSender::FromHeader + } else { + VerifiedSender::None + }, recipients, message_blob: self.message.blob_hash.clone(), message_size: self.message.size, diff --git a/tests/src/jmap/mail/thread_merge.rs b/tests/src/jmap/mail/thread_merge.rs index 1a4f4617..b7ae5fab 100644 --- a/tests/src/jmap/mail/thread_merge.rs +++ b/tests/src/jmap/mail/thread_merge.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::{ @@ -11,7 +13,10 @@ use crate::{ use ::email::{ cache::MessageCacheFetch, mailbox::INBOX_ID, - message::ingest::{EmailIngest, IngestEmail, IngestSource}, + message::{ + delivery::VerifiedSender, + ingest::{EmailIngest, IngestEmail, IngestSource}, + }, }; use common::auth::AccessToken; use jmap_client::{email, mailbox::Role}; @@ -232,7 +237,8 @@ async fn test_multi_thread(test: &TestServer) { received_at: None, source: IngestSource::Smtp { deliver_to: "test@domain.org", - is_sender_authenticated: true, + envelope_from: "", + verified_sender: VerifiedSender::FromHeader, is_spam: false, }, session_id: 0, diff --git a/tests/src/webdav/cal_scheduling.rs b/tests/src/webdav/cal_scheduling.rs index a05ec662..9fdd2408 100644 --- a/tests/src/webdav/cal_scheduling.rs +++ b/tests/src/webdav/cal_scheduling.rs @@ -2,9 +2,12 @@ * 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::{ + smtp::session::TestSession, utils::{server::TestServer, webdav::DummyWebDavClient}, webdav::prop::ALL_DAV_PROPERTIES, }; @@ -929,6 +932,45 @@ pub async fn test(test: &TestServer) { Vec::::new() ); + // A signed-in user can't send an iMIP request in someone else's name + let jane_events = jane_client.fetch_icals().await.len(); + let mut session = test.new_mta_session(); + session.data.authenticated_as = Some( + test.server + .account_info(bill_client.account_id) + .await + .unwrap(), + ); + session.eval_session_params().await; + session.ehlo("mx.example.com").await; + session + .send_message( + "bill@example.com", + &["jane.smith@example.com"], + &TEST_IMIP_SPOOFED + .replace( + "$START", + &DateTime::from_timestamp(now() as i64 + 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ) + .replace( + "$END", + &DateTime::from_timestamp(now() as i64 + 2 * 60 * 60) + .to_rfc3339() + .replace(['-', ':'], ""), + ), + "250", + ) + .await; + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + test.wait_for_tasks().await; + let cals = jane_client.fetch_icals().await; + assert!( + cals.len() == jane_events && !cals.iter().any(|cal| cal.ical.contains("SPOOF9263504")), + "spoofed iMIP request was accepted: {cals:?}" + ); + for client in [bill_client, jane_client, john_client] { client.delete_default_containers().await; } @@ -1316,6 +1358,29 @@ END:VEVENT END:VCALENDAR "#; +const TEST_IMIP_SPOOFED: &str = "From: jdoe@example.com\r +To: jane.smith@example.com\r +Subject: Invitation\r +MIME-Version: 1.0\r +Content-Type: text/calendar; method=REQUEST; charset=utf-8\r +\r +BEGIN:VCALENDAR\r +VERSION:2.0\r +PRODID:-//Example Corp.//CalDAV Client//EN\r +METHOD:REQUEST\r +BEGIN:VEVENT\r +UID:SPOOF9263504\r +SEQUENCE:0\r +DTSTART:$START\r +DTEND:$END\r +DTSTAMP:20090602T170000Z\r +SUMMARY:Spoofed\r +ORGANIZER:mailto:jdoe@example.com\r +ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:jane.smith@example.com\r +END:VEVENT\r +END:VCALENDAR\r +"; + const TEST_ITIP_EXTERNAL: &str = r#"BEGIN:VCALENDAR VERSION:2.0 PRODID:-//Example Corp.//CalDAV Client//EN -- 2.54.0 From 6efa90cfc12c4e91eeea6441f46272707960acec Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 19:28:45 -0700 Subject: [PATCH 20/35] Calendar: answer iTIP REFRESH and REPLY only from the event's attendees An organizer's server sent the whole event, with every attendee, the description and the location, to anyone who sent a REFRESH with the event's UID, including attendees who had been removed and people who were never invited. A REPLY for an instance the organizer hadn't changed was stored as a new instance as long as the sender listed itself as an attendee of the reply, so a removed attendee or a stranger could add instances on any date, with any ORGANIZER parameters and any other ATTENDEE lines; those attendees would then get the organizer's next update. A REFRESH is now answered only when the sender is an attendee of the stored event. A REPLY for an unchanged instance needs the sender to be an attendee of the series and the date to be one of the series' own dates. The new instance takes its ORGANIZER, UID and SEQUENCE from the organizer's copy and the sender's own ATTENDEE line with the answer applied; only the people the sender delegated to are added from the reply. The recurring to-do test used a RECURRENCE-ID that is not a date of its series; it now uses the series' second date. Test: a REFRESH from someone never invited returned the full event on main, and a removed attendee's REPLY created an instance; a REPLY that added another attendee stored that attendee, and one for a date outside the series stored it. Now each is refused or ignored and the organizer's copy holds only the attendee's own answer. --- crates/groupware/src/calendar/itip.rs | 2 + crates/groupware/src/scheduling/inbound.rs | 256 +++++++++++++----- tests/resources/itip/itip_attendee_checks.txt | 167 ++++++++++++ tests/resources/itip/rfc5546_todo.txt | 8 +- tests/resources/itip/rfc6638_recurring.txt | 43 +-- tests/src/webdav/cal_itip.rs | 1 + 6 files changed, 382 insertions(+), 95 deletions(-) create mode 100644 tests/resources/itip/itip_attendee_checks.txt diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs index 301c7630..32f4109e 100644 --- a/crates/groupware/src/calendar/itip.rs +++ b/crates/groupware/src/calendar/itip.rs @@ -210,6 +210,7 @@ impl ItipIngest for Server { &itip, itip_snapshots, sender.to_string(), + self.core.groupware.max_ical_instances, )? { MergeResult::Actions(changes) => { commit_itip_merge( @@ -488,6 +489,7 @@ impl ItipIngest for Server { &reply, reply_snapshots, rsvp.attendee.clone(), + self.core.groupware.max_ical_instances, ) }); let changes = match merge { diff --git a/crates/groupware/src/scheduling/inbound.rs b/crates/groupware/src/scheduling/inbound.rs index 175d3829..e688ef5e 100644 --- a/crates/groupware/src/scheduling/inbound.rs +++ b/crates/groupware/src/scheduling/inbound.rs @@ -6,14 +6,21 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::scheduling::{ - InstanceId, ItipError, ItipMessage, ItipSnapshots, organizer::organizer_request_full, +use crate::{ + calendar::dates::has_oversized_rrule, + scheduling::{ + Attendee, InstanceId, ItipError, ItipMessage, ItipSnapshot, ItipSnapshots, + organizer::organizer_request_full, + }, }; use ahash::AHashSet; -use calcard::icalendar::{ - ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, - ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, ICalendarValue, - Uri, +use calcard::{ + common::timezone::Tz, + icalendar::{ + ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarMethod, + ICalendarParameter, ICalendarParameterName, ICalendarProperty, ICalendarStatus, + ICalendarValue, Uri, + }, }; #[derive(Debug)] @@ -56,6 +63,7 @@ pub fn itip_process_message( itip: &ICalendar, itip_snapshots: ItipSnapshots<'_>, sender: String, + max_instances: usize, ) -> Result { if snapshots.organizer.email != itip_snapshots.organizer.email { return Err(ItipError::OrganizerMismatch); @@ -71,9 +79,24 @@ pub fn itip_process_message( } match method { ICalendarMethod::Reply => { - handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?; + handle_reply( + ical, + &snapshots, + &itip_snapshots, + &sender, + max_instances, + &mut merge_actions, + )?; } ICalendarMethod::Refresh => { + // inbuxa: only a current attendee may ask for the event again + if !snapshots + .components + .values() + .any(|snapshot| snapshot.attendee_by_email(&sender).is_some()) + { + return Err(ItipError::SenderIsNotParticipant(sender)); + } return organizer_request_full(ical, &snapshots, None, false).and_then( |messages| { messages @@ -315,7 +338,14 @@ pub fn itip_process_message( }) }) => { - handle_reply(&snapshots, &itip_snapshots, &sender, &mut merge_actions)?; + handle_reply( + ical, + &snapshots, + &itip_snapshots, + &sender, + max_instances, + &mut merge_actions, + )?; } _ => return Err(ItipError::UnsupportedMethod(method.clone())), } @@ -351,11 +381,15 @@ pub fn itip_import_message(ical: &mut ICalendar) -> Result<(), ItipError> { } fn handle_reply( + ical: &ICalendar, snapshots: &ItipSnapshots<'_>, itip_snapshots: &ItipSnapshots<'_>, sender: &str, + max_instances: usize, merge_actions: &mut Vec, ) -> Result<(), ItipError> { + let mut occurrences = None; + for (instance_id, itip_snapshot) in &itip_snapshots.components { if let Some(snapshot) = snapshots.components.get(instance_id) { if let (Some(attendee), Some(updated_attendee)) = ( @@ -370,44 +404,8 @@ fn handle_reply( if changed_part_stat || changed_rsvp || changed_delegated_to || has_request_status { // Update participant status - let mut add_parameters = Vec::new(); - let mut remove_parameters = Vec::new(); - if changed_part_stat { - remove_parameters.push(ICalendarParameterName::Partstat); - if let Some(part_stat) = updated_attendee.part_stat { - add_parameters.push(ICalendarParameter::partstat(part_stat.clone())); - } - } - - if changed_rsvp { - remove_parameters.push(ICalendarParameterName::Rsvp); - if let Some(rsvp) = updated_attendee.rsvp { - add_parameters.push(ICalendarParameter::rsvp(rsvp)); - } - } - - if changed_delegated_to { - remove_parameters.push(ICalendarParameterName::DelegatedTo); - if !updated_attendee.delegated_to.is_empty() { - add_parameters.extend(updated_attendee.delegated_to.iter().map( - |email| { - ICalendarParameter::delegated_to(Uri::Location( - email.to_string(), - )) - }, - )); - } - } - - // RFC 6638 3.2.5: the status defaults to 2.0 when the reply carries none - remove_parameters.push(ICalendarParameterName::ScheduleStatus); - add_parameters.push(ICalendarParameter::schedule_status( - if has_request_status { - itip_snapshot.request_status.join(",") - } else { - "2.0".to_string() - }, - )); + let (remove_parameters, add_parameters) = + reply_parameters(attendee, updated_attendee, itip_snapshot); merge_actions.push(MergeAction::RemoveParameters { component_id: snapshot.comp_id, @@ -506,35 +504,81 @@ fn handle_reply( } else { return Err(ItipError::SenderIsNotParticipant(sender.to_string())); } - } else if itip_snapshot.attendee_by_email(sender).is_some() { - // Add component + } else if let (InstanceId::Recurrence(recurrence_id), Some(updated_attendee)) = + (instance_id, itip_snapshot.attendee_by_email(sender)) + { + // inbuxa: an answer for an instance the organizer hasn't changed must + // come from an attendee of the series and name one of its dates. The + // new instance keeps the organizer's own ORGANIZER, UID and SEQUENCE + // and takes only the sender's answer from the reply. + let Some(series) = snapshots.components.get(&InstanceId::Main) else { + continue; + }; + let Some(attendee) = series.attendee_by_email(sender) else { + return Err(ItipError::SenderIsNotParticipant(sender.to_string())); + }; + if !occurrences + .get_or_insert_with(|| series_occurrences(ical, series.comp_id, max_instances)) + .contains(&recurrence_id.date) + { + continue; + } + let itip_component = itip_snapshot.comp; let is_todo = itip_component.component_type == ICalendarComponentType::VTodo; + let (remove_parameters, add_parameters) = + reply_parameters(attendee, updated_attendee, itip_snapshot); + let mut attendee_entry = series.comp.entries[attendee.entry_id as usize].clone(); + attendee_entry + .params + .retain(|param| !remove_parameters.contains(¶m.name)); + attendee_entry.params.extend(add_parameters); + + let mut entries = series + .comp + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Organizer + | ICalendarProperty::Uid + | ICalendarProperty::Sequence + ) + }) + .cloned() + .chain([attendee_entry]) + .collect::>(); + entries.extend( + itip_component + .entries + .iter() + .filter(|entry| { + matches!( + entry.name, + ICalendarProperty::Dtstamp | ICalendarProperty::RecurrenceId + ) || (is_todo + && matches!( + entry.name, + ICalendarProperty::PercentComplete + | ICalendarProperty::Status + | ICalendarProperty::Completed + )) + }) + .cloned(), + ); + + // Add the attendees the sender delegated to + for delegated_to in &updated_attendee.delegated_to { + if let Some(itip_delegated) = itip_snapshot.attendee_by_email(&delegated_to.email) { + entries.push(itip_component.entries[itip_delegated.entry_id as usize].clone()); + } + } + merge_actions.push(MergeAction::AddComponent { component: ICalendarComponent { component_type: itip_component.component_type.clone(), - entries: itip_component - .entries - .iter() - .filter(|entry| { - matches!( - entry.name, - ICalendarProperty::Organizer - | ICalendarProperty::Attendee - | ICalendarProperty::Uid - | ICalendarProperty::Dtstamp - | ICalendarProperty::Sequence - | ICalendarProperty::RecurrenceId - ) || (is_todo - && matches!( - entry.name, - ICalendarProperty::PercentComplete - | ICalendarProperty::Status - | ICalendarProperty::Completed - )) - }) - .cloned() - .collect(), + entries, component_ids: vec![], }, }); @@ -546,6 +590,78 @@ fn handle_reply( Ok(()) } +fn reply_parameters( + attendee: &Attendee<'_>, + updated_attendee: &Attendee<'_>, + itip_snapshot: &ItipSnapshot<'_>, +) -> (Vec, Vec) { + let mut add_parameters = Vec::new(); + let mut remove_parameters = Vec::new(); + if attendee.part_stat != updated_attendee.part_stat { + remove_parameters.push(ICalendarParameterName::Partstat); + if let Some(part_stat) = updated_attendee.part_stat { + add_parameters.push(ICalendarParameter::partstat(part_stat.clone())); + } + } + + if attendee.rsvp != updated_attendee.rsvp { + remove_parameters.push(ICalendarParameterName::Rsvp); + if let Some(rsvp) = updated_attendee.rsvp { + add_parameters.push(ICalendarParameter::rsvp(rsvp)); + } + } + + if attendee.delegated_to != updated_attendee.delegated_to { + remove_parameters.push(ICalendarParameterName::DelegatedTo); + if !updated_attendee.delegated_to.is_empty() { + add_parameters.extend( + updated_attendee.delegated_to.iter().map(|email| { + ICalendarParameter::delegated_to(Uri::Location(email.to_string())) + }), + ); + } + } + + // RFC 6638 3.2.5: the status defaults to 2.0 when the reply carries none + remove_parameters.push(ICalendarParameterName::ScheduleStatus); + add_parameters.push(ICalendarParameter::schedule_status( + if !itip_snapshot.request_status.is_empty() { + itip_snapshot.request_status.join(",") + } else { + "2.0".to_string() + }, + )); + + (remove_parameters, add_parameters) +} + +// The start times of a series on its own, without the instances that override it +fn series_occurrences(ical: &ICalendar, main_comp_id: u16, max_instances: usize) -> AHashSet { + let mut series = ical.clone(); + let overrides = series + .components + .iter() + .enumerate() + .filter(|(comp_id, comp)| { + comp.component_type.is_scheduling_object() && *comp_id != main_comp_id as usize + }) + .map(|(comp_id, _)| comp_id as u32) + .collect::>(); + if !overrides.is_empty() { + series.remove_component_ids(&overrides); + } + if has_oversized_rrule(&series) { + return AHashSet::new(); + } + + series + .expand_dates(Tz::Floating, max_instances) + .events + .into_iter() + .map(|event| event.start.timestamp()) + .collect() +} + pub fn itip_merge_changes(ical: &mut ICalendar, itip: &ICalendar, changes: Vec) { let mut remove_component_ids: Vec = Vec::new(); for action in changes { diff --git a/tests/resources/itip/itip_attendee_checks.txt b/tests/resources/itip/itip_attendee_checks.txt new file mode 100644 index 00000000..51d5691e --- /dev/null +++ b/tests/resources/itip/itip_attendee_checks.txt @@ -0,0 +1,167 @@ +# iTIP: REFRESH and REPLY are accepted only from the event's attendees + +# Organizer invites two attendees to a three-day series (1) +> put a@example.com attendee-checks@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:0 +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +ATTENDEE;RSVP=TRUE:mailto:c@example.com +SUMMARY:Standup +DTSTART:20270104T180000Z +DTEND:20270104T183000Z +RRULE:FREQ=DAILY;COUNT=3 +DTSTAMP:20261027T120000Z +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR + +# Organizer invites two attendees to a three-day series (2) +> send + +# Someone who was never invited asks for the event +> itip x@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REFRESH +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +ORGANIZER:mailto:a@example.com +ATTENDEE:mailto:x@example.com +DTSTAMP:20261027T130000Z +END:VEVENT +END:VCALENDAR + +> send +SenderIsNotParticipant("x@example.com") + +# Organizer removes the second attendee (1) +> put a@example.com attendee-checks@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +ORGANIZER:mailto:a@example.com +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +SUMMARY:Standup +DTSTART:20270104T180000Z +DTEND:20270104T183000Z +RRULE:FREQ=DAILY;COUNT=3 +DTSTAMP:20261027T130000Z +STATUS:CONFIRMED +END:VEVENT +END:VCALENDAR + +# Organizer removes the second attendee (2) +> send + +# The removed attendee asks for the event again +> itip c@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REFRESH +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +ORGANIZER:mailto:a@example.com +ATTENDEE:mailto:c@example.com +DTSTAMP:20261027T140000Z +END:VEVENT +END:VCALENDAR + +> send +SenderIsNotParticipant("c@example.com") + +# The removed attendee replies to an instance the organizer hasn't changed +> itip c@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +RECURRENCE-ID:20270105T180000Z +ORGANIZER:mailto:a@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:c@example.com +DTSTAMP:20261027T150000Z +END:VEVENT +END:VCALENDAR + +> send +SenderIsNotParticipant("c@example.com") + +# An attendee replies to one instance and adds a stranger to it +> itip b@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +RECURRENCE-ID:20270105T180000Z +ORGANIZER;CN="Not A":mailto:a@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:b@example.com +ATTENDEE;PARTSTAT=ACCEPTED:mailto:mallory@example.org +DTSTAMP:20261027T160000Z +END:VEVENT +END:VCALENDAR + +> send + +# An attendee replies to a date the series doesn't have +> itip b@example.com a@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +METHOD:REPLY +VERSION:2.0 +BEGIN:VEVENT +UID:attendee-checks@example.com +SEQUENCE:1 +RECURRENCE-ID:20270110T180000Z +ORGANIZER:mailto:a@example.com +ATTENDEE;PARTSTAT=DECLINED:mailto:b@example.com +DTSTAMP:20261027T170000Z +END:VEVENT +END:VCALENDAR + +> send + +# Only the attendee's own answer reaches the organizer's copy +> get a@example.com attendee-checks@example.com +BEGIN:VCALENDAR +PRODID:-//Example/ExampleCalendarClient//EN +VERSION:2.0 +BEGIN:VEVENT +ATTENDEE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:b@example.com +ORGANIZER:mailto:a@example.com +RECURRENCE-ID:20270105T180000Z +UID:attendee-checks@example.com +DTSTAMP:1 +SEQUENCE:2 +END:VEVENT +BEGIN:VEVENT +STATUS:CONFIRMED +SUMMARY:Standup +DTEND:20270104T183000Z +DTSTART:20270104T180000Z +ATTENDEE;ROLE=CHAIR;PARTSTAT=ACCEPTED:mailto:a@example.com +ATTENDEE;RSVP=TRUE:mailto:b@example.com +ORGANIZER:mailto:a@example.com +UID:attendee-checks@example.com +RRULE:FREQ=DAILY;COUNT=3 +DTSTAMP:0 +SEQUENCE:2 +END:VEVENT +END:VCALENDAR + diff --git a/tests/resources/itip/rfc5546_todo.txt b/tests/resources/itip/rfc5546_todo.txt index 29b747de..4b6a60e7 100644 --- a/tests/resources/itip/rfc5546_todo.txt +++ b/tests/resources/itip/rfc5546_todo.txt @@ -436,7 +436,7 @@ ATTENDEE;PARTSTAT=IN-PROCESS:mailto:b@example.com PERCENT-COMPLETE:75 UID:calsrv.example.com-873970198738777-00@example.com DTSTAMP:19970717T233000Z -RECURRENCE-ID:19980101T170000Z +RECURRENCE-ID:19980102T100000Z SEQUENCE:1 END:VTODO END:VCALENDAR @@ -457,7 +457,7 @@ BEGIN:VTODO PERCENT-COMPLETE:75 ATTENDEE;PARTSTAT=IN-PROCESS:mailto:b@example.com ORGANIZER:mailto:a@example.com -RECURRENCE-ID:19980101T170000Z +RECURRENCE-ID:19980102T100000Z UID:calsrv.example.com-873970198738777-00@example.com DTSTAMP:0 SEQUENCE:1 @@ -489,9 +489,9 @@ SEQUENCE:1 END:VTODO BEGIN:VTODO PERCENT-COMPLETE:75 -ATTENDEE;PARTSTAT=IN-PROCESS:mailto:b@example.com +ATTENDEE;CUTYPE=INDIVIDUAL;PARTSTAT=IN-PROCESS;SCHEDULE-STATUS=2.0:mailto:b@example.com ORGANIZER:mailto:a@example.com -RECURRENCE-ID:19980101T170000Z +RECURRENCE-ID:19980102T100000Z UID:calsrv.example.com-873970198738777-00@example.com DTSTAMP:0 SEQUENCE:1 diff --git a/tests/resources/itip/rfc6638_recurring.txt b/tests/resources/itip/rfc6638_recurring.txt index cfdfac35..b3bc644c 100644 --- a/tests/resources/itip/rfc6638_recurring.txt +++ b/tests/resources/itip/rfc6638_recurring.txt @@ -240,6 +240,15 @@ BEGIN:VCALENDAR PRODID:-//Example Corp.//CalDAV Client//EN VERSION:2.0 BEGIN:VEVENT +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + RSVP=TRUE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +UID:9263504FD3AD +DTSTAMP:0 +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT SUMMARY:Review Internet-Draft DTEND;TZID=America/Montreal:20090601T160000 DTSTART;TZID=America/Montreal:20090601T150000 @@ -254,15 +263,6 @@ RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 DTSTAMP:1 SEQUENCE:0 END:VEVENT -BEGIN:VEVENT -ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE - =REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net -ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com -RECURRENCE-ID;TZID=America/Montreal:20090602T150000 -UID:9263504FD3AD -DTSTAMP:0 -SEQUENCE:1 -END:VEVENT BEGIN:VTIMEZONE TZID:America/Montreal BEGIN:STANDARD @@ -384,12 +384,22 @@ BEGIN:VCALENDAR PRODID:-//Example Corp.//CalDAV Client//EN VERSION:2.0 BEGIN:VEVENT -ATTENDEE;PARTSTAT=DECLINED:mailto:bernard@example.net -ORGANIZER:mailto:cyrus@example.com +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com RECURRENCE-ID;TZID=America/Montreal:20090603T150000 UID:9263504FD3AD DTSTAMP:0 -SEQUENCE:1 +SEQUENCE:0 +END:VEVENT +BEGIN:VEVENT +ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT; + RSVP=TRUE;PARTSTAT=DECLINED;SCHEDULE-STATUS=2.0:mailto:bernard@example.net +ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com +RECURRENCE-ID;TZID=America/Montreal:20090602T150000 +UID:9263504FD3AD +DTSTAMP:0 +SEQUENCE:0 END:VEVENT BEGIN:VEVENT SUMMARY:Review Internet-Draft @@ -406,15 +416,6 @@ RRULE:FREQ=DAILY;COUNT=5;INTERVAL=1 DTSTAMP:1 SEQUENCE:0 END:VEVENT -BEGIN:VEVENT -ATTENDEE;CN="Bernard Desruisseaux";CUTYPE=INDIVIDUAL;PARTSTAT=DECLINED;ROLE - =REQ-PARTICIPANT;RSVP=TRUE:mailto:bernard@example.net -ORGANIZER;CN="Cyrus Daboo":mailto:cyrus@example.com -RECURRENCE-ID;TZID=America/Montreal:20090602T150000 -UID:9263504FD3AD -DTSTAMP:0 -SEQUENCE:1 -END:VEVENT BEGIN:VTIMEZONE TZID:America/Montreal BEGIN:STANDARD diff --git a/tests/src/webdav/cal_itip.rs b/tests/src/webdav/cal_itip.rs index 0c6aaaff..60c7c788 100644 --- a/tests/src/webdav/cal_itip.rs +++ b/tests/src/webdav/cal_itip.rs @@ -276,6 +276,7 @@ pub fn test() { &message.message, itip_snapshots, message.from.clone(), + 100, ) { Ok(result) => match result { MergeResult::Actions(changes) => { -- 2.54.0 From 4527b494da8129d642eec023b7e85a8223d4cbcc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 19:30:53 -0700 Subject: [PATCH 21/35] Sharing: share a calendar's events and an address book's contacts only Calendars and their events are numbered separately, and so are address books and their contacts. The list of items a sharee may read added every event or contact whose number matched a shared calendar or address book, so sharing one calendar also exposed an unrelated event in a private calendar, and the same for contacts. A sharee could read, copy, list and download that item through JMAP, and find it through CalDAV, CardDAV and free-busy queries. Only files carry their own sharing and share their numbering with folders, so that match now applies to files alone. Events and contacts are shared through the calendar or address book that holds them. Test: a sharee of one calendar read a private event that had the shared calendar's id on main; now the event is not found and query does not list it. --- crates/common/src/sharing/resources.rs | 8 ++- tests/src/jmap/calendar/acl.rs | 83 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index 74ee8fee..a52007df 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -49,8 +49,14 @@ impl DavResources { if !shared_containers.is_empty() { let mut document_ids = RoaringBitmap::new(); + // inbuxa: only files carry their own ACLs and share an id space with + // their folders. Calendar events and contacts are shared through + // their containers alone. for resource in &self.resources { - if !resource.is_container() && shared_containers.contains(resource.document_id) { + if !resource.is_container() + && resource.acls().is_some() + && shared_containers.contains(resource.document_id) + { document_ids.insert(resource.document_id); } } diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs index c76f49d0..e57be70e 100644 --- a/tests/src/jmap/calendar/acl.rs +++ b/tests/src/jmap/calendar/acl.rs @@ -754,6 +754,89 @@ pub async fn test(test: &TestServer) { [john_calendar_id.as_str()] ); + // inbuxa: sharing a calendar shares its events only. An event in a private + // calendar whose id happens to equal the shared calendar's id stays private. + let private_calendar_id = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Private", + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let shared_calendar_id = john + .jmap_create( + MethodObject::Calendar, + [json!({ + "name": "Shared", + "shareWith": { + &jane_id : { + "mayReadItems": true, + } + } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let mut private_event_id = None; + for num in 0..20 { + let event_id = john + .jmap_create( + MethodObject::CalendarEvent, + [json!({ + "uid": format!("private-event-{num}@example.com"), + "title": "John's private event", + "start": "2020-01-15T13:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &private_calendar_id: true + }, + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + if event_id == shared_calendar_id { + private_event_id = Some(event_id); + break; + } + } + let private_event_id = private_event_id.expect("no event got the shared calendar's id"); + assert_eq!( + jane.jmap_get_account( + john, + MethodObject::CalendarEvent, + [JSCalendarProperty::::Id, JSCalendarProperty::Title], + [private_event_id.as_str()], + ) + .await + .not_found() + .collect::>(), + [private_event_id.as_str()] + ); + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + }), + ) + .await; + assert!( + !response.ids().any(|id| id == private_event_id), + "private event listed to a sharee: {response:?}" + ); + // Destroy all mailboxes john.destroy_all_calendars().await; jane.destroy_all_calendars().await; -- 2.54.0 From 84a7667316b184121cc42137fdeac220fd9f6837 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 19:40:12 -0700 Subject: [PATCH 22/35] Calendar: keep private and secret events and hidden attendees from sharees A calendar event's privacy and its hideAttendees setting were stored and reported but never applied. Anyone a calendar was shared with saw secret events, the full details of private events, and every attendee of events meant to show only their owners, through CalendarEvent/get, query, copy, changes and blob download. For anyone but the calendar's owner, a secret event (CLASS:CONFIDENTIAL) now doesn't exist. A private event shows only its time, recurrence and basic metadata; matching it on title, description, location or participants finds nothing, and copying it out is refused. An event that hides its attendees shows only its owners and the reader, unless the reader may change every event in its calendar; matching on its participants finds nothing for others. Its blob, like a private event's, is not downloadable by sharees. Test: a sharee read a secret event, the title of a private event, and a hidden attendee on main; now the secret event is not found, the private one has its time only, it can't be found by title or copied, and only the event's owner is listed as a participant. --- crates/common/src/lib.rs | 7 + crates/common/src/sharing/resources.rs | 28 +++- crates/common/src/storage/dav.rs | 10 ++ crates/groupware/src/cache/calcard.rs | 5 +- crates/groupware/src/calendar/mod.rs | 3 + crates/groupware/src/calendar/privacy.rs | 152 ++++++++++++++++++ crates/jmap/src/blob/download.rs | 12 +- crates/jmap/src/calendar_event/copy.rs | 41 ++++- crates/jmap/src/calendar_event/get.rs | 61 ++++---- crates/jmap/src/calendar_event/query.rs | 50 +++++- crates/jmap/src/changes/get.rs | 2 +- tests/src/jmap/calendar/acl.rs | 191 +++++++++++++++++++++++ 12 files changed, 518 insertions(+), 44 deletions(-) create mode 100644 crates/groupware/src/calendar/privacy.rs diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index d3ede7e1..48a911a4 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -380,6 +380,8 @@ pub enum DavResourceMetadata { names: TinyVec<[DavName; 2]>, start: i64, duration: u32, + // inbuxa: EVENT_VIEW_* flags + view: u8, }, CalendarEventNotification { names: TinyVec<[DavName; 2]>, @@ -393,6 +395,11 @@ pub enum DavResourceMetadata { }, } +// inbuxa: what a sharee may see of a calendar event +pub const EVENT_VIEW_PRIVATE: u8 = 1; +pub const EVENT_VIEW_SECRET: u8 = 1 << 1; +pub const EVENT_VIEW_HIDE_ATTENDEES: u8 = 1 << 2; + #[derive(Debug, Clone, Default)] pub struct TinyCalendarPreferences { pub account_id: u32, diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index a52007df..f6cc2029 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -6,7 +6,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{DavResources, auth::AccessToken}; +use crate::{DavResources, EVENT_VIEW_SECRET, auth::AccessToken}; use store::roaring::RoaringBitmap; use types::acl::Acl; use utils::map::bitmap::Bitmap; @@ -75,6 +75,32 @@ impl DavResources { } } + /// inbuxa: the shared items a sharee may see, without the calendar events + /// whose view has any of `hidden` (EVENT_VIEW_*). Secret events are always + /// left out. + pub fn shared_events( + &self, + access_token: &AccessToken, + check_acls: impl IntoIterator, + match_any: bool, + hidden: u8, + ) -> RoaringBitmap { + let mut document_ids = self.shared_items(access_token, check_acls, match_any); + if !document_ids.is_empty() { + document_ids -= self.events_with_view(hidden | EVENT_VIEW_SECRET); + } + document_ids + } + + /// inbuxa: the calendar events whose view has any of `view` (EVENT_VIEW_*) + pub fn events_with_view(&self, view: u8) -> RoaringBitmap { + self.resources + .iter() + .filter(|resource| resource.event_view() & view != 0) + .map(|resource| resource.document_id) + .collect() + } + pub fn shared_documents( &self, access_token: &AccessToken, diff --git a/crates/common/src/storage/dav.rs b/crates/common/src/storage/dav.rs index c36eeed5..86673314 100644 --- a/crates/common/src/storage/dav.rs +++ b/crates/common/src/storage/dav.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::{ @@ -309,6 +311,14 @@ impl DavResource { } } + // inbuxa: EVENT_VIEW_* flags of a calendar event + pub fn event_view(&self) -> u8 { + match &self.data { + DavResourceMetadata::CalendarEvent { view, .. } => *view, + _ => 0, + } + } + pub fn calendar_preferences(&self, account_id: u32) -> Option<&TinyCalendarPreferences> { match &self.data { DavResourceMetadata::Calendar { preferences, .. } => preferences diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index f9dbe14c..b8b598e1 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.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 super::GroupwareCache; @@ -9,7 +11,7 @@ use crate::{ DavResourceName, RFC_3986, calendar::{ ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent, SCHEDULE_INBOX_ID, - SCHEDULE_OUTBOX_ID, storage::ItipAutoExpunge, + SCHEDULE_OUTBOX_ID, privacy::archived_event_view, storage::ItipAutoExpunge, }, contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, encode_path_segment, @@ -319,6 +321,7 @@ pub(super) fn resource_from_event(event: &ArchivedCalendarEvent, document_id: u3 .collect(), start, duration, + view: archived_event_view(event), }, } } diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 424689b1..0ade961c 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.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. */ pub mod alarm; @@ -9,6 +11,7 @@ pub mod dates; pub mod expand; pub mod index; pub mod itip; +pub mod privacy; pub mod storage; use calcard::icalendar::{ diff --git a/crates/groupware/src/calendar/privacy.rs b/crates/groupware/src/calendar/privacy.rs new file mode 100644 index 00000000..e6bc84a8 --- /dev/null +++ b/crates/groupware/src/calendar/privacy.rs @@ -0,0 +1,152 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! What a sharee of a calendar may see of an event: nothing of a secret +//! (CLASS:CONFIDENTIAL) event, the time only of a private one, and only the +//! owners and themselves among the attendees of an event that hides them. + +use super::{ArchivedCalendarEvent, CalendarEvent, EVENT_HIDE_ATTENDEES}; +use calcard::icalendar::{ + ArchivedICalendarClassification, ArchivedICalendarProperty, ArchivedICalendarValue, ICalendar, + ICalendarClassification, ICalendarComponentType, ICalendarParameterName, + ICalendarParameterValue, ICalendarParticipationRole, ICalendarProperty, ICalendarValue, +}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, EVENT_VIEW_SECRET}; + +pub fn event_view(event: &CalendarEvent) -> u8 { + let mut view = 0; + for entry in event + .data + .event + .components + .iter() + .filter(|comp| comp.component_type.is_scheduling_object()) + .flat_map(|comp| comp.entries.iter()) + .filter(|entry| entry.name == ICalendarProperty::Class) + { + view |= match entry.values.first() { + Some(ICalendarValue::Classification(ICalendarClassification::Public)) | None => 0, + Some(ICalendarValue::Classification(ICalendarClassification::Confidential)) => { + EVENT_VIEW_SECRET + } + // An unknown class counts as private + _ => EVENT_VIEW_PRIVATE, + }; + } + if event.flags & EVENT_HIDE_ATTENDEES != 0 { + view |= EVENT_VIEW_HIDE_ATTENDEES; + } + view +} + +pub fn archived_event_view(event: &ArchivedCalendarEvent) -> u8 { + let mut view = 0; + for entry in event + .data + .event + .components + .iter() + .filter(|comp| comp.component_type.is_scheduling_object()) + .flat_map(|comp| comp.entries.iter()) + .filter(|entry| matches!(entry.name, ArchivedICalendarProperty::Class)) + { + view |= match entry.values.first() { + Some(ArchivedICalendarValue::Classification( + ArchivedICalendarClassification::Public, + )) + | None => 0, + Some(ArchivedICalendarValue::Classification( + ArchivedICalendarClassification::Confidential, + )) => EVENT_VIEW_SECRET, + _ => EVENT_VIEW_PRIVATE, + }; + } + if event.flags.to_native() & EVENT_HIDE_ATTENDEES != 0 { + view |= EVENT_VIEW_HIDE_ATTENDEES; + } + view +} + +/// Keeps only the time and basic metadata of a private event (RFC 8984, +/// section 4.4.3), with its recurrence so its occurrences stay right. +pub fn redact_private_event(ical: &mut ICalendar) { + let mut remove_ids = Vec::new(); + for (comp_id, comp) in ical.components.iter_mut().enumerate() { + match comp.component_type { + ICalendarComponentType::VCalendar => { + comp.entries.retain(|entry| { + matches!( + entry.name, + ICalendarProperty::Prodid + | ICalendarProperty::Version + | ICalendarProperty::Calscale + ) + }); + } + ICalendarComponentType::VTimezone + | ICalendarComponentType::Standard + | ICalendarComponentType::Daylight => {} + ref component_type if component_type.is_scheduling_object() => { + comp.entries.retain(|entry| { + matches!( + entry.name, + ICalendarProperty::Dtstart + | ICalendarProperty::Dtend + | ICalendarProperty::Duration + | ICalendarProperty::Due + | ICalendarProperty::Rrule + | ICalendarProperty::Rdate + | ICalendarProperty::Exdate + | ICalendarProperty::Exrule + | ICalendarProperty::RecurrenceId + | ICalendarProperty::Uid + | ICalendarProperty::Sequence + | ICalendarProperty::Dtstamp + | ICalendarProperty::Created + | ICalendarProperty::LastModified + | ICalendarProperty::Class + | ICalendarProperty::Transp + ) + }); + for entry in &mut comp.entries { + entry.params.retain(|param| { + matches!( + param.name, + ICalendarParameterName::Tzid + | ICalendarParameterName::Value + | ICalendarParameterName::Range + ) + }); + } + } + _ => remove_ids.push(comp_id as u32), + } + } + if !remove_ids.is_empty() { + ical.remove_component_ids(&remove_ids); + } +} + +/// Keeps only the attendees with the owner role and those at `addresses` +pub fn hide_attendees(ical: &mut ICalendar, addresses: &[String]) { + for component in &mut ical.components { + if component.component_type.is_scheduling_object() { + component.entries.retain(|entry| match &entry.name { + ICalendarProperty::Attendee => { + entry.parameters(&ICalendarParameterName::Role).any(|role| { + matches!( + role, + ICalendarParameterValue::Role(ICalendarParticipationRole::Owner,) + ) + }) || entry + .calendar_address() + .is_some_and(|addr| addresses.iter().any(|a| a.eq_ignore_ascii_case(addr))) + } + _ => true, + }); + } + } +} diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index a88882a1..8a6021f8 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -6,7 +6,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use common::{Server, auth::AccessToken}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; use email::cache::MessageCacheFetch; use email::cache::email::MessageCacheAccess; use email::message::metadata::MessageMetadata; @@ -139,7 +139,15 @@ impl BlobDownload for Server { ) .await .caused_by(trc::location!())? - .shared_items(access_token, [Acl::ReadItems], true) + // inbuxa: a calendar event's blob holds all of + // it, so a sharee gets none of a private event + // or one that hides its attendees + .shared_events( + access_token, + [Acl::ReadItems], + true, + EVENT_VIEW_PRIVATE | EVENT_VIEW_HIDE_ATTENDEES, + ) .contains(*document_id), _ => false, } diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index a5932ae4..e1070044 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -11,8 +11,14 @@ use crate::{ changes::state::JmapCacheState, }; use calcard::jscalendar::JSCalendarProperty; -use common::{Server, auth::AccessToken}; -use groupware::{cache::GroupwareCache, calendar::CalendarEvent}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; +use groupware::{ + cache::GroupwareCache, + calendar::{ + CalendarEvent, + privacy::{event_view, hide_attendees}, + }, +}; use http_proto::HttpSessionData; use jmap_proto::{ error::set::SetError, @@ -92,11 +98,13 @@ impl JmapCalendarEventCopy for Server { ) .await .caused_by(trc::location!())?; - let from_calendar_event_ids = if access_token.reads_as_owner(from_account_id) { + let is_owner = access_token.reads_as_owner(from_account_id); + let from_calendar_event_ids = if is_owner { from_cache.document_ids(false).collect::() } else { - from_cache.shared_items(access_token, [Acl::ReadItems], true) + from_cache.shared_events(access_token, [Acl::ReadItems], true, 0) }; + let mut writable_ids = None; let can_add_calendars = if access_token.is_shared(account_id) { cache @@ -161,10 +169,33 @@ impl JmapCalendarEventCopy for Server { continue; }; - let calendar_event = _calendar_event + let mut calendar_event = _calendar_event .deserialize::() .caused_by(trc::location!())?; + // inbuxa: a sharee can't copy out what it may not see + if !is_owner { + let view = event_view(&calendar_event); + if view & EVENT_VIEW_PRIVATE != 0 { + response.not_created.append( + id, + SetError::forbidden().with_description( + "Private events can't be copied from a shared calendar.", + ), + ); + continue; + } + if view & EVENT_VIEW_HIDE_ATTENDEES != 0 + && !writable_ids + .get_or_insert_with(|| { + from_cache.shared_items(access_token, [Acl::ModifyItems], true) + }) + .contains(from_calendar_event_id) + { + hide_attendees(&mut calendar_event.data.event, account_info.addresses()); + } + } + match self .create_calendar_event( &cache, diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index f866331f..72677c87 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -11,20 +11,21 @@ use calcard::{ common::{PartialDateTime, timezone::Tz}, icalendar::{ ICalendar, ICalendarComponent, ICalendarComponentType, ICalendarEntry, ICalendarParameter, - ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole, - ICalendarProperty, ICalendarValue, + ICalendarParameterName, ICalendarProperty, ICalendarValue, }, jscalendar::{ JSCalendarDateTime, JSCalendarProperty, JSCalendarValue, import::ConversionOptions, }, }; use chrono::DateTime; -use common::{Server, auth::AccessToken}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; use groupware::{ cache::GroupwareCache, calendar::{ CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF, - PREF_USE_DEFAULT_ALERTS, expand::CalendarEventExpansion, + PREF_USE_DEFAULT_ALERTS, + expand::CalendarEventExpansion, + privacy::{event_view, hide_attendees, redact_private_event}, }, }; use jmap_proto::{ @@ -73,11 +74,13 @@ impl CalendarEventGet for Server { SyncCollection::Calendar, ) .await?; - let calendar_event_ids = if access_token.reads_as_owner(account_id) { + let is_owner = access_token.reads_as_owner(account_id); + let calendar_event_ids = if is_owner { cache.document_ids(false).collect::() } else { - cache.shared_items(access_token, [Acl::ReadItems], true) + cache.shared_events(access_token, [Acl::ReadItems], true, 0) }; + let mut writable_ids = None; let (mut ids, has_synthetic_ids) = if let Some(rr) = request.ids.take() { let rr = rr.unwrap(); if rr.len() > self.core.jmap.get_max_objects { @@ -234,6 +237,26 @@ impl CalendarEventGet for Server { .deserialize::() .caused_by(trc::location!())?; + // inbuxa: a sharee sees the time of a private event only, and only the + // owners and themselves among the attendees of an event that hides + // them, unless they may change every event in its calendar. + let mut reduce_participants = reduce_participants; + if !is_owner { + let view = event_view(&calendar_event); + if view & EVENT_VIEW_PRIVATE != 0 { + redact_private_event(&mut calendar_event.data.event); + } + if view & EVENT_VIEW_HIDE_ATTENDEES != 0 + && !writable_ids + .get_or_insert_with(|| { + cache.shared_items(access_token, [Acl::ModifyItems], true) + }) + .contains(document_id) + { + reduce_participants = true; + } + } + // Extract recurrence keys from synthetic ids let mut recurrence_keys = AHashSet::new(); let mut include_base_event = false; @@ -257,28 +280,10 @@ impl CalendarEventGet for Server { // Reduce participants if reduce_participants { - for component in &mut calendar_event.data.event.components { - if component.component_type.is_scheduling_object() { - component.entries.retain(|entry| match &entry.name { - ICalendarProperty::Attendee => { - entry.parameters(&ICalendarParameterName::Role).any(|role| { - matches!( - role, - ICalendarParameterValue::Role( - ICalendarParticipationRole::Owner, - ), - ) - }) || entry.calendar_address().is_some_and(|addr| { - current_account_info - .addresses() - .iter() - .any(|a| a.eq_ignore_ascii_case(addr)) - }) - } - _ => true, - }); - } - } + hide_attendees( + &mut calendar_event.data.event, + current_account_info.addresses(), + ); } // Expand synthetic ids diff --git a/crates/jmap/src/calendar_event/query.rs b/crates/jmap/src/calendar_event/query.rs index fc19452e..c2df5312 100644 --- a/crates/jmap/src/calendar_event/query.rs +++ b/crates/jmap/src/calendar_event/query.rs @@ -9,7 +9,7 @@ use crate::{api::query::QueryResponseBuilder, changes::state::JmapCacheState}; use calcard::{common::timezone::Tz, jscalendar::JSCalendarDateTime}; use chrono::offset::TimeZone; -use common::{Server, auth::AccessToken}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; use groupware::{ cache::GroupwareCache, calendar::{CalendarEvent, expand::RecurrenceKey}, @@ -69,6 +69,8 @@ impl CalendarEventQuery for Server { .await?; let default_tz = request.arguments.time_zone.unwrap_or(Tz::UTC); let mut filter: Option = None; + let mut matches_details = false; + let mut matches_participants = false; // Extract from/to arguments for cond in &request.filter { @@ -84,6 +86,23 @@ impl CalendarEventQuery for Server { } for cond in std::mem::take(&mut request.filter) { + if let Filter::Property(cond) = &cond { + matches_details |= matches!( + cond, + CalendarEventFilter::Text(_) + | CalendarEventFilter::Title(_) + | CalendarEventFilter::Description(_) + | CalendarEventFilter::Location(_) + | CalendarEventFilter::Owner(_) + | CalendarEventFilter::Attendee(_) + ); + matches_participants |= matches!( + cond, + CalendarEventFilter::Text(_) + | CalendarEventFilter::Owner(_) + | CalendarEventFilter::Attendee(_) + ); + } match cond { Filter::Property(cond) => match cond { CalendarEventFilter::InCalendar(MaybeInvalid::Value(id)) => { @@ -243,6 +262,29 @@ impl CalendarEventQuery for Server { vec![] }; + // inbuxa: a sharee never finds a secret event, and can't match on what + // it may not see: the details of a private event, or the attendees of + // one that hides them unless it may change every event in its calendar. + let mask = if access_token.reads_through_share(account_id) { + let mut mask = cache.shared_events( + access_token, + [Acl::ReadItems], + true, + if matches_details { + EVENT_VIEW_PRIVATE + } else { + 0 + }, + ); + if matches_participants { + mask -= cache.events_with_view(EVENT_VIEW_HIDE_ATTENDEES) + - cache.shared_items(access_token, [Acl::ModifyItems], true); + } + mask + } else { + cache.document_ids(false).collect() + }; + let results = self .search_store() .query_account( @@ -250,11 +292,7 @@ impl CalendarEventQuery for Server { .with_filters(filters) .with_comparators(comparators) .with_account_id(account_id) - .with_mask(if access_token.reads_through_share(account_id) { - cache.shared_items(access_token, [Acl::ReadItems], true) - } else { - cache.document_ids(false).collect() - }), + .with_mask(mask), ) .await?; diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 7e221fd0..4f373dbd 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -188,7 +188,7 @@ impl ChangesLookup for Server { SyncCollection::Calendar, ) .await? - .shared_items(access_token, [Acl::ReadItems], true), + .shared_events(access_token, [Acl::ReadItems], true, 0), MethodObject::FileNode => self .fetch_dav_resources( access_token.account_id(), diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs index e57be70e..c6195c20 100644 --- a/tests/src/jmap/calendar/acl.rs +++ b/tests/src/jmap/calendar/acl.rs @@ -837,6 +837,197 @@ pub async fn test(test: &TestServer) { "private event listed to a sharee: {response:?}" ); + // inbuxa: a sharee sees only the time of a private event, nothing of a + // secret one, and no other attendees of an event that hides them. + let event = |uid: &str, title: &str, extra: serde_json::Value| { + let mut event = json!({ + "uid": uid, + "title": title, + "description": "Room 4, bring the files", + "start": "2020-02-03T10:00:00", + "timeZone": "America/New_York", + "duration": "PT1H", + "calendarIds": { + &shared_calendar_id: true + }, + "participants": { + "a": { + "calendarAddress": "mailto:organizer@remote.org", + "roles": { "owner": true, "attendee": true } + }, + "b": { + "calendarAddress": "mailto:jdoe@example.com", + "roles": { "attendee": true } + }, + "c": { + "calendarAddress": "mailto:guest@remote.org", + "roles": { "attendee": true } + } + }, + }); + for (key, value) in extra.as_object().unwrap() { + event[key] = value.clone(); + } + event + }; + let response = john + .jmap_create( + MethodObject::CalendarEvent, + [ + event( + "private-event@example.com", + "Doctor appointment", + json!({"privacy": "private"}), + ), + event( + "secret-event@example.com", + "Job interview", + json!({"privacy": "secret"}), + ), + event( + "hidden-attendees@example.com", + "Board meeting", + json!({"hideAttendees": true}), + ), + ], + Vec::<(&str, &str)>::new(), + ) + .await; + let private_id = response.created(0).id().to_string(); + let secret_id = response.created(1).id().to_string(); + let hidden_id = response.created(2).id().to_string(); + + // The owner sees everything + let response = john + .jmap_get( + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [private_id.as_str(), secret_id.as_str()], + ) + .await; + assert_eq!(response.list().len(), 2, "{response:?}"); + assert_eq!( + response.list()[0].get("title").and_then(|v| v.as_str()), + Some("Doctor appointment") + ); + + // A secret event doesn't exist for the sharee + let response = jane + .jmap_get_account( + john, + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [secret_id.as_str()], + ) + .await; + assert_eq!( + response.not_found().collect::>(), + [secret_id.as_str()], + "{response:?}" + ); + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + }), + ) + .await; + let ids = response.ids().collect::>(); + assert!(ids.contains(&private_id.as_str()), "{response:?}"); + assert!(!ids.contains(&secret_id.as_str()), "{response:?}"); + + // A private event shows its time only + let response = jane + .jmap_get_account( + john, + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [private_id.as_str()], + ) + .await; + let private_event = &response.list()[0]; + assert_eq!( + private_event.get("start").and_then(|v| v.as_str()), + Some("2020-02-03T10:00:00"), + "{response:?}" + ); + for property in [ + "title", + "description", + "participants", + "organizerCalendarAddress", + ] { + assert!( + private_event.get(property).is_none(), + "{property} of a private event shown to a sharee: {response:?}" + ); + } + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + "filter": {"title": "Doctor"}, + }), + ) + .await; + assert_eq!(response.ids().count(), 0, "{response:?}"); + let response = jane + .jmap_method_call( + "CalendarEvent/copy", + json!({ + "fromAccountId": &john_id, + "accountId": &jane_id, + "create": { + &private_id: { + "calendarIds": { + &jane_calendar_id: true + } + } + } + }), + ) + .await; + assert_eq!( + response + .pointer(&format!( + "/methodResponses/0/1/notCreated/{private_id}/type" + )) + .and_then(|v| v.as_str()), + Some("forbidden"), + "{response:?}" + ); + + // An event that hides its attendees shows the sharee its owners only + let response = jane + .jmap_get_account( + john, + MethodObject::CalendarEvent, + ["id", "title", "participants"], + [hidden_id.as_str()], + ) + .await; + let participants = response.list()[0] + .get("participants") + .and_then(|v| v.as_object()) + .unwrap_or_else(|| panic!("{response:?}")); + let addresses = participants + .values() + .filter_map(|p| p.get("calendarAddress").and_then(|v| v.as_str())) + .collect::>(); + assert_eq!(addresses, ["mailto:organizer@remote.org"], "{response:?}"); + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + "filter": {"attendee": "guest@remote.org"}, + }), + ) + .await; + assert!(!response.ids().any(|id| id == hidden_id), "{response:?}"); + // Destroy all mailboxes john.destroy_all_calendars().await; jane.destroy_all_calendars().await; -- 2.54.0 From 57dd7ba0410a840a6423ff9b9ac325a5644fe7c1 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 19:43:49 -0700 Subject: [PATCH 23/35] Calendar: send invitations to events that hide attendees one by one An organizer's invitations, updates and cancellations went out as one message listing every attendee, even when the event was set to hide its attendees, so each attendee's mail and calendar showed everyone else invited. The answer to an attendee's request for the event again did the same. For such an event the server now sends each recipient its own message naming only the organizer, the owners and that recipient, in the calendar data and in the summary shown in the mail. Test: an attendee's copy of an invitation to an event hiding its attendees listed the other attendee on main; now it lists only the organizer and the attendee. --- crates/dav/src/calendar/update.rs | 9 ++- crates/groupware/src/calendar/itip.rs | 8 ++- crates/groupware/src/calendar/privacy.rs | 86 ++++++++++++++++++++++++ crates/groupware/src/calendar/storage.rs | 3 +- crates/jmap/src/calendar_event/set.rs | 9 ++- tests/src/jmap/calendar/notification.rs | 58 ++++++++++++++++ 6 files changed, 166 insertions(+), 7 deletions(-) diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index a4d60802..fba65473 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -32,6 +32,7 @@ use groupware::{ cache::GroupwareCache, calendar::{ CalendarEvent, CalendarEventData, dates::has_oversized_rrule, itip::ItipSendStatus, + privacy::itip_messages_for, }, scheduling::{ ItipMessages, event_create::itip_create, event_update::itip_update, @@ -266,7 +267,10 @@ impl CalendarUpdateRequestHandler for Server { } } - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = Some(ItipMessages::new(itip_messages_for( + messages, + new_event.flags, + ))); } else { return Err(DavError::Condition(DavErrorCondition::new( StatusCode::PRECONDITION_FAILED, @@ -423,7 +427,8 @@ impl CalendarUpdateRequestHandler for Server { < self.core.groupware.itip_outbound_max_recipients { event.schedule_tag = Some(1); - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = + Some(ItipMessages::new(itip_messages_for(messages, event.flags))); } else { return Err(DavError::Condition(DavErrorCondition::new( StatusCode::PRECONDITION_FAILED, diff --git a/crates/groupware/src/calendar/itip.rs b/crates/groupware/src/calendar/itip.rs index 32f4109e..55c74e6b 100644 --- a/crates/groupware/src/calendar/itip.rs +++ b/crates/groupware/src/calendar/itip.rs @@ -11,7 +11,7 @@ use crate::{ cache::GroupwareCache, calendar::{ CalendarEvent, CalendarEventData, CalendarEventNotification, ChangedBy, - EVENT_HIDE_ATTENDEES, EVENT_NOTIFICATION_IS_CHANGE, + EVENT_HIDE_ATTENDEES, EVENT_NOTIFICATION_IS_CHANGE, privacy::itip_messages_for, }, scheduling::{ InstanceId, ItipError, ItipMessage, ItipSnapshots, @@ -230,7 +230,11 @@ impl ItipIngest for Server { Ok(None) } - MergeResult::Message(itip_message) => Ok(Some(itip_message)), + MergeResult::Message(itip_message) => { + Ok(itip_messages_for(vec![itip_message], event.flags) + .into_iter() + .next()) + } MergeResult::None => Ok(None), } } else { diff --git a/crates/groupware/src/calendar/privacy.rs b/crates/groupware/src/calendar/privacy.rs index e6bc84a8..55c69b5b 100644 --- a/crates/groupware/src/calendar/privacy.rs +++ b/crates/groupware/src/calendar/privacy.rs @@ -9,6 +9,7 @@ //! owners and themselves among the attendees of an event that hides them. use super::{ArchivedCalendarEvent, CalendarEvent, EVENT_HIDE_ATTENDEES}; +use crate::scheduling::{ItipField, ItipMessage, ItipSummary, ItipValue}; use calcard::icalendar::{ ArchivedICalendarClassification, ArchivedICalendarProperty, ArchivedICalendarValue, ICalendar, ICalendarClassification, ICalendarComponentType, ICalendarParameterName, @@ -150,3 +151,88 @@ pub fn hide_attendees(ical: &mut ICalendar, addresses: &[String]) { } } } + +/// An organizer's scheduling messages for an event that hides its attendees +/// go to each recipient on its own and name no attendee but the organizer, +/// the owners and that recipient. +pub fn itip_hide_attendees(messages: Vec>) -> Vec> { + let mut result = Vec::with_capacity(messages.len()); + for message in messages { + if !message.from_organizer { + result.push(message); + continue; + } + for rcpt in &message.to { + let mut ical = message.message.clone(); + for component in &mut ical.components { + if component.component_type.is_scheduling_object() { + let organizer = component + .property(&ICalendarProperty::Organizer) + .and_then(|entry| entry.calendar_address()) + .map(|addr| addr.to_string()); + component.entries.retain(|entry| { + entry.name != ICalendarProperty::Attendee + || entry.parameters(&ICalendarParameterName::Role).any(|role| { + matches!( + role, + ICalendarParameterValue::Role( + ICalendarParticipationRole::Owner + ) + ) + }) + || entry.calendar_address().is_some_and(|addr| { + addr.eq_ignore_ascii_case(rcpt) + || organizer.as_deref().is_some_and(|organizer| { + addr.eq_ignore_ascii_case(organizer) + }) + }) + }); + } + } + let mut summary = message.summary.clone(); + match &mut summary { + ItipSummary::Invite(fields) + | ItipSummary::Cancel(fields) + | ItipSummary::Rsvp { + current: fields, .. + } => hide_summary_attendees(fields, rcpt), + ItipSummary::Update { + current, previous, .. + } => { + hide_summary_attendees(current, rcpt); + hide_summary_attendees(previous, rcpt); + } + } + result.push(ItipMessage { + from: message.from.clone(), + from_organizer: true, + to: vec![rcpt.clone()], + summary, + message: ical, + }); + } + } + result +} + +fn hide_summary_attendees(fields: &mut [ItipField], rcpt: &str) { + for field in fields { + if let ItipValue::Participants(participants) = &mut field.value { + participants.retain(|participant| { + participant.is_organizer || participant.email.eq_ignore_ascii_case(rcpt) + }); + } + } +} + +/// The scheduling messages of an event with these flags as they may be sent +pub fn itip_messages_for( + messages: Vec>, + event_flags: u16, +) -> Vec> { + if event_flags & EVENT_HIDE_ATTENDEES != 0 { + itip_hide_attendees(messages) + } else { + messages + } +} diff --git a/crates/groupware/src/calendar/storage.rs b/crates/groupware/src/calendar/storage.rs index 6b2bb104..ed8da790 100644 --- a/crates/groupware/src/calendar/storage.rs +++ b/crates/groupware/src/calendar/storage.rs @@ -14,6 +14,7 @@ use crate::{ DavResourceName, DestroyArchive, RFC_3986, calendar::{ ArchivedCalendarEventNotification, CalendarEventNotification, alarm::CalendarAlarmType, + privacy::itip_messages_for, }, scheduling::{ItipMessages, event_cancel::itip_cancel}, }; @@ -495,7 +496,7 @@ impl DestroyArchive> { .caused_by(trc::location!())?; if let Ok(messages) = itip_cancel(&event.data.event, account_info.addresses(), true) { - ItipMessages::new(vec![messages]) + ItipMessages::new(itip_messages_for(vec![messages], event.flags)) .queue(batch) .caused_by(trc::location!())?; } diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index ba994182..0cc66634 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -32,6 +32,7 @@ use groupware::{ dates::has_oversized_rrule, expand::{CalendarEventExpansion, ComponentRecurrenceId, RecurrenceKey, resolve_local}, itip::ItipSendStatus, + privacy::itip_messages_for, }, scheduling::{ ItipMessages, @@ -498,7 +499,10 @@ impl CalendarEventSet for Server { } } - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = Some(ItipMessages::new(itip_messages_for( + messages, + new_calendar_event.flags, + ))); } else { update.fail( &mut response, @@ -873,7 +877,8 @@ impl CalendarEventSet for Server { < self.core.groupware.itip_outbound_max_recipients { event.schedule_tag = Some(1); - itip_messages = Some(ItipMessages::new(messages)); + itip_messages = + Some(ItipMessages::new(itip_messages_for(messages, event.flags))); } else { return Ok(Err(SetError::invalid_properties() .with_property(JSCalendarProperty::Participants) diff --git a/tests/src/jmap/calendar/notification.rs b/tests/src/jmap/calendar/notification.rs index bdf76682..ffdb3a78 100644 --- a/tests/src/jmap/calendar/notification.rs +++ b/tests/src/jmap/calendar/notification.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::{ @@ -500,6 +502,62 @@ pub async fn test(test: &TestServer) { "organizerCalendarAddress": "mailto:jdoe@example.com" })); + // inbuxa: an invitation to an event that hides its attendees names only + // the organizer and the attendee it goes to + let mut hidden_event = test_event(); + hidden_event["uid"] = json!("hidden-attendees-imip"); + hidden_event["hideAttendees"] = json!(true); + john.jmap_create( + MethodObject::CalendarEvent, + [hidden_event.with_property( + JSCalendarProperty::::CalendarIds, + [john_calendar_id.as_str()].into_jmap_set(), + )], + [("sendSchedulingMessages", true)], + ) + .await + .created(0); + tokio::time::sleep(std::time::Duration::from_millis(600)).await; + test.wait_for_tasks().await; + for (client, other) in [ + (jane, "mailto:bill@example.com"), + (bill, "mailto:jane.smith@example.com"), + ] { + let response = client + .jmap_method_calls(json!([ + [ + "CalendarEvent/query", + { + "accountId": client.id_string(), + "filter": {"uid": "hidden-attendees-imip"} + }, + "Q" + ], + [ + "CalendarEvent/get", + { + "accountId": client.id_string(), + "#ids": {"resultOf": "Q", "name": "CalendarEvent/query", "path": "/ids"}, + "properties": ["participants"] + }, + "G" + ] + ])) + .await; + let participants = response + .pointer("/methodResponses/1/1/list/0/participants") + .and_then(|v| v.as_object()) + .unwrap_or_else(|| panic!("invitation not received: {response:?}")); + let addresses = participants + .values() + .filter_map(|p| p.get("calendarAddress").and_then(|v| v.as_str())) + .collect::>(); + assert!( + addresses.contains(&"mailto:jdoe@example.com") && !addresses.contains(&other), + "{addresses:?}" + ); + } + // Cleanup test.wait_for_tasks().await; for client in [john, jane, bill] { -- 2.54.0 From 72a66e78e74f8e35f0f0bc6b6f57f53d104569db Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:03:39 -0700 Subject: [PATCH 24/35] WebDAV: report only the deletions a sync caller can see A sync-collection REPORT with a sync token listed every item deleted from the account since that token, from every calendar, address book or folder. A user a single calendar, address book or folder was shared with saw the names of events, contacts and files deleted from the owner's unshared collections, and the owner syncing one collection got the deletions of all the others. A sync now reports only deletions inside the synced collection, and for a sharee only those under a container shared with them. Test: webdav sync.rs, Jane syncing John's shared folder got "/dav/file/john%40example.com/sync-private/secret-plans" on main; now she and John get nothing, for files, calendars and address books. --- crates/dav/src/common/propfind.rs | 25 ++++++++++ tests/src/webdav/sync.rs | 81 +++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 79dad936..331f47c8 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.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 super::{ @@ -1223,6 +1225,9 @@ async fn get( })) }); + // inbuxa: the containers the caller can see, before narrowing to changes + let visible_containers = display_containers.clone(); + // Filter by changelog let is_sync = match query.sync_type { SyncType::From { id, seq } => { @@ -1307,6 +1312,26 @@ async fn get( .vanished(account_id, vanished_collection.into(), Query::Since(id)) .await .caused_by(trc::location!())?; + + // inbuxa: the log holds deletions from the whole account: keep + // those inside the synced collection, under a container the + // caller can see + let scope = resource + .resource + .filter(|path| !path.is_empty()) + .map_or_else( + || resources.base_path.clone(), + |path| resources.format_collection(path), + ); + vanished.retain(|href| { + href.starts_with(scope.as_str()) + && visible_containers.as_ref().is_none_or(|containers| { + href.strip_prefix(resources.base_path.as_str()) + .and_then(|path| path.trim_end_matches('/').rsplit_once('/')) + .and_then(|(parent, _)| resources.by_path(parent)) + .is_some_and(|parent| containers.contains(parent.document_id())) + }) + }); total_changes += vanished.len(); } diff --git a/tests/src/webdav/sync.rs b/tests/src/webdav/sync.rs index 5a86511e..272d474a 100644 --- a/tests/src/webdav/sync.rs +++ b/tests/src/webdav/sync.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, webdav::GenerateTestDavResource}; @@ -287,6 +289,85 @@ pub async fn test(test: &TestServer) { ); } + // inbuxa: a sync reports only deletions inside the synced collection + // that the caller could see + let sharee_client = test.account("jane@example.com").webdav_client(); + let sharee_principal = format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ); + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!( + "Running REPORT sync-collection deletion scope tests ({})...", + resource_type.base_path() + ); + let user_base_path = format!("{}/john%40example.com/", resource_type.base_path()); + let shared = format!("{user_base_path}sync-shared/"); + let private = format!("{user_base_path}sync-private/"); + let shared_item = format!("{shared}item"); + let private_item = format!("{private}secret-plans"); + for (folder, item) in [(&shared, &shared_item), (&private, &private_item)] { + client + .request("MKCOL", folder, "") + .await + .with_status(StatusCode::CREATED); + client + .request("PUT", item, resource_type.generate()) + .await + .with_status(StatusCode::CREATED); + } + client + .acl(&shared, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + if resource_type == DavResourceName::File { + client + .acl(&shared_item, sharee_principal.as_str(), ["read"]) + .await + .with_status(StatusCode::OK); + } + let sharee_token = sharee_client + .sync_collection(&shared, "", Depth::One, None, ["D:getetag"]) + .await + .sync_token() + .to_string(); + let owner_token = client + .sync_collection(&shared, "", Depth::One, None, ["D:getetag"]) + .await + .sync_token() + .to_string(); + client + .request("DELETE", &private_item, "") + .await + .with_status(StatusCode::NO_CONTENT); + for (client, token) in [(&sharee_client, &sharee_token), (&client, &owner_token)] { + let response = client + .sync_collection(&shared, token, Depth::One, None, ["D:getetag"]) + .await; + assert_eq!(response.hrefs(), Vec::<&str>::new()); + } + let mut shared_paths = vec![&shared]; + if resource_type == DavResourceName::File { + shared_paths.push(&shared_item); + } + for path in shared_paths { + client + .acl(path, sharee_principal.as_str(), []) + .await + .with_status(StatusCode::OK); + } + for folder in [&shared, &private] { + client + .request("DELETE", folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + } + client.delete_default_containers().await; test.assert_is_empty().await; } -- 2.54.0 From 1b84e846769e5969a45ed7ceecc8f296cd69b8fe Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:06:21 -0700 Subject: [PATCH 25/35] WebDAV: check every locked resource a request touches A write was let through as soon as the If header held the token of any one lock found, skipping the remaining locks on that resource and every later resource of the request. A MOVE or COPY that named the source's lock token overwrote a destination locked by another client, and a DELETE of a locked folder removed resources below it locked by someone else. A tagged If list also matched lock paths by suffix. Anyone able to write to the account (its owner, members and sharees) could do it. Now the resource and each locked resource below it must each be covered by a submitted token of one of the locks on it (shared locks need only their holder's token), and a token counts as submitted wherever it appears in the If header. Test: webdav lock.rs, MOVE onto a folder locked by another client with only the source's token got "Expected 423 Locked but got 204 No Content" on main; now 423 naming the destination's lock, the MOVE works with both tokens, and a shared lock's holder writes with its own token. --- crates/dav/src/common/lock.rs | 68 ++++++++++++++++------ tests/src/webdav/lock.rs | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 17 deletions(-) diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs index f768ea4e..6c758541 100644 --- a/crates/dav/src/common/lock.rs +++ b/crates/dav/src/common/lock.rs @@ -385,36 +385,70 @@ impl LockRequestHandler for Server { ) { let mut base_path = None; - 'outer: for (pos, resource) in resources.iter().enumerate() { + // inbuxa: a lock token counts as submitted wherever it appears in + // the If header + let submitted_tokens = headers + .if_ + .iter() + .flat_map(|if_| if_.list.iter()) + .filter_map(|cond| match cond { + Condition::StateToken { token, .. } => Some(*token), + _ => None, + }) + .collect::>(); + + for (pos, resource) in resources.iter().enumerate() { if pos == 0 && matches!(method, DavMethod::COPY) { continue; } if let Some(idx) = locks.find_cache_pos(self, resource).await? { - let mut failed_locks = Vec::new(); + let found_locks = locks.find_locks_by_pos(idx, resource, true)?; + let mut failed_locks: Vec = Vec::new(); - for (lock_path, lock_item) in locks.find_locks_by_pos(idx, resource, true)? { - let lock_token = lock_item.urn().to_string(); - if headers.if_.iter().any(|if_| { - if_.resource - .is_none_or(|r| { - r.trim_end_matches('/').ends_with(lock_path)}) - && if_.list.iter().any(|cond| matches!(cond, Condition::StateToken { token, .. } if token == &lock_token)) + // inbuxa: the resource and every locked resource below it + // each need the token of one of the locks covering them + // (more than one only when the locks are shared) + let mut locked_paths = vec![resource.path]; + for (lock_path, _) in &found_locks { + if lock_path.len() > resource.path.len() + && !locked_paths.contains(lock_path) + { + locked_paths.push(lock_path); + } + } + for locked_path in locked_paths { + let covering = found_locks + .iter() + .filter(|(lock_path, lock_item)| { + *lock_path == locked_path + || (lock_item.depth_infinity + && locked_path + .strip_prefix(*lock_path) + .is_some_and(|rest| rest.starts_with('/'))) + }) + .collect::>(); + if covering.iter().any(|(_, lock_item)| { + submitted_tokens.contains(&lock_item.urn().to_string().as_str()) }) { - break 'outer; - } else { - let base_path = base_path.get_or_insert_with(|| { - headers.base_uri() - .unwrap_or_default() - }); - failed_locks.push(format!("{base_path}/{lock_path}").into()); + continue; + } + for (lock_path, _) in covering { + let base_path = base_path + .get_or_insert_with(|| headers.base_uri().unwrap_or_default()); + let href = format!("{base_path}/{lock_path}"); + if !failed_locks.contains(&href) { + failed_locks.push(href); + } } } if !failed_locks.is_empty() { lock_response = Err(DavErrorCondition::new( StatusCode::LOCKED, - BaseCondition::LockTokenSubmitted(List(failed_locks)), + BaseCondition::LockTokenSubmitted(List( + failed_locks.into_iter().map(Into::into).collect(), + )), ) .into()); break; diff --git a/tests/src/webdav/lock.rs b/tests/src/webdav/lock.rs index 4d729c03..2b02a6c7 100644 --- a/tests/src/webdav/lock.rs +++ b/tests/src/webdav/lock.rs @@ -15,6 +15,109 @@ use hyper::StatusCode; pub async fn test(test: &TestServer) { let client = test.account("john@example.com").webdav_client(); + // inbuxa: every locked resource a request touches needs one of its + // locks' tokens, not just the first one found + println!("Running LOCK tests (several resources)..."); + let base_path = format!("{}/john%40example.com", DavResourceName::File.base_path()); + let src_path = format!("{base_path}/lock-src"); + let dst_path = format!("{base_path}/lock-dst"); + let mut tokens = Vec::new(); + for path in [&src_path, &dst_path] { + client + .request("MKCOL", path, "") + .await + .with_status(StatusCode::CREATED); + tokens.push( + client + .lock_create(path, "some-client", true, "infinity", "Second-123") + .await + .with_status(StatusCode::CREATED) + .lock_token() + .to_string(), + ); + } + let src_only = format!("(<{}>)", tokens[0]); + let both = format!("(<{}>) (<{}>)", tokens[0], tokens[1]); + client + .request_with_headers( + "MOVE", + &src_path, + [ + ("destination", dst_path.as_str()), + ("overwrite", "T"), + ("if", src_only.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::LOCKED) + .with_value("D:error.D:lock-token-submitted.D:href", &dst_path); + client + .request_with_headers( + "MOVE", + &src_path, + [ + ("destination", dst_path.as_str()), + ("overwrite", "T"), + ("if", both.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + for (path, token) in [&src_path, &dst_path].into_iter().zip(&tokens) { + client + .unlock(path, token) + .await + .with_status(StatusCode::NO_CONTENT); + } + + // A shared lock's holder writes with its own token alone + let shared_path = format!("{base_path}/lock-shared"); + client + .request("MKCOL", &shared_path, "") + .await + .with_status(StatusCode::CREATED); + let mut tokens = Vec::new(); + for owner in ["client-a", "client-b"] { + tokens.push( + client + .lock_create(&shared_path, owner, false, "infinity", "Second-123") + .await + .with_status(StatusCode::CREATED) + .lock_token() + .to_string(), + ); + } + let file_path = format!("{shared_path}/file.txt"); + client + .request("PUT", &file_path, "content") + .await + .with_status(StatusCode::LOCKED); + client + .request_with_headers( + "PUT", + &file_path, + [("if", format!("(<{}>)", tokens[1]).as_str())], + "content", + ) + .await + .with_status(StatusCode::CREATED); + for token in &tokens { + client + .unlock(&shared_path, token) + .await + .with_status(StatusCode::NO_CONTENT); + } + client + .request("DELETE", &shared_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + client + .request("DELETE", &dst_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + for resource_type in [ DavResourceName::File, DavResourceName::Cal, -- 2.54.0 From 7ddb70d535bf48be4e2f479bb612d5a82965530f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:06:59 -0700 Subject: [PATCH 26/35] WebDAV: write dead properties and lock owners back as well-formed XML Dead properties set with PROPPATCH and the owner given in a LOCK are stored as sent and written into PROPFIND and lock responses. Attribute values (and the namespace) were copied between double quotes without escaping, so a value quoted with ' that held a " ended the attribute and let the rest be written as markup. Any user able to set a property or lock a resource could put elements of their choosing into the responses other users of a shared calendar, address book or folder read. Nested properties were also closed with the innermost element's name, giving malformed XML. Attribute values and the namespace are now escaped for double quotes when stored, names that can't be written back as names are replaced, and on output, attributes already stored are written only while they are plain name="value" pairs. Each end tag closes the element opened last. Test: dav-proto dead_property_round_trip_is_well_formed got "\" ..." and "expected ``, but `` was found" on main; now the output parses with the same elements and attribute values, and stored attributes holding markup are cut off at it. --- crates/dav-proto/src/requests/mod.rs | 104 ++++++++++++++++++++- crates/dav-proto/src/responses/mod.rs | 92 ++++++++++++++++-- crates/dav-proto/src/responses/property.rs | 17 ++-- 3 files changed, 190 insertions(+), 23 deletions(-) diff --git a/crates/dav-proto/src/requests/mod.rs b/crates/dav-proto/src/requests/mod.rs index e00526c5..269dc547 100644 --- a/crates/dav-proto/src/requests/mod.rs +++ b/crates/dav-proto/src/requests/mod.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::{ @@ -75,10 +77,18 @@ impl From<&RawElement<'_>> for DeadElementTag { .unwrap_or("invalid-utf8") .trim_ascii() .to_string(); + let name = if is_xml_name(&name) { + name + } else { + "invalid-name".to_string() + }; let mut attrs = String::with_capacity(raw.element.attributes_raw().len()); if let Some(namespace) = &raw.namespace { attrs.push_str("xmlns=\""); - attrs.push_str(std::str::from_utf8(namespace).unwrap_or("invalid-utf8")); + push_attribute_value( + &mut attrs, + std::str::from_utf8(namespace).unwrap_or("invalid-utf8"), + ); attrs.push('"'); } @@ -90,14 +100,15 @@ impl From<&RawElement<'_>> for DeadElementTag { if let (Ok(key), Ok(value)) = ( std::str::from_utf8(attr.key.as_ref()), std::str::from_utf8(attr.value.as_ref()), - ) { + ) && is_xml_name(key) + { if !attrs.is_empty() { attrs.push(' '); } attrs.push_str(key); attrs.push('='); attrs.push('"'); - attrs.push_str(value); + push_attribute_value(&mut attrs, value); attrs.push('"'); } } @@ -109,6 +120,31 @@ impl From<&RawElement<'_>> for DeadElementTag { } } +/// inbuxa: element and attribute names are echoed back as they came in, so +/// nothing that could end the tag or start markup may be in them +pub(crate) fn is_xml_name(name: &str) -> bool { + !name.is_empty() + && !name + .chars() + .any(|c| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | '&' | '=' | '/')) +} + +/// inbuxa: a stored attribute value is always written inside double quotes, +/// so it's unescaped as received (it may have been quoted with ') and escaped +/// again for that +fn push_attribute_value(out: &mut String, raw: &str) { + let value = quick_xml::escape::unescape(raw).unwrap_or(std::borrow::Cow::Borrowed(raw)); + for c in value.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } +} + #[cfg(test)] mod tests { use calcard::vcard::VCardVersion; @@ -121,6 +157,68 @@ mod tests { }, }; + // inbuxa: dead properties are stored from one user's PROPPATCH or LOCK + // and shown to everyone who can read the resource: what comes back out + // must be the same well-formed XML, whatever quoting came in + #[test] + fn dead_property_round_trip_is_well_formed() { + let xml = r#" + + + + text + + + "#; + + let update = PropertyUpdate::parse(&mut Tokenizer::new(xml.as_bytes())).unwrap(); + let output = update + .set + .iter() + .map(|property| property.to_string()) + .collect::(); + let wrapped = format!("{output}"); + + let mut reader = quick_xml::Reader::from_str(&wrapped); + let mut elements = Vec::new(); + let mut attributes = Vec::new(); + loop { + match reader.read_event() { + Ok(quick_xml::events::Event::Start(element)) + | Ok(quick_xml::events::Event::Empty(element)) => { + elements + .push(String::from_utf8(element.local_name().as_ref().to_vec()).unwrap()); + for attribute in element.attributes() { + let attribute = attribute.unwrap(); + attributes.push(( + String::from_utf8(attribute.key.as_ref().to_vec()).unwrap(), + quick_xml::escape::unescape( + std::str::from_utf8(&attribute.value).unwrap(), + ) + .unwrap() + .into_owned(), + )); + } + } + Ok(quick_xml::events::Event::Eof) => break, + Ok(_) => {} + Err(err) => panic!("malformed output {output:?}: {err}"), + } + } + + assert_eq!( + elements, + ["root", "note", "outer", "inner", "empty"], + "{output}" + ); + for (name, value) in [("a", r#"x" >"#), ("b", r#"&""#), ("c", "")] { + assert!( + attributes.contains(&(name.to_string(), value.to_string())), + "{name}={value:?} not in {attributes:?}" + ); + } + } + #[test] fn parse_address_data_version() { let xml = r#" diff --git a/crates/dav-proto/src/responses/mod.rs b/crates/dav-proto/src/responses/mod.rs index 1ce07a87..d2ae4582 100644 --- a/crates/dav-proto/src/responses/mod.rs +++ b/crates/dav-proto/src/responses/mod.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. */ pub mod acl; @@ -13,6 +15,7 @@ pub mod property; pub mod propstat; pub mod schedule; +use crate::requests::is_xml_name; use crate::schema::{ Namespaces, property::{Comp, ResourceType, SupportedCollation}, @@ -174,21 +177,20 @@ pub trait DeadPropertyFormat { impl DeadPropertyFormat for DeadProperty { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut last_tag = ""; + // inbuxa: each end tag closes the element opened last + let mut open_tags = Vec::new(); for item in &self.0 { match item { DeadPropertyTag::ElementStart(tag) => { - let name = &tag.name; - if let Some(attrs) = &tag.attrs { - write!(f, "<{name} {attrs}>")?; - } else { - write!(f, "<{name}>")?; - } - last_tag = name; + let name = dead_element_name(&tag.name); + write!(f, "<{name}{}>", DeadAttributes(tag.attrs.as_deref()))?; + open_tags.push(name); } DeadPropertyTag::ElementEnd => { - write!(f, "", last_tag)?; + if let Some(name) = open_tags.pop() { + write!(f, "")?; + } } DeadPropertyTag::Text(text) => { text.write_escaped_to(f)?; @@ -196,6 +198,47 @@ impl DeadPropertyFormat for DeadProperty { } } + while let Some(name) = open_tags.pop() { + write!(f, "")?; + } + + Ok(()) + } +} + +/// inbuxa: a stored element name that couldn't be written back as one +pub(crate) fn dead_element_name(name: &str) -> &str { + if is_xml_name(name) { + name + } else { + "invalid-name" + } +} + +/// inbuxa: writes a dead property's stored attributes as ` name="value"` +/// pairs. Values stored before they were escaped on the way in may hold a +/// quote or markup, so writing stops at the first part that isn't a plain +/// pair. +pub(crate) struct DeadAttributes<'x>(pub Option<&'x str>); + +impl Display for DeadAttributes<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Some(mut attrs) = self.0 else { + return Ok(()); + }; + + loop { + attrs = attrs.trim_start(); + let Some((name, value, rest)) = attrs.split_once('=').and_then(|(name, rest)| { + let (value, rest) = rest.strip_prefix('"')?.split_once('"')?; + (is_xml_name(name) && !value.contains('<')).then_some((name, value, rest)) + }) else { + break; + }; + write!(f, " {name}=\"{value}\"")?; + attrs = rest; + } + Ok(()) } } @@ -247,6 +290,37 @@ mod tests { } } + // inbuxa: attributes stored before they were escaped on the way in are + // written only up to the first part that isn't a plain name="value" pair + #[test] + fn stored_dead_property_attributes_stay_inside_the_tag() { + struct Format(DeadProperty); + impl Display for Format { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + super::DeadPropertyFormat::fmt(&self.0, f) + } + } + + let property = DeadProperty(vec![ + DeadPropertyTag::ElementStart(DeadElementTag { + name: "note".to_string(), + attrs: Some(r#"xmlns="urn:example:z" a="x" >" b="y""#.to_string()), + }), + DeadPropertyTag::ElementStart(DeadElementTag { + name: "inner".to_string(), + attrs: Some(r#"c="""#.to_string()), + }), + DeadPropertyTag::Text("text".to_string()), + DeadPropertyTag::ElementEnd, + DeadPropertyTag::ElementEnd, + ]); + + assert_eq!( + Format(property).to_string(), + r#"text"# + ); + } + #[test] fn parse_responses() { for (num, test) in [ diff --git a/crates/dav-proto/src/responses/property.rs b/crates/dav-proto/src/responses/property.rs index 44dffde6..4acbec64 100644 --- a/crates/dav-proto/src/responses/property.rs +++ b/crates/dav-proto/src/responses/property.rs @@ -2,11 +2,13 @@ * 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 super::{XmlCdataEscape, XmlEscape}; use crate::{ - responses::DeadPropertyFormat, + responses::{DeadAttributes, DeadPropertyFormat, dead_element_name}, schema::{ Namespace, Namespaces, property::{ @@ -39,12 +41,9 @@ impl Display for PropResponse { impl Display for DavPropertyValue { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let (name, attrs) = self.property.tag_name(); + let name = dead_element_name(name); - write!(f, "<{}", name)?; - - if let Some(attrs) = attrs { - write!(f, " {attrs}")?; - } + write!(f, "<{name}{}", DeadAttributes(attrs))?; if !matches!(self.value, DavValue::Null) { write!(f, ">{}", self.value, name) @@ -275,11 +274,7 @@ impl Display for ReportSet { impl Display for DavProperty { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let (name, attrs) = self.tag_name(); - if let Some(attrs) = attrs { - write!(f, "<{name} {attrs}/>") - } else { - write!(f, "<{name}/>") - } + write!(f, "<{}{}/>", dead_element_name(name), DeadAttributes(attrs)) } } -- 2.54.0 From 25fa7543363ef97e2ef1e28e7759bc6daa8d2cc7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:10:38 -0700 Subject: [PATCH 27/35] WebDAV: check file COPY and MOVE like PUT and DELETE; copies take the folder's ACL A sharee could COPY or MOVE a file into a shared folder holding only the right to change the folder's properties, where PUT and MKCOL need the right to add to it. An overwriting COPY or MOVE needed the right to delete only the top of what it replaced, then removed everything below it, where DELETE needs that right on every node. What a COPY, or a MOVE from another account, created also kept the source's ACL. A user with write access to someone's shared folder could build a folder of their own shared with anyone, copy it in, and so share the owner's folder contents with people the owner never chose. Adding to a folder by COPY or MOVE now needs the right to add to it, and overwriting needs the right to delete every node replaced. What a COPY, or a MOVE from another account, puts in a folder takes the folder's ACL, as an uploaded file does; a copy over an existing file keeps that file's ACL. A MOVE within an account keeps the node's ACL. Test: webdav acl.rs, John (properties-only on Bill's folder) copying a file into it got "Expected 403 Forbidden but got 201 Created" on main; with only the rights checks, the folder John copied into Bill's still granted Jane: "Expected at least one of ...john... values, but got {...jane...}". Now the copy is refused, the overwrite of a folder John can't fully delete is refused, and copies and moves carry Bill's grants only. --- crates/dav/src/file/copy_move.rs | 56 ++++++++++---- tests/src/webdav/acl.rs | 127 +++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 16 deletions(-) diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 0130e4ab..520da2b0 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.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 super::FromDavResource; @@ -32,7 +34,7 @@ use store::{ }; use trc::AddContext; use types::{ - acl::Acl, + acl::{Acl, AclGrant}, collection::{Collection, SyncCollection, VanishedCollection}, }; @@ -158,21 +160,23 @@ impl FileCopyMoveRequestHandler for Server { // Validate destination ACLs if let Some(document_id) = destination.document_id { - if let Some(delete_destination) = &delete_destination - && !access_token.is_member(to_account_id) - && !to_resources.has_access_to_container( - access_token, - delete_destination.document_id.unwrap(), - Acl::Delete, - ) - { - return Err(DavError::Code(StatusCode::FORBIDDEN)); - } - - if !access_token.is_member(to_account_id) - && !to_resources.has_access_to_container(access_token, document_id, Acl::Modify) - { - return Err(DavError::Code(StatusCode::FORBIDDEN)); + if !access_token.is_member(to_account_id) { + // inbuxa: adding to a folder needs AddItems, as PUT and MKCOL + // do, and replacing what is there needs Delete on all of it, + // as DELETE does + if !to_resources.has_access_to_container(access_token, document_id, Acl::AddItems) { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + if delete_destination.is_some() { + let permissions = + to_resources.shared_containers(access_token, [Acl::Delete], false); + if !to_resources + .subtree(destination_resource_name) + .all(|resource| permissions.contains(resource.document_id())) + { + return Err(DavError::Code(StatusCode::FORBIDDEN)); + } + } } } else if !access_token.is_member(to_account_id) { return Err(DavError::Code(StatusCode::FORBIDDEN)); @@ -342,6 +346,10 @@ pub(crate) struct Destination { pub new_name: Option, pub document_id: Option, pub is_container: bool, + // inbuxa: the ACL of this node. What a COPY, or a MOVE from another + // account, puts in a folder takes the folder's ACL, as an uploaded file + // does, never the source's. + pub acls: Vec, } impl Default for Destination { @@ -351,6 +359,7 @@ impl Default for Destination { document_id: Default::default(), new_name: Default::default(), is_container: true, + acls: Default::default(), } } } @@ -501,6 +510,7 @@ async fn copy_container( }; node.modified = now; node.created = now; + node.acls = destination.acls.clone(); if let Some(new_name) = destination.new_name.take() { node.name = new_name; } @@ -612,6 +622,11 @@ async fn overwrite_and_delete_item( dest_node.inner.name.to_string() }; source_node.parent_id = dest_node.inner.parent_id.into(); + // inbuxa: a file moved from another account over a file keeps that + // file's ACL + if from_account_id != to_account_id { + source_node.acls = dest_node.inner.acls.iter().map(AclGrant::from).collect(); + } let mut batch = BatchBuilder::new(); let etag = source_node @@ -689,6 +704,8 @@ async fn overwrite_item( dest_node.inner.name.to_string() }; source_node.parent_id = dest_node.inner.parent_id.into(); + // inbuxa: a copy that replaces a file keeps that file's ACL + source_node.acls = dest_node.inner.acls.iter().map(AclGrant::from).collect(); let mut batch = BatchBuilder::new(); let etag = source_node .update( @@ -758,6 +775,7 @@ async fn move_item( .etag() } else { // Destination is in a different account: insert a new node, then delete the old one + new_node.acls = destination.acls; let to_document_id = server .store() .assign_document_ids(to_account_id, Collection::FileNode, 1) @@ -818,6 +836,7 @@ async fn copy_item( .deserialize::() .caused_by(trc::location!())?; node.parent_id = parent_id; + node.acls = destination.acls; if let Some(new_name) = destination.new_name { node.name = new_name; } @@ -902,6 +921,11 @@ impl FromDavResource for Destination { document_id: Some(item.document_id()), is_container: item.is_container(), new_name: None, + acls: item + .resource + .acls() + .map(|acls| acls.to_vec()) + .unwrap_or_default(), } } } diff --git a/tests/src/webdav/acl.rs b/tests/src/webdav/acl.rs index a177df7f..b9d3934f 100644 --- a/tests/src/webdav/acl.rs +++ b/tests/src/webdav/acl.rs @@ -508,6 +508,133 @@ pub async fn test(test: &TestServer) { .await .with_status(StatusCode::NO_CONTENT); + // inbuxa: COPY and MOVE into a shared folder need the rights PUT and + // DELETE need there, and what lands there takes the folder's ACL + let file_base = DavResourceName::File.base_path(); + let jane_principal = format!( + "{}/jane%40example.com/", + DavResourceName::Principal.base_path() + ); + let owner_drop = format!("{file_base}/bill%40example.com/drop-box/"); + let owner_team = format!("{file_base}/bill%40example.com/team-folder/"); + let owner_keep = format!("{owner_team}keep/"); + let owner_keep_file = format!("{owner_keep}notes.txt"); + let sharee_src = format!("{file_base}/john%40example.com/outbox/"); + let sharee_src_file = format!("{sharee_src}payload.txt"); + for folder in [&owner_drop, &owner_team, &owner_keep] { + owner_client + .request("MKCOL", folder, "") + .await + .with_status(StatusCode::CREATED); + } + owner_client + .request("PUT", &owner_keep_file, "keep me") + .await + .with_status(StatusCode::CREATED); + for (path, grants) in [ + (&owner_drop, ["read", "write-properties"].as_slice()), + (&owner_team, ["read", "write-content"].as_slice()), + (&owner_keep, ["read", "write"].as_slice()), + (&owner_keep_file, ["read"].as_slice()), + ] { + owner_client + .acl(path, john_principal.as_str(), grants.iter().copied()) + .await + .with_status(StatusCode::OK); + } + sharee_client + .request("MKCOL", &sharee_src, "") + .await + .with_status(StatusCode::CREATED); + sharee_client + .acl(&sharee_src, jane_principal.as_str(), ["read", "write"]) + .await + .with_status(StatusCode::OK); + sharee_client + .request("PUT", &sharee_src_file, "payload") + .await + .with_status(StatusCode::CREATED); + + // Adding to a folder needs the right to add to it, as for PUT + sharee_client + .request("PUT", &format!("{owner_drop}direct.txt"), "payload") + .await + .with_status(StatusCode::FORBIDDEN); + let copy_dest = format!("{owner_drop}payload.txt"); + sharee_client + .request_with_headers( + "COPY", + &sharee_src_file, + [("destination", copy_dest.as_str())], + "", + ) + .await + .with_status(StatusCode::FORBIDDEN); + + // Overwriting a folder needs the right to delete everything in it, as + // for DELETE + sharee_client + .request("DELETE", &owner_keep, "") + .await + .with_status(StatusCode::FORBIDDEN); + sharee_client + .request_with_headers( + "COPY", + &sharee_src, + [("destination", owner_keep.as_str()), ("overwrite", "T")], + "", + ) + .await + .with_status(StatusCode::FORBIDDEN); + owner_client + .request("GET", &owner_keep_file, "") + .await + .with_status(StatusCode::OK) + .with_body("keep me"); + + // A copy or a move from another account takes the destination folder's + // ACL: John's grant to Jane stays behind + for (method, name) in [("COPY", "copied"), ("MOVE", "moved")] { + let dest = format!("{owner_team}{name}/"); + sharee_client + .request_with_headers(method, &sharee_src, [("destination", dest.as_str())], "") + .await + .with_status(StatusCode::CREATED); + let dest_file = format!("{dest}payload.txt"); + let response = owner_client + .propfind(&dest, [DavProperty::WebDav(WebDavProperty::Acl)]) + .await; + for href in [dest.as_str(), dest_file.as_str()] { + response + .properties(href) + .get(DavProperty::WebDav(WebDavProperty::Acl)) + .with_some_values([format!("D:ace.D:principal.D:href:{john_principal}").as_str()]) + .without_values([format!("D:ace.D:principal.D:href:{jane_principal}").as_str()]); + } + } + for folder in [&owner_drop, &owner_team] { + owner_client + .request("DELETE", folder, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + // Jane's grants left John's account with the move: a grant made and + // taken back refreshes her view of it + sharee_client + .request("MKCOL", &sharee_src, "") + .await + .with_status(StatusCode::CREATED); + for grants in [["read"].as_slice(), [].as_slice()] { + sharee_client + .acl(&sharee_src, jane_principal.as_str(), grants.iter().copied()) + .await + .with_status(StatusCode::OK); + } + sharee_client + .request("DELETE", &sharee_src, "") + .await + .with_status(StatusCode::NO_CONTENT); + sharee_client.delete_default_containers().await; owner_client.delete_default_containers().await; test.assert_is_empty().await; -- 2.54.0 From 670a0098b7d777cb2124cd35425c51cc8b0d7312 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:11:49 -0700 Subject: [PATCH 28/35] WebDAV: keep a zero result limit from lifting the cap on query results An addressbook-query or other DAV REPORT asking for 0 started its countdown of results at zero and subtracted from it after the first result, wrapping it around to the largest number: the configured maximum number of results no longer applied, so one request could return every item of the collection. Debug builds panicked in the request handler instead. Any signed-in user could send it. A limit of zero is now treated as one, and the message for an exceeded limit names the limit actually applied. Test: webdav card_query.rs, an addressbook-query with nresults 0 hit "attempt to subtract with overflow" at propfind.rs and the connection closed on main; now one result and the 507 number-of-matches-within- limits response naming a limit of 1. --- crates/dav/src/common/propfind.rs | 15 +++++++-------- tests/src/webdav/card_query.rs | 21 +++++++++++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index 331f47c8..cfd2d912 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -296,10 +296,14 @@ impl PropFindRequestHandler for Server { let collection_children; let sync_collection; let mut query_filter = None; - let mut limit = std::cmp::min( + // inbuxa: a limit of zero still allows one result, so the countdown + // below can't wrap around and lift the cap + let max_results = std::cmp::min( query.limit.unwrap_or(u32::MAX) as usize, self.core.groupware.max_results, - ); + ) + .max(1); + let mut limit = max_results; let mut is_sync_limited = false; let mut is_propfind = false; let mut ical_instances_limit = self.core.groupware.max_ical_instances; @@ -1117,12 +1121,7 @@ impl PropFindRequestHandler for Server { Response::new_status([query.uri], StatusCode::INSUFFICIENT_STORAGE) .with_error(BaseCondition::NumberOfMatchesWithinLimit) .with_response_description(if ical_instances_limit > 0 { - format!( - "The number of matches exceeds the limit of {}", - query - .limit - .unwrap_or(self.core.groupware.max_results as u32) - ) + format!("The number of matches exceeds the limit of {max_results}") } else { format!( "The number of recurrence instances exceeds the limit of {}", diff --git a/tests/src/webdav/card_query.rs b/tests/src/webdav/card_query.rs index 39a8214f..c7a3066f 100644 --- a/tests/src/webdav/card_query.rs +++ b/tests/src/webdav/card_query.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; @@ -141,6 +143,25 @@ END:VCARD ) .with_href_count(3); + // Test 5: A limit of zero still caps the results + client + .request( + "REPORT", + &default_path, + &QUERY4.replace("2<", "0<"), + ) + .await + .with_status(StatusCode::MULTI_STATUS) + .with_value( + "D:multistatus.D:response.D:status", + "HTTP/1.1 507 Insufficient Storage", + ) + .with_value( + "D:multistatus.D:response.D:responsedescription", + "The number of matches exceeds the limit of 1", + ) + .with_href_count(2); + client.delete_default_containers().await; test.assert_is_empty().await; } -- 2.54.0 From e9877bcfb2cafcb75acb741be3bd6a015f416171 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:52:33 -0700 Subject: [PATCH 29/35] JMAP: return a property listed twice in /get or Email/parse once A property list that named the same property many times was rendered once per mention for every object returned. A signed-in user could list bodyValues a few thousand times with fetchAllBodyValues and make Email/get or Email/parse build that many copies of every body in memory. Every /get method, Email/get's bodyProperties and Email/parse's properties and bodyProperties now keep each property once, in the order first given. Test: Email/get and Email/parse with bodyValues, textBody and partId listed several times; on main the response held each of them three times over, now once. --- crates/jmap-proto/src/method/get.rs | 17 ++----- crates/jmap-proto/src/request/mod.rs | 11 ++++- crates/jmap/src/email/get.rs | 4 +- crates/jmap/src/email/parse.rs | 57 +++++++++++------------ tests/src/jmap/mail/get.rs | 69 ++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 44 deletions(-) diff --git a/crates/jmap-proto/src/method/get.rs b/crates/jmap-proto/src/method/get.rs index 942ac336..396213ec 100644 --- a/crates/jmap-proto/src/method/get.rs +++ b/crates/jmap-proto/src/method/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 crate::{ @@ -10,6 +12,7 @@ use crate::{ MaybeInvalid, deserialize::{DeserializeArguments, deserialize_request}, reference::{MaybeIdReference, MaybeResultReference, ResultReference}, + unique_valid, }, types::state::State, }; @@ -99,20 +102,10 @@ impl Default for GetRequest { impl GetRequest { pub fn unwrap_properties(&mut self, default: &[T::Property]) -> Vec { if let Some(properties_) = self.properties.take().map(|p| p.unwrap()) { - let mut properties = Vec::with_capacity(properties_.len()); + let mut properties = unique_valid(properties_); let id_prop = T::ID_PROPERTY; - let mut has_id = false; - for prop in properties_ { - if let MaybeInvalid::Value(p) = prop { - if p == id_prop { - has_id = true; - } - properties.push(p); - } - } - - if !has_id { + if !properties.contains(&id_prop) { properties.push(id_prop); } diff --git a/crates/jmap-proto/src/request/mod.rs b/crates/jmap-proto/src/request/mod.rs index 570a5e08..37c50211 100644 --- a/crates/jmap-proto/src/request/mod.rs +++ b/crates/jmap-proto/src/request/mod.rs @@ -42,7 +42,7 @@ use crate::{ request::{capability::CapabilityIds, reference::MaybeIdReference}, }; use jmap_tools::{Null, Value}; -use std::{collections::HashMap, fmt::Debug, str::FromStr}; +use std::{collections::HashMap, fmt::Debug, hash::Hash, str::FromStr}; use types::id::Id; use utils::map::vec_map::VecMap; @@ -342,6 +342,15 @@ pub trait IntoValid { fn into_valid(self) -> impl Iterator; } +// inbuxa: a property listed twice is returned once; a list repeating one +// (bodyValues, say) used to render it again for every repeat in every object +pub fn unique_valid(list: Vec>) -> Vec { + let mut seen = ahash::AHashSet::with_capacity(list.len()); + list.into_valid() + .filter(|item| seen.insert(item.clone())) + .collect() +} + impl IntoValid for Vec> { type Item = T; diff --git a/crates/jmap/src/email/get.rs b/crates/jmap/src/email/get.rs index 5554c6e1..1ae4a346 100644 --- a/crates/jmap/src/email/get.rs +++ b/crates/jmap/src/email/get.rs @@ -22,7 +22,7 @@ use email::{ use jmap_proto::{ method::get::{GetRequest, GetResponse}, object::email::{Email, EmailProperty, EmailValue, HeaderForm}, - request::IntoValid, + request::unique_valid, types::date::UTCDate, }; use jmap_tools::{Key, Map, Value}; @@ -87,7 +87,7 @@ impl EmailGet for Server { let body_properties = request .arguments .body_properties - .map(|v| v.into_valid().collect()) + .map(unique_valid) .unwrap_or_else(|| { vec![ EmailProperty::PartId, diff --git a/crates/jmap/src/email/parse.rs b/crates/jmap/src/email/parse.rs index 8c89bdcc..999fd01e 100644 --- a/crates/jmap/src/email/parse.rs +++ b/crates/jmap/src/email/parse.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::blob::download::BlobDownload; @@ -14,7 +16,7 @@ use email::message::{ use jmap_proto::{ method::parse::{ParseRequest, ParseResponse}, object::email::{Email, EmailProperty}, - request::{IntoValid, MaybeInvalid, reference::MaybeIdReference}, + request::{MaybeInvalid, reference::MaybeIdReference, unique_valid}, }; use jmap_tools::{Key, Map, Value}; use mail_parser::{ @@ -41,37 +43,34 @@ impl EmailParse for Server { if request.blob_ids.len() > self.core.jmap.mail_parse_max_items { return Err(trc::JmapEvent::RequestTooLarge.into_err()); } - let properties = request - .properties - .map(|v| v.into_valid().collect()) - .unwrap_or_else(|| { - vec![ - EmailProperty::BlobId, - EmailProperty::Size, - EmailProperty::ReceivedAt, - EmailProperty::MessageId, - EmailProperty::InReplyTo, - EmailProperty::References, - EmailProperty::Sender, - EmailProperty::From, - EmailProperty::To, - EmailProperty::Cc, - EmailProperty::Bcc, - EmailProperty::ReplyTo, - EmailProperty::Subject, - EmailProperty::SentAt, - EmailProperty::HasAttachment, - EmailProperty::Preview, - EmailProperty::BodyValues, - EmailProperty::TextBody, - EmailProperty::HtmlBody, - EmailProperty::Attachments, - ] - }); + let properties = request.properties.map(unique_valid).unwrap_or_else(|| { + vec![ + EmailProperty::BlobId, + EmailProperty::Size, + EmailProperty::ReceivedAt, + EmailProperty::MessageId, + EmailProperty::InReplyTo, + EmailProperty::References, + EmailProperty::Sender, + EmailProperty::From, + EmailProperty::To, + EmailProperty::Cc, + EmailProperty::Bcc, + EmailProperty::ReplyTo, + EmailProperty::Subject, + EmailProperty::SentAt, + EmailProperty::HasAttachment, + EmailProperty::Preview, + EmailProperty::BodyValues, + EmailProperty::TextBody, + EmailProperty::HtmlBody, + EmailProperty::Attachments, + ] + }); let body_properties = request .arguments .body_properties - .map(|v| v.into_valid().collect()) + .map(unique_valid) .unwrap_or_else(|| { vec![ EmailProperty::PartId, diff --git a/tests/src/jmap/mail/get.rs b/tests/src/jmap/mail/get.rs index 89b041e4..67ab4637 100644 --- a/tests/src/jmap/mail/get.rs +++ b/tests/src/jmap/mail/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 crate::{jmap::replace_blob_ids, utils::server::TestServer}; @@ -166,6 +168,73 @@ pub async fn test(test: &TestServer) { } } + // inbuxa: a property listed twice is returned once, in Email/get and + // Email/parse alike, for the object and for each body part + let blob_id = client + .upload( + None, + b"From: a@example.com\r\nSubject: twice\r\n\r\nThe body.\r\n".to_vec(), + None, + ) + .await + .unwrap() + .take_blob_id(); + let email_id = client + .email_import( + b"From: a@example.com\r\nSubject: twice\r\n\r\nThe body.\r\n".to_vec(), + [mailbox_id.clone()], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + let repeated = [ + "bodyValues", + "bodyValues", + "textBody", + "textBody", + "bodyValues", + ]; + let response = account + .jmap_raw_post( + serde_json::json!({ + "using": ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], + "methodCalls": [ + ["Email/get", { + "accountId": account.id_string(), + "ids": [&email_id], + "properties": repeated, + "bodyProperties": ["partId", "partId", "size", "partId"], + "fetchAllBodyValues": true + }, "0"], + ["Email/parse", { + "accountId": account.id_string(), + "blobIds": [&blob_id], + "properties": repeated, + "bodyProperties": ["partId", "partId", "size", "partId"], + "fetchAllBodyValues": true + }, "1"] + ] + }) + .to_string(), + "application/json", + ) + .await + .text(); + for (name, count) in [ + ("\"bodyValues\"", 2), + ("\"textBody\"", 2), + ("\"partId\"", 2), + ("\"The body.", 2), + ] { + assert_eq!( + response.matches(name).count(), + count, + "{name} in {response}" + ); + } + test.destroy_all_mailboxes(account).await; test.account("admin@example.com") .registry_destroy_all(ObjectType::SpamTrainingSample) -- 2.54.0 From 0e52380b8068efcb5271c9cc5fcab49fcbff5873 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:52:53 -0700 Subject: [PATCH 30/35] Quota: charge calendar, contact and file writes to the account written into CalendarEvent/set and /copy checked the disk quota of the signed-in user, while the bytes were stored in, and counted against, the account written into. A sharee with write access to a calendar could fill its owner's account past the owner's quota. FileNode/set and FileNode/copy checked no quota at all, so any user could store files past their own disk quota (and their tenant's). Every item of a calendar, contact or file request was also checked against the usage stored before the request, so one request with many items could go far over the limit. Calendar events, contacts and files are now checked against the disk quota (and tenant quota) of the account they are written into, the same way mail delivery and Email/copy are, and each item is counted together with the items before it in the same request. Each item is checked at the size it is charged at (the stored record, not only its iCalendar or vCard text); a file node counts its blob; an update counts what it adds. Test: on main a sharee's 1.2 KB event went into an account with a 1 KB quota, two events and two contacts that each fit alone but not together were both created, and a 2 KB file was created and another copied in; each is now refused with overQuota, while a small file still fits. --- crates/common/src/storage/quota.rs | 30 +++ crates/jmap/src/calendar_event/copy.rs | 9 +- crates/jmap/src/calendar_event/set.rs | 34 ++-- crates/jmap/src/contact/copy.rs | 7 +- crates/jmap/src/contact/set.rs | 66 +++---- crates/jmap/src/file/copy.rs | 18 +- crates/jmap/src/file/set.rs | 34 +++- tests/src/system/quota.rs | 250 ++++++++++++++++++++++++- 8 files changed, 391 insertions(+), 57 deletions(-) diff --git a/crates/common/src/storage/quota.rs b/crates/common/src/storage/quota.rs index 6ae7b209..577adf25 100644 --- a/crates/common/src/storage/quota.rs +++ b/crates/common/src/storage/quota.rs @@ -15,9 +15,28 @@ use registry::{ schema::enums::{StorageQuota, TenantStorageQuota}, types::EnumImpl, }; +use std::sync::Arc; use store::{ValueKey, write::ValueClass}; use trc::AddContext; +// inbuxa: the disk quota of the account a request writes into, with the bytes +// the request has already added. A request commits all its items at once, so +// each item is checked together with the ones before it, not against the +// stored usage alone. +pub struct QuotaBudget { + account: Arc, + reserved: u64, +} + +impl QuotaBudget { + pub fn new(account: Arc) -> Self { + Self { + account, + reserved: 0, + } + } +} + impl Server { pub async fn get_used_quota_account(&self, account_id: u32) -> trc::Result { self.core @@ -73,6 +92,17 @@ impl Server { Ok(()) } + // inbuxa: checks an item against the account's quota and, when it fits, + // counts it towards the rest of the request + pub async fn reserve_quota(&self, budget: &mut QuotaBudget, item_size: u64) -> trc::Result<()> { + if item_size > 0 { + self.has_available_quota(&budget.account, budget.reserved + item_size) + .await?; + budget.reserved += item_size; + } + Ok(()) + } + #[inline(always)] pub fn object_quota(&self, user_quotas: Option<&ObjectQuota>, object: StorageQuota) -> u32 { user_quotas.unwrap_or(&self.core.email.max_objects).0[object as usize] diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index e1070044..9ebfe1e8 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -11,7 +11,10 @@ use crate::{ changes::state::JmapCacheState, }; use calcard::jscalendar::JSCalendarProperty; -use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken}; +use common::{ + EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, Server, auth::AccessToken, + storage::quota::QuotaBudget, +}; use groupware::{ cache::GroupwareCache, calendar::{ @@ -121,6 +124,9 @@ impl JmapCalendarEventCopy for Server { .account_info(access_token.account_id()) .await .caused_by(trc::location!())?; + // inbuxa: the account copied into is the one whose quota applies + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); // Prepare batch let mut batch = BatchBuilder::new(); @@ -203,6 +209,7 @@ impl JmapCalendarEventCopy for Server { access_token, account_id, &account_info, + &mut quota, false, &can_add_calendars, calendar_event.data.event.into_jscalendar(), diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index 0cc66634..cb9a7007 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -21,6 +21,7 @@ use chrono::DateTime; use common::{ DavName, DavResources, Server, auth::{AccessToken, AccountInfo}, + storage::quota::QuotaBudget, }; use groupware::{ DestroyArchive, @@ -81,6 +82,7 @@ pub trait CalendarEventSet: Sync + Send { access_token: &AccessToken, account_id: u32, account_info: &AccountInfo, + quota: &mut QuotaBudget, send_scheduling_messages: bool, can_add_calendars: &Option, js_calendar_event: JSCalendar<'_, Id, BlobId>, @@ -107,6 +109,9 @@ impl CalendarEventSet for Server { .scheduling_account_info(access_token.account_id(), account_id) .await .caused_by(trc::location!())?; + // inbuxa: the account written into is the one whose quota applies + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); let mut response = SetResponse::from_request(&request, self.core.jmap.set_max_objects)? .with_state(cache.assert_state(false, &request.if_in_state)?); let will_destroy = response.collect_will_destroy(request.unwrap_destroy()); @@ -140,6 +145,7 @@ impl CalendarEventSet for Server { access_token, account_id, &account_info, + &mut quota, send_scheduling_messages, &can_add_calendars, JSCalendar::default(), @@ -558,20 +564,16 @@ impl CalendarEventSet for Server { } // Validate quota - let extra_bytes = (new_calendar_event.size as u64) - .saturating_sub(u32::from(calendar_event.inner.size) as u64); - if extra_bytes > 0 { - match self - .has_available_quota(account_info.account(), extra_bytes) - .await - { - Ok(_) => {} - Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { - update.fail(&mut response, SetError::over_quota()); - continue 'update; - } - Err(err) => return Err(err.caused_by(trc::location!())), + // inbuxa: counted as the stored record is, not the iCalendar alone + let extra_bytes = (new_calendar_event.size() as u64) + .saturating_sub(calendar_event.inner.size() as u64); + match self.reserve_quota(&mut quota, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + update.fail(&mut response, SetError::over_quota()); + continue 'update; } + Err(err) => return Err(err.caused_by(trc::location!())), } // Update record @@ -721,6 +723,7 @@ impl CalendarEventSet for Server { access_token: &AccessToken, account_id: u32, account_info: &AccountInfo, + quota: &mut QuotaBudget, send_scheduling_messages: bool, can_add_calendars: &Option, mut js_calendar_group: JSCalendar<'_, Id, BlobId>, @@ -915,10 +918,7 @@ impl CalendarEventSet for Server { } // Validate quota - match self - .has_available_quota(account_info.account(), size as u64) - .await - { + match self.reserve_quota(quota, event.size() as u64).await { Ok(_) => {} Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { return Ok(Err(SetError::over_quota())); diff --git a/crates/jmap/src/contact/copy.rs b/crates/jmap/src/contact/copy.rs index f5d78b2b..76de3501 100644 --- a/crates/jmap/src/contact/copy.rs +++ b/crates/jmap/src/contact/copy.rs @@ -7,7 +7,7 @@ */ use crate::{changes::state::JmapCacheState, contact::set::ContactCardSet}; -use common::{Server, auth::AccessToken}; +use common::{Server, auth::AccessToken, storage::quota::QuotaBudget}; use groupware::{cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -56,7 +56,8 @@ impl JmapContactCardCopy for Server { ) -> trc::Result> { let account_id = request.account_id.document_id(); let from_account_id = request.from_account_id.document_id(); - let account = self.account(account_id).await.caused_by(trc::location!())?; + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); if account_id == from_account_id { return Err(trc::JmapEvent::InvalidArguments @@ -149,7 +150,7 @@ impl JmapContactCardCopy for Server { &cache, &mut batch, access_token, - &account, + &mut quota, account_id, &can_add_address_books, contact.card.into_jscontact(), diff --git a/crates/jmap/src/contact/set.rs b/crates/jmap/src/contact/set.rs index b67b13a0..affeb57a 100644 --- a/crates/jmap/src/contact/set.rs +++ b/crates/jmap/src/contact/set.rs @@ -2,15 +2,14 @@ * 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::changes::state::JmapCacheState; use crate::contact::assert_is_unique_uid; use calcard::jscontact::{JSContact, JSContactProperty, JSContactValue}; -use common::{ - DavName, DavResources, Server, - auth::{AccessToken, AccountCache}, -}; +use common::{DavName, DavResources, Server, auth::AccessToken, storage::quota::QuotaBudget}; use groupware::{DestroyArchive, cache::GroupwareCache, contact::ContactCard}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -49,7 +48,7 @@ pub trait ContactCardSet: Sync + Send { cache: &DavResources, batch: &mut BatchBuilder, access_token: &AccessToken, - account: &AccountCache, + quota: &mut QuotaBudget, account_id: u32, can_add_address_books: &Option, js_contact: JSContact<'_, Id, BlobId>, @@ -65,7 +64,8 @@ impl ContactCardSet for Server { _session: &HttpSessionData, ) -> trc::Result> { let account_id = request.account_id.document_id(); - let account = self.account(account_id).await.caused_by(trc::location!())?; + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); let cache = self .fetch_dav_resources( access_token.account_id(), @@ -103,7 +103,7 @@ impl ContactCardSet for Server { &cache, &mut batch, access_token, - &account, + &mut quota, account_id, &can_add_address_books, JSContact::default(), @@ -271,17 +271,16 @@ impl ContactCardSet for Server { ); continue 'update; } - let extra_bytes = (new_contact_card.size as u64) - .saturating_sub(u32::from(contact_card.inner.size) as u64); - if extra_bytes > 0 { - match self.has_available_quota(&account, extra_bytes).await { - Ok(_) => {} - Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { - response.not_updated.append(id, SetError::over_quota()); - continue 'update; - } - Err(err) => return Err(err.caused_by(trc::location!())), + // inbuxa: counted as the stored record is, not the vCard alone + let extra_bytes = + (new_contact_card.size() as u64).saturating_sub(contact_card.inner.size() as u64); + match self.reserve_quota(&mut quota, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_updated.append(id, SetError::over_quota()); + continue 'update; } + Err(err) => return Err(err.caused_by(trc::location!())), } // Update record @@ -388,7 +387,7 @@ impl ContactCardSet for Server { cache: &DavResources, batch: &mut BatchBuilder, access_token: &AccessToken, - account: &AccountCache, + quota: &mut QuotaBudget, account_id: u32, can_add_address_books: &Option, mut js_contact: JSContact<'_, Id, BlobId>, @@ -441,7 +440,13 @@ impl ContactCardSet for Server { ), ))); } - match self.has_available_quota(account, size as u64).await { + let contact = ContactCard { + names, + size: size as u32, + card, + ..Default::default() + }; + match self.reserve_quota(quota, contact.size() as u64).await { Ok(_) => {} Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { return Ok(Err(SetError::over_quota())); @@ -455,20 +460,15 @@ impl ContactCardSet for Server { .assign_document_ids(account_id, Collection::ContactCard, 1) .await .caused_by(trc::location!())?; - ContactCard { - names, - size: size as u32, - card, - ..Default::default() - } - .insert( - access_token.account_tenant_ids(), - account_id, - document_id, - batch, - ) - .caused_by(trc::location!()) - .map(|_| Ok(document_id)) + contact + .insert( + access_token.account_tenant_ids(), + account_id, + document_id, + batch, + ) + .caused_by(trc::location!()) + .map(|_| Ok(document_id)) } } diff --git a/crates/jmap/src/file/copy.rs b/crates/jmap/src/file/copy.rs index 1dfced31..e73e5aa2 100644 --- a/crates/jmap/src/file/copy.rs +++ b/crates/jmap/src/file/copy.rs @@ -15,7 +15,7 @@ use crate::{ update_file_node, validate_file_node_hierarchy, }, }; -use common::{Server, auth::AccessToken, sharing::EffectiveAcl}; +use common::{Server, auth::AccessToken, sharing::EffectiveAcl, storage::quota::QuotaBudget}; use groupware::{cache::GroupwareCache, file::FileNode}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -137,6 +137,9 @@ impl FileNodeCopy for Server { let mut implicit_destroys: AHashSet = AHashSet::new(); let mut created_folders = AHashMap::new(); let mut destroy_ids = Vec::new(); + // inbuxa: copies count towards the disk quota of the account they land in + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); 'create: for (id, create) in request.create.into_valid() { let from_document_id = id.document_id(); @@ -400,6 +403,19 @@ impl FileNodeCopy for Server { continue 'create; } + // inbuxa: Validate quota + match self + .reserve_quota(&mut quota, file_node.size() as u64) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_created.append(id, SetError::over_quota()); + continue 'create; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + if !file_node.acls.is_empty() { if let Err(err) = self.acl_validate(account_id, &file_node.acls).await { response.not_created.append(id, err.into()); diff --git a/crates/jmap/src/file/set.rs b/crates/jmap/src/file/set.rs index 380fbb85..d280f660 100644 --- a/crates/jmap/src/file/set.rs +++ b/crates/jmap/src/file/set.rs @@ -11,7 +11,10 @@ use crate::{ blob::download::BlobDownload, changes::state::JmapCacheState, }; -use common::{DavResourceMetadata, DavResources, Server, auth::AccessToken, sharing::EffectiveAcl}; +use common::{ + DavResourceMetadata, DavResources, Server, auth::AccessToken, sharing::EffectiveAcl, + storage::quota::QuotaBudget, +}; use groupware::{DestroyArchive, cache::GroupwareCache, file::FileNode}; use http_proto::HttpSessionData; use jmap_proto::{ @@ -97,6 +100,9 @@ impl FileNodeSet for Server { .unwrap_or(false); let mut pending_names: AHashMap<(u32, String), Option> = AHashMap::new(); let mut implicit_destroys: AHashSet = AHashSet::new(); + // inbuxa: files count towards the disk quota of the account they're in + let mut quota = + QuotaBudget::new(self.account(account_id).await.caused_by(trc::location!())?); // Process creates let mut batch = BatchBuilder::new(); @@ -316,6 +322,19 @@ impl FileNodeSet for Server { } } + // inbuxa: Validate quota + match self + .reserve_quota(&mut quota, file_node.size() as u64) + .await + { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_created.append(id, SetError::over_quota()); + continue 'create; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + // Validate ACLs if !file_node.acls.is_empty() { if let Err(err) = self.acl_validate(account_id, &file_node.acls).await { @@ -595,6 +614,19 @@ impl FileNodeSet for Server { ); continue 'update; } + + // inbuxa: Validate quota + let extra_bytes = + (new_file_node.size() as u64).saturating_sub(file_node.inner.size() as u64); + match self.reserve_quota(&mut quota, extra_bytes).await { + Ok(_) => {} + Err(err) if err.matches(trc::EventType::Limit(trc::LimitEvent::Quota)) => { + response.not_updated.append(id, SetError::over_quota()); + continue 'update; + } + Err(err) => return Err(err.caused_by(trc::location!())), + } + if has_acl_changes { if let Err(err) = self.acl_validate(account_id, &new_file_node.acls).await { response.not_updated.append(id, err.into()); diff --git a/tests/src/system/quota.rs b/tests/src/system/quota.rs index 1fcdf536..9ec0c120 100644 --- a/tests/src/system/quota.rs +++ b/tests/src/system/quota.rs @@ -26,7 +26,7 @@ use registry::{ }, types::{EnumImpl, list::List, map::Map}, }; -use serde_json::json; +use serde_json::{Value, json}; use types::id::Id; use utils::map::vec_map::VecMap; @@ -440,6 +440,18 @@ pub async fn test(test: &mut TestServer) { 0 ); + // inbuxa: calendar events, contacts and files count towards the quota of + // the account they are written into, all the items of a request together + groupware_quota(test, &account, &other_account).await; + test.wait_for_tasks().await; + assert_eq!( + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap(), + 0 + ); + // Test delivery quota let mut lmtp = SmtpConnection::connect().await; for i in 0..2 { @@ -506,6 +518,242 @@ pub async fn test(test: &mut TestServer) { test.cleanup().await; } +async fn groupware_quota(test: &TestServer, account: &Account, other_account: &Account) { + let other_id = other_account.id_string().to_string(); + let used = || async { + test.server + .get_used_quota_account(account.id().document_id()) + .await + .unwrap() as usize + }; + // The size of an item that fits the quota alone but not twice over + let half_size = + |used: usize, overhead: usize| ((1024 - used) / 2 + 50).saturating_sub(overhead); + + // A sharee writing into the account is held to the account's quota + let calendar_id = account + .jmap_create( + "Calendar", + [json!({ + "name": "Quota", + "shareWith": { &other_id: { "mayReadItems": true, "mayWriteAll": true } } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let event = |uid: &str, size: usize| { + json!({ + "@type": "Event", + "uid": uid, + "title": "Quota", + "description": "x".repeat(size), + "start": "2026-01-15T13:00:00", + "duration": "PT1H", + "calendarIds": { &calendar_id: true }, + }) + }; + assert_eq!( + other_account + .jmap_create_account( + account, + "CalendarEvent", + [event("quota-shared", 1200)], + Vec::<(&str, &str)>::new(), + ) + .await + .not_created(0) + .typ(), + "overQuota" + ); + + // Items created together count together + let before = used().await; + let small_id = account + .jmap_create( + "CalendarEvent", + [event("quota-0", 0)], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let overhead = used().await - before; + account + .jmap_destroy( + "CalendarEvent", + [small_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + let size = half_size(used().await, overhead); + let response = account + .jmap_create( + "CalendarEvent", + [event("quota-1", size), event("quota-2", size)], + Vec::<(&str, &str)>::new(), + ) + .await; + response.created(0); + assert_eq!(response.not_created(1).typ(), "overQuota"); + account.destroy_all_calendars().await; + + let book_id = account + .jmap_create( + "AddressBook", + [json!({ "name": "Quota" })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let contact = |uid: &str, size: usize| { + json!({ + "uid": uid, + "name": { "full": "Quota" }, + "notes": { "n1": { "note": "x".repeat(size) } }, + "addressBookIds": { &book_id: true }, + }) + }; + let before = used().await; + let small_id = account + .jmap_create( + "ContactCard", + [contact("quota-0", 1)], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let overhead = used().await - before; + account + .jmap_destroy( + "ContactCard", + [small_id.as_str()], + Vec::<(&str, &str)>::new(), + ) + .await + .destroyed() + .for_each(drop); + let size = half_size(used().await, overhead); + let response = account + .jmap_create( + "ContactCard", + [contact("quota-1", size), contact("quota-2", size)], + Vec::<(&str, &str)>::new(), + ) + .await; + response.created(0); + assert_eq!(response.not_created(1).typ(), "overQuota"); + account.destroy_all_addressbooks().await; + + // Files count towards the quota, created or copied in + let file_with_blob = |owner: &Account, name: &str, size: usize, extra: Value| { + let mut file = json!({ "name": name, "parentId": null, "blobId": "#file" }); + if let (Value::Object(file), Value::Object(extra)) = (&mut file, extra) { + file.extend(extra); + } + json!([ + ["Blob/upload", { + "accountId": owner.id_string(), + "create": { "file": { "data": [{ "data:asText": "f".repeat(size) }] } } + }, "0"], + ["FileNode/set", { + "accountId": owner.id_string(), + "create": { "i0": file } + }, "1"] + ]) + }; + let response = account + .jmap_method_calls(file_with_blob(account, "small.txt", 100, json!({}))) + .await + .into_inner(); + assert!( + response + .pointer("/methodResponses/1/1/created/i0/id") + .is_some(), + "{response}" + ); + let response = account + .jmap_method_calls(file_with_blob(account, "big.txt", 2000, json!({}))) + .await + .into_inner(); + assert_eq!( + response.pointer("/methodResponses/1/1/notCreated/i0/type"), + Some(&json!("overQuota")), + "{response}" + ); + + // (the upload limit is three files a second) + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let account_id = account.id_string().to_string(); + let folder_id = other_account + .jmap_create( + "FileNode", + [json!({ + "name": "shared", + "parentId": null, + "shareWith": { &account_id: { "mayRead": true } } + })], + Vec::<(&str, &str)>::new(), + ) + .await + .created(0) + .id() + .to_string(); + let response = other_account + .jmap_method_calls(file_with_blob( + other_account, + "big.txt", + 2000, + json!({ "parentId": &folder_id }), + )) + .await + .into_inner(); + let file_id = response + .pointer("/methodResponses/1/1/created/i0/id") + .and_then(|id| id.as_str()) + .unwrap_or_else(|| panic!("{response}")) + .to_string(); + let response = account + .jmap_copy( + other_account, + account, + "FileNode", + [(&file_id, json!({ "parentId": null, "name": "copied.txt" }))], + false, + ) + .await; + assert_eq!( + response.method_response()["notCreated"][&file_id].typ(), + "overQuota" + ); + + for owner in [account, other_account] { + owner + .jmap_method_calls(json!([ + ["FileNode/get", { + "accountId": owner.id_string(), + "ids": null, + "properties": ["id"] + }, "R1"], + ["FileNode/set", { + "accountId": owner.id_string(), + "#destroy": { "resultOf": "R1", "name": "FileNode/get", "path": "/list/*/id" }, + "onDestroyRemoveChildren": true + }, "R2"] + ])) + .await; + } +} + fn assert_over_quota(result: Result) { match result { Ok(result) => panic!("Expected error, got {:?}", result), -- 2.54.0 From dab2327f0fcd8b16a8398d07d264a2c9127f0c1c Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:52:53 -0700 Subject: [PATCH 31/35] Mail: compare a new message with at most 512 others when threading it To find a new message's thread, delivery read every stored message with the same thread name (or, for messages without a subject, every subject-less message) and compared their references. Anyone able to send mail could make each delivery to an account slower than the last by sending it many messages with one subject or none. A new message is now compared with the newest 512 messages sharing its thread name. A reply whose thread has no message among those starts a new thread. Duplicate detection looks at the same messages. Test: after 513 messages with one subject, a reply to the newest still joins its thread, and a reply to the oldest (now outside the window) starts a new one; on main it was still compared with all of them. --- crates/email/src/message/ingest.rs | 16 ++++++++- tests/src/jmap/mail/thread_merge.rs | 52 +++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/crates/email/src/message/ingest.rs b/crates/email/src/message/ingest.rs index fd45befb..68fa6b08 100644 --- a/crates/email/src/message/ingest.rs +++ b/crates/email/src/message/ingest.rs @@ -749,6 +749,7 @@ impl EmailIngest for Server { let key_len = IndexKeyPrefix::len() + result.thread_hash.len() + U32_LEN; let document_id_pos = key_len - U32_LEN; let mut thread_merge = ThreadMerge::new(); + let mut candidates_left = THREAD_SCAN_LIMIT; self.store() .iterate( IterateParams::new( @@ -771,9 +772,16 @@ impl EmailIngest for Server { }), }, ) - .ascending(), + .descending(), |key, value| { if key.len() == key_len { + // inbuxa: only the newest messages with this thread name + // are candidates + if candidates_left == 0 { + return Ok(false); + } + candidates_left -= 1; + // Find matching references let references = value.get(U32_LEN..).unwrap_or_default(); @@ -963,6 +971,12 @@ impl EmailIngest for Server { } } +// inbuxa: how many of the newest messages sharing a thread name (or sharing +// none) a new message is compared with. Every delivery used to read all of +// them, so mail sent with one subject over and over made each delivery to that +// account slower than the last. +pub const THREAD_SCAN_LIMIT: usize = 512; + pub fn has_message_id(a: &[CheekyHash], b: &[u8]) -> bool { let mut i = 0; let mut j = 0; diff --git a/tests/src/jmap/mail/thread_merge.rs b/tests/src/jmap/mail/thread_merge.rs index b7ae5fab..aedf5e2f 100644 --- a/tests/src/jmap/mail/thread_merge.rs +++ b/tests/src/jmap/mail/thread_merge.rs @@ -31,6 +31,58 @@ use types::id::Id; pub async fn test(test: &TestServer) { test_single_thread(test).await; test_multi_thread(test).await; + test_scan_limit(test).await; +} + +// inbuxa: a new message is compared with the newest THREAD_SCAN_LIMIT messages +// sharing its thread name, not with every one of them +async fn test_scan_limit(test: &TestServer) { + println!("Running Email thread scan limit tests..."); + let account = test.account("jdoe@example.com"); + let client = account.jmap_client().await; + let inbox_id = Id::from(INBOX_ID).to_string(); + let import = |message: String| { + let client = &client; + let inbox_id = &inbox_id; + async move { + let id = client + .email_import( + message.into_bytes(), + [inbox_id.clone()], + None::>, + None, + ) + .await + .unwrap() + .take_id(); + Id::from_str(&id).unwrap().prefix_id() + } + }; + + let oldest_thread = import("Message-ID: \nSubject: Same\n\nm\n".into()).await; + let mut newest_thread = 0; + for i in 1..=::email::message::ingest::THREAD_SCAN_LIMIT { + newest_thread = import(format!("Message-ID: \nSubject: Same\n\nm\n")).await; + } + let newest = ::email::message::ingest::THREAD_SCAN_LIMIT; + assert_eq!( + import(format!( + "Message-ID: \nReferences: \nSubject: re: Same\n\nr\n" + )) + .await, + newest_thread + ); + assert_ne!( + import( + "Message-ID: \nReferences: \nSubject: re: Same\n\nr\n".into() + ) + .await, + oldest_thread + ); + + test.destroy_all_mailboxes(account).await; + test.wait_for_tasks().await; + test.assert_is_empty().await; } async fn test_single_thread(test_server: &TestServer) { -- 2.54.0 From a00ff9cf84129b4f80b9cd5708ceca54b91482c3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:52:53 -0700 Subject: [PATCH 32/35] Calendar: cut components nested more than 32 deep before converting events Components the JSCalendar converter doesn't know are carried along as nested jCal. A signed-in user could upload an iCalendar file with an unknown component nested tens of thousands deep (about nine bytes per level) and ask CalendarEvent/parse for it, or store such an event over CalDAV and fetch it through JMAP; building and serializing the nested value overflowed the worker's stack and aborted the whole server. Before an event is converted for CalendarEvent/get, /parse, /set and /copy, components nested more than 32 levels below the VCALENDAR are dropped. Test: converting an event nested 200,000 deep on a 2 MiB stack aborted with a stack overflow on main; it now keeps at most 32 levels, and CalendarEvent/parse of a blob nested 100,000 deep returns the event. --- crates/groupware/src/calendar/mod.rs | 78 +++++++++++++++++++++++++ crates/jmap/src/calendar_event/copy.rs | 3 +- crates/jmap/src/calendar_event/get.rs | 4 +- crates/jmap/src/calendar_event/parse.rs | 7 ++- crates/jmap/src/calendar_event/set.rs | 2 + tests/src/jmap/calendar/event.rs | 33 +++++++++++ 6 files changed, 124 insertions(+), 3 deletions(-) diff --git a/crates/groupware/src/calendar/mod.rs b/crates/groupware/src/calendar/mod.rs index 0ade961c..c61074b2 100644 --- a/crates/groupware/src/calendar/mod.rs +++ b/crates/groupware/src/calendar/mod.rs @@ -440,3 +440,81 @@ impl From for ICalendarComponentType { } } } + +// inbuxa: how deep components may nest below the VCALENDAR when an event is +// converted to JSCalendar. Components the converter doesn't know (X- ones, for +// example) are carried along as nested jCal, and nesting tens of thousands +// deep built a value that overflowed the stack when it was dropped or +// serialized, which aborted the server. +pub const MAX_COMPONENT_DEPTH: usize = 32; + +/// Drops whatever is nested below `MAX_COMPONENT_DEPTH`, returning whether +/// anything was. +pub fn limit_component_depth(ical: &mut ICalendar) -> bool { + let mut level = vec![0u32]; + for _ in 0..MAX_COMPONENT_DEPTH { + level = level + .iter() + .filter_map(|id| ical.components.get(*id as usize)) + .flat_map(|component| component.component_ids.iter().copied()) + .collect(); + if level.is_empty() { + return false; + } + } + + let mut truncated = false; + for id in level { + if let Some(component) = ical.components.get_mut(id as usize) + && !component.component_ids.is_empty() + { + component.component_ids.clear(); + truncated = true; + } + } + truncated +} + +#[cfg(test)] +mod tests { + use super::{MAX_COMPONENT_DEPTH, limit_component_depth}; + use calcard::{icalendar::ICalendar, jscalendar::import::ConversionOptions}; + + fn nested(depth: usize) -> String { + let mut ical = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:deep\r\n".to_string(); + ical.push_str(&"BEGIN:X-DEEP\r\n".repeat(depth)); + ical.push_str(&"END:X-DEEP\r\n".repeat(depth)); + ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n"); + ical + } + + #[test] + fn deep_components_are_cut_before_conversion() { + let shallow = nested(4); + let mut ical = ICalendar::parse(&shallow).unwrap(); + assert!(!limit_component_depth(&mut ical)); + assert_eq!(ical, ICalendar::parse(&shallow).unwrap()); + + // Converting and dropping the result runs on a small stack, as the + // server's workers do + let deep = nested(200_000); + let jcal = std::thread::Builder::new() + .stack_size(2 * 1024 * 1024) + .spawn(move || { + let mut ical = ICalendar::parse(&deep).unwrap(); + assert!(limit_component_depth(&mut ical)); + let jcal = serde_json::to_string( + &ical.into_jscalendar_with_opt::(ConversionOptions::default()), + ) + .unwrap(); + jcal.matches("x-deep").count() + }) + .unwrap() + .join() + .unwrap(); + assert!( + (1..=MAX_COMPONENT_DEPTH).contains(&jcal), + "{jcal} levels kept" + ); + } +} diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index 9ebfe1e8..96b5c033 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -18,7 +18,7 @@ use common::{ use groupware::{ cache::GroupwareCache, calendar::{ - CalendarEvent, + CalendarEvent, limit_component_depth, privacy::{event_view, hide_attendees}, }, }; @@ -178,6 +178,7 @@ impl JmapCalendarEventCopy for Server { let mut calendar_event = _calendar_event .deserialize::() .caused_by(trc::location!())?; + limit_component_depth(&mut calendar_event.data.event); // inbuxa: a sharee can't copy out what it may not see if !is_owner { diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index 72677c87..a6bcfcc4 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -25,6 +25,7 @@ use groupware::{ CalendarEvent, EVENT_DRAFT, EVENT_HIDE_ATTENDEES, EVENT_INVITE_OTHERS, EVENT_INVITE_SELF, PREF_USE_DEFAULT_ALERTS, expand::CalendarEventExpansion, + limit_component_depth, privacy::{event_view, hide_attendees, redact_private_event}, }, }; @@ -496,7 +497,7 @@ impl CalendarEventGet for Server { results.push((Id::from(document_id), event, expansion)); } - for (id, ical, expansion) in results { + for (id, mut ical, expansion) in results { let is_origin = return_is_origin.as_ref().is_some_and(|account| { ical.components .iter() @@ -511,6 +512,7 @@ impl CalendarEventGet for Server { }) }); + limit_component_depth(&mut ical); let jscal = ical .into_jscalendar_with_opt::( ConversionOptions::default() diff --git a/crates/jmap/src/calendar_event/parse.rs b/crates/jmap/src/calendar_event/parse.rs index b6507aa0..0646947a 100644 --- a/crates/jmap/src/calendar_event/parse.rs +++ b/crates/jmap/src/calendar_event/parse.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::blob::download::BlobDownload; @@ -10,6 +12,7 @@ use calcard::{ jscalendar::{JSCalendarProperty, import::ConversionOptions}, }; use common::{Server, auth::AccessToken}; +use groupware::calendar::limit_component_depth; use jmap_proto::{ method::parse::{ParseRequest, ParseResponse}, object::calendar_event::CalendarEvent, @@ -58,11 +61,13 @@ impl CalendarEventParse for Server { continue; } }; - let Ok(vcard) = ICalendar::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) + let Ok(mut vcard) = + ICalendar::parse(std::str::from_utf8(&raw_vcard).unwrap_or_default()) else { response.not_parsable.push(blob_id); continue; }; + limit_component_depth(&mut vcard); let mut js_calendar_entries = vcard .into_jscalendar_with_opt::(ConversionOptions::default()) .into_inner() diff --git a/crates/jmap/src/calendar_event/set.rs b/crates/jmap/src/calendar_event/set.rs index cb9a7007..3e61c8fe 100644 --- a/crates/jmap/src/calendar_event/set.rs +++ b/crates/jmap/src/calendar_event/set.rs @@ -33,6 +33,7 @@ use groupware::{ dates::has_oversized_rrule, expand::{CalendarEventExpansion, ComponentRecurrenceId, RecurrenceKey, resolve_local}, itip::ItipSendStatus, + limit_component_depth, privacy::itip_messages_for, }, scheduling::{ @@ -295,6 +296,7 @@ impl CalendarEventSet for Server { } } + limit_component_depth(&mut new_calendar_event.data.event); let mut js_calendar_group = std::mem::take(&mut new_calendar_event.data.event).into_jscalendar::(); diff --git a/tests/src/jmap/calendar/event.rs b/tests/src/jmap/calendar/event.rs index 3d74b891..1778de7f 100644 --- a/tests/src/jmap/calendar/event.rs +++ b/tests/src/jmap/calendar/event.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::{ @@ -707,6 +709,37 @@ END:VCALENDAR } ])); + // inbuxa: components nested far below an event are cut at a fixed depth + // instead of being converted level by level (which overflowed the stack) + let depth = 100_000; + let deep = format!( + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:deep\r\nDTSTART:20260101T100000Z\r\n{}{}END:VEVENT\r\nEND:VCALENDAR\r\n", + "BEGIN:X-DEEP\r\n".repeat(depth), + "END:X-DEEP\r\n".repeat(depth) + ); + let response = account + .jmap_method_calls(json!([ + ["Blob/upload", { + "accountId": account.id_string(), + "create": { "deep": { "data": [{ "data:asText": deep }] } } + }, "S1"], + ["CalendarEvent/parse", { + "accountId": account.id_string(), + "blobIds": ["#deep"] + }, "P1"] + ])) + .await + .into_inner(); + let parsed = response + .pointer("/methodResponses/1/1/parsed") + .and_then(|parsed| parsed.as_object()) + .and_then(|parsed| parsed.values().next()) + .unwrap_or_else(|| panic!("{}", response.to_string().len())) + .to_string(); + assert!(parsed.contains("\"uid\":\"deep\""), "{parsed}"); + let levels = parsed.matches("x-deep").count(); + assert!((1..=32).contains(&levels), "{levels} levels"); + // Deletion tests test.wait_for_tasks().await; assert_eq!( -- 2.54.0 From 70bac69c2e851663c53f74a3db36d6f4a9037bdb Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 20:52:54 -0700 Subject: [PATCH 33/35] Sieve: hold regexes to 1 MiB and compile variable patterns once per run Sieve :regex patterns were compiled with the regex library's default 10 MiB size limit, so every :regex key in a user's script could hold that much memory for as long as the script was cached. A pattern built from variables ("${x}") was compiled again for every header or value it was tested against: a user's script tested against a message with many headers spent seconds of CPU on each delivery, and anyone could send that message. Patterns now compile to at most 1 MiB (the matcher and its lazy DFA each); a script with a larger one fails to compile with "Invalid regular expression". Patterns built from variables are compiled once per script run (up to 32 kept), and a pattern that fails to compile no longer trips a debug assertion. Test: on main a script with ^\w{200}$ compiled, and a variable pattern tested against 1,000 headers took 16.7 s against 18 ms for one header; the script is now refused and 1,000 headers take as long as one. --- crates/common/src/config/mailstore/scripts.rs | 48 ++++++++++++++ vendor/sieve-rs/VENDORED.md | 9 +++ vendor/sieve-rs/src/compiler/grammar/mod.rs | 2 +- vendor/sieve-rs/src/lib.rs | 1 + .../src/runtime/actions/action_editheader.rs | 3 + vendor/sieve-rs/src/runtime/context.rs | 4 ++ .../sieve-rs/src/runtime/tests/comparator.rs | 65 ++++++++++++++++--- .../src/runtime/tests/test_address.rs | 3 + .../sieve-rs/src/runtime/tests/test_body.rs | 26 +++++--- .../src/runtime/tests/test_envelope.rs | 3 + .../sieve-rs/src/runtime/tests/test_header.rs | 3 + .../src/runtime/tests/test_metadata.rs | 3 + .../sieve-rs/src/runtime/tests/test_notify.rs | 14 ++-- .../src/runtime/tests/test_spamtest.rs | 4 ++ .../sieve-rs/src/runtime/tests/test_string.rs | 3 + 15 files changed, 169 insertions(+), 22 deletions(-) diff --git a/crates/common/src/config/mailstore/scripts.rs b/crates/common/src/config/mailstore/scripts.rs index 4067603f..7a34e0f1 100644 --- a/crates/common/src/config/mailstore/scripts.rs +++ b/crates/common/src/config/mailstore/scripts.rs @@ -300,4 +300,52 @@ mod tests { assert_eq!(Capability::parse(name), capability); } } + + // inbuxa: a :regex key compiles to at most 1 MiB; it could take ten + #[test] + fn sieve_regexes_are_size_limited() { + let compile = |pattern: &str| { + sieve::Compiler::new().compile( + format!( + "require \"regex\";\nif header :regex \"subject\" \"{pattern}\" {{ keep; }}\n" + ) + .as_bytes(), + ) + }; + assert!(compile(r"^\\w{20}$").is_ok()); + let err = compile(r"^\\w{200}$").unwrap_err().to_string(); + assert!(err.contains("Invalid regular expression"), "{err}"); + } + + // inbuxa: a pattern built from a variable is compiled once per run, not + // once for every header it's tested against + #[test] + fn sieve_variable_patterns_compile_once() { + let script = std::sync::Arc::new( + sieve::Compiler::new() + .compile( + br#"require ["regex", "variables"]; +set "p" "^\\w{20}x$"; +if header :regex "x-h" "${p}" { discard; } +"#, + ) + .unwrap(), + ); + let runtime = sieve::Runtime::new(); + let run = |headers: usize| { + let mut message = "X-H: value\r\n".repeat(headers); + message.push_str("Subject: test\r\n\r\nbody\r\n"); + let start = std::time::Instant::now(); + let mut ctx = runtime.filter(message.as_bytes()); + let mut input = sieve::Input::script("test", script.clone()); + while let Some(result) = ctx.run(input) { + result.unwrap(); + input = sieve::Input::True; + } + start.elapsed() + }; + let one = run(1); + let many = run(1000); + assert!(many < one * 10, "1 header: {one:?}, 1000 headers: {many:?}"); + } } diff --git a/vendor/sieve-rs/VENDORED.md b/vendor/sieve-rs/VENDORED.md index d5e40258..42c3fab5 100644 --- a/vendor/sieve-rs/VENDORED.md +++ b/vendor/sieve-rs/VENDORED.md @@ -20,6 +20,15 @@ Every changed file carries the AGPL 5(a) notice in its header. (the server sets its own name on both of its runtimes anyway), and the test-only capability is `vnd.inbuxa.testsuite`. - `src/lib.rs`: the test-suite environment variables are `vnd.inbuxa.*`. +- `src/runtime/tests/comparator.rs`, `src/compiler/grammar/mod.rs`: every + `:regex` pattern is built through `build_regex`, which holds the compiled + matcher and its lazy DFA to 1 MiB each (fancy-regex's default is 10 MiB), + and a pattern built from variables is compiled once per run (`RegexCache`, + 32 patterns, a field of `Context` in `src/lib.rs` and + `src/runtime/context.rs`) instead of once for every value it is tested + against. Every caller of `Comparator::regex` passes the cache. The + `sieve_regexes_are_size_limited` and `sieve_variable_patterns_compile_once` + tests in `crates/common` cover both. - `Cargo.toml`: the example target is dropped, with the top-level `examples/` and `tests/`, which the server never builds. (`src/` keeps its own `tests` modules: the crate declares them.) diff --git a/vendor/sieve-rs/src/compiler/grammar/mod.rs b/vendor/sieve-rs/src/compiler/grammar/mod.rs index f9f4c187..855875bb 100644 --- a/vendor/sieve-rs/src/compiler/grammar/mod.rs +++ b/vendor/sieve-rs/src/compiler/grammar/mod.rs @@ -487,7 +487,7 @@ impl CompilerState<'_> { if matches!(match_type, MatchType::Regex(_)) { for key in key_list { if let Value::Text(expr) = key { - match fancy_regex::Regex::new(expr) { + match crate::runtime::tests::comparator::build_regex(expr) { Ok(regex) => { *key = Value::Regex(Regex::new(expr.to_string(), regex)); } diff --git a/vendor/sieve-rs/src/lib.rs b/vendor/sieve-rs/src/lib.rs index da509b56..0ad11608 100644 --- a/vendor/sieve-rs/src/lib.rs +++ b/vendor/sieve-rs/src/lib.rs @@ -137,6 +137,7 @@ pub struct Context<'x> { pub(crate) num_redirects: usize, pub(crate) num_instructions: usize, pub(crate) num_out_messages: usize, + pub(crate) regex_cache: runtime::tests::comparator::RegexCache, } #[derive(Debug, Clone, Eq, PartialEq, Hash)] diff --git a/vendor/sieve-rs/src/runtime/actions/action_editheader.rs b/vendor/sieve-rs/src/runtime/actions/action_editheader.rs index 6c4c88b3..a124cf6a 100644 --- a/vendor/sieve-rs/src/runtime/actions/action_editheader.rs +++ b/vendor/sieve-rs/src/runtime/actions/action_editheader.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::borrow::Cow; @@ -91,6 +93,7 @@ impl DeleteHeader { &mut Vec::new(), ), MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/context.rs b/vendor/sieve-rs/src/runtime/context.rs index d48fcf5c..64a6c4ff 100644 --- a/vendor/sieve-rs/src/runtime/context.rs +++ b/vendor/sieve-rs/src/runtime/context.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::{borrow::Cow, sync::Arc, time::SystemTime}; @@ -70,6 +72,7 @@ impl<'x> Context<'x> { num_redirects: 0, num_instructions: 0, num_out_messages: 0, + regex_cache: Default::default(), last_message_id: 0, main_message_id: 0, virus_status: VirusStatus::Unknown, @@ -680,6 +683,7 @@ impl<'x> Context<'x> { num_redirects: 0, num_instructions: 0, num_out_messages: 0, + regex_cache: Default::default(), last_message_id: 0, main_message_id: 0, virus_status: VirusStatus::Unknown, diff --git a/vendor/sieve-rs/src/runtime/tests/comparator.rs b/vendor/sieve-rs/src/runtime/tests/comparator.rs index c8e6a551..21b92e50 100644 --- a/vendor/sieve-rs/src/runtime/tests/comparator.rs +++ b/vendor/sieve-rs/src/runtime/tests/comparator.rs @@ -2,9 +2,11 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ -use std::{borrow::Cow, sync::Arc}; +use std::{borrow::Cow, cell::RefCell, sync::Arc}; use crate::{ MatchAs, @@ -17,6 +19,50 @@ use crate::{ use super::glob::GlobPattern; +// inbuxa: the most a compiled pattern may take (each of the matcher and its +// lazy DFA cache); the default of 10 MiB let every :regex key of a stored +// script hold that much +pub(crate) const REGEX_SIZE_LIMIT: usize = 1024 * 1024; + +pub(crate) fn build_regex(expr: &str) -> Result { + fancy_regex::RegexBuilder::new(expr) + .delegate_size_limit(REGEX_SIZE_LIMIT) + .delegate_dfa_size_limit(REGEX_SIZE_LIMIT) + .build() +} + +// inbuxa: patterns built from variables ("${x}") are compiled once per run, not +// once for every value they are tested against +const REGEX_CACHE_SIZE: usize = 32; + +#[derive(Debug, Clone, Default)] +pub(crate) struct RegexCache(RefCell)>>); + +impl RegexCache { + fn eval( + &self, + expr: &str, + value: &str, + capture_positions: u64, + captured_values: &mut Vec<(usize, String)>, + ) -> bool { + let mut cache = self.0.borrow_mut(); + let idx = if let Some(idx) = cache.iter().position(|(cached, _)| cached == expr) { + idx + } else { + if cache.len() == REGEX_CACHE_SIZE { + cache.remove(0); + } + cache.push((expr.to_string(), build_regex(expr).ok())); + cache.len() - 1 + }; + cache[idx] + .1 + .as_ref() + .is_some_and(|regex| eval_regex(regex, value, capture_positions, captured_values)) + } +} + pub(crate) trait Comparable { fn to_str(&'_ self) -> Cow<'_, str>; fn to_number(&self) -> Number; @@ -68,8 +114,10 @@ impl Comparator { } } + #[allow(clippy::too_many_arguments)] pub(crate) fn regex( &self, + cache: &RegexCache, pattern: &Value, pattern_expr: &Variable, value: &str, @@ -81,7 +129,7 @@ impl Comparator { if let Some(regex) = lazy_regex.as_ref() { eval_regex(regex, value, capture_positions, captured_values) } else { - match fancy_regex::Regex::new(®ex.expr) { + match build_regex(®ex.expr) { Ok(fancy_regex) => { let result = eval_regex(&fancy_regex, value, capture_positions, captured_values); @@ -95,13 +143,12 @@ impl Comparator { } } } else { - match fancy_regex::Regex::new(pattern_expr.to_string().as_ref()) { - Ok(regex) => eval_regex(®ex, value, capture_positions, captured_values), - Err(err) => { - debug_assert!(false, "Failed to compile regex: {err:?}"); - false - } - } + cache.eval( + pattern_expr.to_string().as_ref(), + value, + capture_positions, + captured_values, + ) } } diff --git a/vendor/sieve-rs/src/runtime/tests/test_address.rs b/vendor/sieve-rs/src/runtime/tests/test_address.rs index 827b07e3..13cbe48c 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_address.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_address.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::{ @@ -90,6 +92,7 @@ impl TestAddress { return true; } } else if self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_body.rs b/vendor/sieve-rs/src/runtime/tests/test_body.rs index 79e5d4b3..4ab03c6b 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_body.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_body.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::{MimeHeaders, PartType, decoders::html::html_to_text}; @@ -45,10 +47,14 @@ impl TestBody { 0, &mut Vec::new(), ), - MatchType::Regex(_) => { - self.comparator - .regex(pattern, key, subject, 0, &mut Vec::new()) - } + MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, + pattern, + key, + subject, + 0, + &mut Vec::new(), + ), _ => break, }; @@ -203,10 +209,14 @@ impl TestBody { 0, &mut Vec::new(), ), - MatchType::Regex(_) => { - self.comparator - .regex(pattern, key, text.as_ref(), 0, &mut Vec::new()) - } + MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, + pattern, + key, + text.as_ref(), + 0, + &mut Vec::new(), + ), _ => false, }; diff --git a/vendor/sieve-rs/src/runtime/tests/test_envelope.rs b/vendor/sieve-rs/src/runtime/tests/test_envelope.rs index 57f33247..7eb6921c 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_envelope.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_envelope.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::DateTime; @@ -63,6 +65,7 @@ impl TestEnvelope { return true; } } else if self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_header.rs b/vendor/sieve-rs/src/runtime/tests/test_header.rs index 64b188ad..58ddd483 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_header.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_header.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use mail_parser::{Header, HeaderName, HeaderValue, parsers::MessageStream}; @@ -89,6 +91,7 @@ impl TestHeader { return true; } } else if self.comparator.regex( + &ctx.regex_cache, pattern, pattern_expr, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_metadata.rs b/vendor/sieve-rs/src/runtime/tests/test_metadata.rs index a1ca0308..012a94ae 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_metadata.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_metadata.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -81,6 +83,7 @@ impl TestMetadata { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, pattern, &key, value, diff --git a/vendor/sieve-rs/src/runtime/tests/test_notify.rs b/vendor/sieve-rs/src/runtime/tests/test_notify.rs index 9504b190..60079880 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_notify.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_notify.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use std::borrow::Cow; @@ -83,10 +85,14 @@ impl TestNotifyMethodCapability { 0, &mut Vec::new(), ), - MatchType::Regex(_) => { - self.comparator - .regex(pattern, &key, "maybe", 0, &mut Vec::new()) - } + MatchType::Regex(_) => self.comparator.regex( + &ctx.regex_cache, + pattern, + &key, + "maybe", + 0, + &mut Vec::new(), + ), _ => false, } { return TestResult::Bool(true ^ self.is_not); diff --git a/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs b/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs index a05102b7..d0958e42 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_spamtest.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -41,6 +43,7 @@ impl TestSpamTest { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, &self.value, &value, status.to_string().as_ref(), @@ -85,6 +88,7 @@ impl TestVirusTest { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, &self.value, &value, status.to_string().as_ref(), diff --git a/vendor/sieve-rs/src/runtime/tests/test_string.rs b/vendor/sieve-rs/src/runtime/tests/test_string.rs index 098594c7..e918ed79 100644 --- a/vendor/sieve-rs/src/runtime/tests/test_string.rs +++ b/vendor/sieve-rs/src/runtime/tests/test_string.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -79,6 +81,7 @@ impl TestString { &mut captured_values, ), MatchType::Regex(capture_positions) => self.comparator.regex( + &ctx.regex_cache, pattern, &key, source.to_string().as_ref(), -- 2.54.0 From c242f88e4bc71fdf0660ad2fbf7466ae796b7219 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 21:53:04 -0700 Subject: [PATCH 34/35] Calendar: show sharees only the time of a confidential event, as of a private one A confidential event (CLASS:CONFIDENTIAL) was treated as secret: for anyone the calendar was shared with it didn't exist. CalDAV clients such as Thunderbird use CONFIDENTIAL for "show time and date only", so their users' events vanished from sharees' calendars instead of showing as busy time. Events are stored as iCalendar, where a JMAP "secret" event is also CLASS:CONFIDENTIAL, so the two can't be told apart. A confidential event, whether set over CalDAV or as JMAP "secret", now shows sharees what a private event does: its time, recurrence and basic metadata only. Matching it on title, description, location or participants finds nothing, copying it out is refused, and its blob is not downloadable by sharees. The owner still sees all of it. Test: a sharee got notFound for a confidential event and couldn't list it; now it is listed and shows its start and duration only, with no title, description or participants, isn't found by title, and can't be copied, as for a private event. --- crates/common/src/lib.rs | 6 +- crates/common/src/sharing/resources.rs | 7 +- crates/groupware/src/calendar/privacy.rs | 21 ++-- crates/jmap/src/blob/download.rs | 5 +- crates/jmap/src/calendar_event/copy.rs | 2 +- crates/jmap/src/calendar_event/get.rs | 9 +- crates/jmap/src/calendar_event/query.rs | 6 +- crates/jmap/src/changes/get.rs | 2 +- tests/src/jmap/calendar/acl.rs | 151 +++++++++++------------ 9 files changed, 100 insertions(+), 109 deletions(-) diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 48a911a4..ee34a61a 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -395,10 +395,10 @@ pub enum DavResourceMetadata { }, } -// inbuxa: what a sharee may see of a calendar event +// inbuxa: what a sharee may see of a calendar event: the time only of a +// private or confidential one, and not the attendees of one that hides them pub const EVENT_VIEW_PRIVATE: u8 = 1; -pub const EVENT_VIEW_SECRET: u8 = 1 << 1; -pub const EVENT_VIEW_HIDE_ATTENDEES: u8 = 1 << 2; +pub const EVENT_VIEW_HIDE_ATTENDEES: u8 = 1 << 1; #[derive(Debug, Clone, Default)] pub struct TinyCalendarPreferences { diff --git a/crates/common/src/sharing/resources.rs b/crates/common/src/sharing/resources.rs index f6cc2029..78ab0960 100644 --- a/crates/common/src/sharing/resources.rs +++ b/crates/common/src/sharing/resources.rs @@ -6,7 +6,7 @@ * Modified by Coffey Labs in 2026 for INBUXA. */ -use crate::{DavResources, EVENT_VIEW_SECRET, auth::AccessToken}; +use crate::{DavResources, auth::AccessToken}; use store::roaring::RoaringBitmap; use types::acl::Acl; use utils::map::bitmap::Bitmap; @@ -76,8 +76,7 @@ impl DavResources { } /// inbuxa: the shared items a sharee may see, without the calendar events - /// whose view has any of `hidden` (EVENT_VIEW_*). Secret events are always - /// left out. + /// whose view has any of `hidden` (EVENT_VIEW_*). pub fn shared_events( &self, access_token: &AccessToken, @@ -87,7 +86,7 @@ impl DavResources { ) -> RoaringBitmap { let mut document_ids = self.shared_items(access_token, check_acls, match_any); if !document_ids.is_empty() { - document_ids -= self.events_with_view(hidden | EVENT_VIEW_SECRET); + document_ids -= self.events_with_view(hidden); } document_ids } diff --git a/crates/groupware/src/calendar/privacy.rs b/crates/groupware/src/calendar/privacy.rs index 55c69b5b..cafac743 100644 --- a/crates/groupware/src/calendar/privacy.rs +++ b/crates/groupware/src/calendar/privacy.rs @@ -4,9 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -//! What a sharee of a calendar may see of an event: nothing of a secret -//! (CLASS:CONFIDENTIAL) event, the time only of a private one, and only the -//! owners and themselves among the attendees of an event that hides them. +//! What a sharee of a calendar may see of an event: the time only of a +//! private or confidential one, and only the owners and themselves among the +//! attendees of an event that hides them. use super::{ArchivedCalendarEvent, CalendarEvent, EVENT_HIDE_ATTENDEES}; use crate::scheduling::{ItipField, ItipMessage, ItipSummary, ItipValue}; @@ -15,7 +15,7 @@ use calcard::icalendar::{ ICalendarClassification, ICalendarComponentType, ICalendarParameterName, ICalendarParameterValue, ICalendarParticipationRole, ICalendarProperty, ICalendarValue, }; -use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE, EVENT_VIEW_SECRET}; +use common::{EVENT_VIEW_HIDE_ATTENDEES, EVENT_VIEW_PRIVATE}; pub fn event_view(event: &CalendarEvent) -> u8 { let mut view = 0; @@ -30,10 +30,8 @@ pub fn event_view(event: &CalendarEvent) -> u8 { { view |= match entry.values.first() { Some(ICalendarValue::Classification(ICalendarClassification::Public)) | None => 0, - Some(ICalendarValue::Classification(ICalendarClassification::Confidential)) => { - EVENT_VIEW_SECRET - } - // An unknown class counts as private + // Confidential (what CalDAV clients use for "time only") and an + // unknown class count as private _ => EVENT_VIEW_PRIVATE, }; } @@ -59,9 +57,6 @@ pub fn archived_event_view(event: &ArchivedCalendarEvent) -> u8 { ArchivedICalendarClassification::Public, )) | None => 0, - Some(ArchivedICalendarValue::Classification( - ArchivedICalendarClassification::Confidential, - )) => EVENT_VIEW_SECRET, _ => EVENT_VIEW_PRIVATE, }; } @@ -71,8 +66,8 @@ pub fn archived_event_view(event: &ArchivedCalendarEvent) -> u8 { view } -/// Keeps only the time and basic metadata of a private event (RFC 8984, -/// section 4.4.3), with its recurrence so its occurrences stay right. +/// Keeps only the time and basic metadata of a private or confidential event +/// (RFC 8984, section 4.4.3), with its recurrence so its occurrences stay right. pub fn redact_private_event(ical: &mut ICalendar) { let mut remove_ids = Vec::new(); for (comp_id, comp) in ical.components.iter_mut().enumerate() { diff --git a/crates/jmap/src/blob/download.rs b/crates/jmap/src/blob/download.rs index 8a6021f8..f41d8532 100644 --- a/crates/jmap/src/blob/download.rs +++ b/crates/jmap/src/blob/download.rs @@ -140,8 +140,9 @@ impl BlobDownload for Server { .await .caused_by(trc::location!())? // inbuxa: a calendar event's blob holds all of - // it, so a sharee gets none of a private event - // or one that hides its attendees + // it, so a sharee gets none of a private or + // confidential event or one that hides its + // attendees .shared_events( access_token, [Acl::ReadItems], diff --git a/crates/jmap/src/calendar_event/copy.rs b/crates/jmap/src/calendar_event/copy.rs index 96b5c033..165d698a 100644 --- a/crates/jmap/src/calendar_event/copy.rs +++ b/crates/jmap/src/calendar_event/copy.rs @@ -105,7 +105,7 @@ impl JmapCalendarEventCopy for Server { let from_calendar_event_ids = if is_owner { from_cache.document_ids(false).collect::() } else { - from_cache.shared_events(access_token, [Acl::ReadItems], true, 0) + from_cache.shared_items(access_token, [Acl::ReadItems], true) }; let mut writable_ids = None; diff --git a/crates/jmap/src/calendar_event/get.rs b/crates/jmap/src/calendar_event/get.rs index a6bcfcc4..ae646dc6 100644 --- a/crates/jmap/src/calendar_event/get.rs +++ b/crates/jmap/src/calendar_event/get.rs @@ -79,7 +79,7 @@ impl CalendarEventGet for Server { let calendar_event_ids = if is_owner { cache.document_ids(false).collect::() } else { - cache.shared_events(access_token, [Acl::ReadItems], true, 0) + cache.shared_items(access_token, [Acl::ReadItems], true) }; let mut writable_ids = None; let (mut ids, has_synthetic_ids) = if let Some(rr) = request.ids.take() { @@ -238,9 +238,10 @@ impl CalendarEventGet for Server { .deserialize::() .caused_by(trc::location!())?; - // inbuxa: a sharee sees the time of a private event only, and only the - // owners and themselves among the attendees of an event that hides - // them, unless they may change every event in its calendar. + // inbuxa: a sharee sees the time of a private or confidential event + // only, and only the owners and themselves among the attendees of an + // event that hides them, unless they may change every event in its + // calendar. let mut reduce_participants = reduce_participants; if !is_owner { let view = event_view(&calendar_event); diff --git a/crates/jmap/src/calendar_event/query.rs b/crates/jmap/src/calendar_event/query.rs index c2df5312..ab577d92 100644 --- a/crates/jmap/src/calendar_event/query.rs +++ b/crates/jmap/src/calendar_event/query.rs @@ -262,9 +262,9 @@ impl CalendarEventQuery for Server { vec![] }; - // inbuxa: a sharee never finds a secret event, and can't match on what - // it may not see: the details of a private event, or the attendees of - // one that hides them unless it may change every event in its calendar. + // inbuxa: a sharee can't match on what it may not see: the details of a + // private or confidential event, or the attendees of one that hides + // them unless it may change every event in its calendar. let mask = if access_token.reads_through_share(account_id) { let mut mask = cache.shared_events( access_token, diff --git a/crates/jmap/src/changes/get.rs b/crates/jmap/src/changes/get.rs index 4f373dbd..7e221fd0 100644 --- a/crates/jmap/src/changes/get.rs +++ b/crates/jmap/src/changes/get.rs @@ -188,7 +188,7 @@ impl ChangesLookup for Server { SyncCollection::Calendar, ) .await? - .shared_events(access_token, [Acl::ReadItems], true, 0), + .shared_items(access_token, [Acl::ReadItems], true), MethodObject::FileNode => self .fetch_dav_resources( access_token.account_id(), diff --git a/tests/src/jmap/calendar/acl.rs b/tests/src/jmap/calendar/acl.rs index c6195c20..8ee98b4e 100644 --- a/tests/src/jmap/calendar/acl.rs +++ b/tests/src/jmap/calendar/acl.rs @@ -837,8 +837,8 @@ pub async fn test(test: &TestServer) { "private event listed to a sharee: {response:?}" ); - // inbuxa: a sharee sees only the time of a private event, nothing of a - // secret one, and no other attendees of an event that hides them. + // inbuxa: a sharee sees only the time of a private or confidential event, + // and no other attendees of an event that hides them. let event = |uid: &str, title: &str, extra: serde_json::Value| { let mut event = json!({ "uid": uid, @@ -880,7 +880,7 @@ pub async fn test(test: &TestServer) { json!({"privacy": "private"}), ), event( - "secret-event@example.com", + "confidential-event@example.com", "Job interview", json!({"privacy": "secret"}), ), @@ -894,7 +894,7 @@ pub async fn test(test: &TestServer) { ) .await; let private_id = response.created(0).id().to_string(); - let secret_id = response.created(1).id().to_string(); + let confidential_id = response.created(1).id().to_string(); let hidden_id = response.created(2).id().to_string(); // The owner sees everything @@ -902,7 +902,7 @@ pub async fn test(test: &TestServer) { .jmap_get( MethodObject::CalendarEvent, Vec::<&str>::new(), - [private_id.as_str(), secret_id.as_str()], + [private_id.as_str(), confidential_id.as_str()], ) .await; assert_eq!(response.list().len(), 2, "{response:?}"); @@ -911,20 +911,8 @@ pub async fn test(test: &TestServer) { Some("Doctor appointment") ); - // A secret event doesn't exist for the sharee - let response = jane - .jmap_get_account( - john, - MethodObject::CalendarEvent, - Vec::<&str>::new(), - [secret_id.as_str()], - ) - .await; - assert_eq!( - response.not_found().collect::>(), - [secret_id.as_str()], - "{response:?}" - ); + // A private event and a confidential one (JMAP "secret", stored as + // CLASS:CONFIDENTIAL) both show the sharee their time only let response = jane .jmap_method_call( "CalendarEvent/query", @@ -935,69 +923,76 @@ pub async fn test(test: &TestServer) { .await; let ids = response.ids().collect::>(); assert!(ids.contains(&private_id.as_str()), "{response:?}"); - assert!(!ids.contains(&secret_id.as_str()), "{response:?}"); - - // A private event shows its time only - let response = jane - .jmap_get_account( - john, - MethodObject::CalendarEvent, - Vec::<&str>::new(), - [private_id.as_str()], - ) - .await; - let private_event = &response.list()[0]; - assert_eq!( - private_event.get("start").and_then(|v| v.as_str()), - Some("2020-02-03T10:00:00"), - "{response:?}" - ); - for property in [ - "title", - "description", - "participants", - "organizerCalendarAddress", + assert!(ids.contains(&confidential_id.as_str()), "{response:?}"); + for (event_id, title) in [ + (private_id.as_str(), "Doctor"), + (confidential_id.as_str(), "Job"), ] { - assert!( - private_event.get(property).is_none(), - "{property} of a private event shown to a sharee: {response:?}" + let response = jane + .jmap_get_account( + john, + MethodObject::CalendarEvent, + Vec::<&str>::new(), + [event_id], + ) + .await; + assert_eq!(response.not_found().count(), 0, "{response:?}"); + let shared_event = &response.list()[0]; + assert_eq!( + shared_event.get("start").and_then(|v| v.as_str()), + Some("2020-02-03T10:00:00"), + "{response:?}" ); - } - let response = jane - .jmap_method_call( - "CalendarEvent/query", - json!({ - "accountId": &john_id, - "filter": {"title": "Doctor"}, - }), - ) - .await; - assert_eq!(response.ids().count(), 0, "{response:?}"); - let response = jane - .jmap_method_call( - "CalendarEvent/copy", - json!({ - "fromAccountId": &john_id, - "accountId": &jane_id, - "create": { - &private_id: { - "calendarIds": { - &jane_calendar_id: true + assert_eq!( + shared_event.get("duration").and_then(|v| v.as_str()), + Some("PT1H"), + "{response:?}" + ); + for property in [ + "title", + "description", + "participants", + "organizerCalendarAddress", + ] { + assert!( + shared_event.get(property).is_none(), + "{property} of event {event_id} shown to a sharee: {response:?}" + ); + } + let response = jane + .jmap_method_call( + "CalendarEvent/query", + json!({ + "accountId": &john_id, + "filter": {"title": title}, + }), + ) + .await; + assert_eq!(response.ids().count(), 0, "{response:?}"); + let response = jane + .jmap_method_call( + "CalendarEvent/copy", + json!({ + "fromAccountId": &john_id, + "accountId": &jane_id, + "create": { + event_id: { + "calendarIds": { + &jane_calendar_id: true + } } } - } - }), - ) - .await; - assert_eq!( - response - .pointer(&format!( - "/methodResponses/0/1/notCreated/{private_id}/type" - )) - .and_then(|v| v.as_str()), - Some("forbidden"), - "{response:?}" - ); + }), + ) + .await; + assert_eq!( + response + .pointer(&format!("/methodResponses/0/1/notCreated/{event_id}/type")) + .and_then(|v| v.as_str()), + Some("forbidden"), + "{response:?}" + ); + } // An event that hides its attendees shows the sharee its owners only let response = jane -- 2.54.0 From 71e7c020495313b42eb464fd2330f581a1d272c3 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Wed, 28 Oct 2026 21:54:41 -0700 Subject: [PATCH 35/35] Version 2026.10.29 --- crates/types/src/branding.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/types/src/branding.rs b/crates/types/src/branding.rs index 98f473e7..362dbfc2 100644 --- a/crates/types/src/branding.rs +++ b/crates/types/src/branding.rs @@ -87,7 +87,7 @@ fn legacy_setting(name: &str, is_set: impl Fn(&str) -> bool) -> Option { #[macro_export] macro_rules! brand_version { () => { - "2026.10.28" + "2026.10.29" }; } -- 2.54.0