Package: release.debian.org Severity: normal Tags: trixie User: [email protected] Usertags: pu
Please consider cyrus-imapd 3.10.2-1+deb13u3 for trixie.
This stacks on top of the already-accepted 3.10.2-1+deb13u2 (ack'ed
by Adam D. Barratt on bug #1142925, accepted into proposed-updates
on 2026-09-05, still awaiting the next point release). This does not
replace or conflict with it.
It backports six new CVEs fixed upstream in 3.10.4, none of which
overlap with the CVE-2026-47081..47089 batch already in deb13u2:
* CVE-2026-61907: JMAP snooze bypassed the destination mailbox's
ACL, letting a sharee insert mail into mailboxes they had no
insert rights on.
* CVE-2026-61908: heap out-of-bounds read via a crafted JMAP
email-header blob ID index.
* CVE-2026-61909: CalDAV/CardDAV multiget did not check per-href
ACLs, letting a partially-shared user read unshared events or
contacts.
* CVE-2026-61910: Mailbox/set let a sharee with maySetKeywords
change a shared mailbox's special-use role.
* CVE-2026-61911: Sieve mailboxexists/metadata let a script probe
another user's mailbox existence or read shared annotations.
* CVE-2026-61915: double-free in VPATCH BYPARAM handling could
crash a CalDAV worker.
The debdiff also includes one small, non-CVE fix: a pre-existing
double-free in ical_support.c's parameter cleanup (present since at
least 3.10.2), needed for the CVE-2026-61915 upstream regression test
to actually pass. No code path previously exercised it. Fixed
upstream in commit 4f9fd773047cb8d2f73b00cee4bc2adc1893fb65.
Built and tested clean via sbuild against a local stable chroot;
lintian warnings are pre-existing and unrelated to this diff. debdiff
attached below.
Kind regards,
Edmund
--
Edmund Lodewijks <[email protected]>
TZ: UTC+2 / GMT+2
diff --git a/debian/changelog b/debian/changelog index 04f3afdb..92cc1a93 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,49 @@ +cyrus-imapd (3.10.2-1+deb13u3) trixie; urgency=medium + + * Backport security fixes from upstream 3.10.4 (Closes: #???????) + (https://www.cyrusimap.org/imap/download/release-notes/3.10/x/3.10.4.html): + - CVE-2026-61907: JMAP snooze bypasses destination-mailbox ACL, allowing + an authenticated user with insert permissions on another user's snoozed + mailbox to cause insertion of mail to that user's inbox, or any other + of their mailboxes whose id was known to the user, despite having no + insert permissions to the target mailbox. + - CVE-2026-61908: JMAP email-header blob ID out-of-bounds index + An authenticated user could attempt to download a specially crafted + JMAP blob ID of the form H<emailid>-<index>, which could read past the + end of the internal blob_headers array during download, exposing + adjacent heap memory. + - CVE-2026-61909: CalDAV/CardDAV multiget bypasses per-href ACL + An authenticated DAV user with some shared access to another user's + calendar or address book could read even unshared events or contacts + by including the target hrefs in a calendar-multiget or + addressbook-multiget REPORT. + - CVE-2026-61910: Mailbox/set let sharee change special-use role on + shared mailboxes. An authenticated user with maySetKeywords on + another user's mailbox could change that mailbox's specialuse + annotation. This could allow the sharee to change the shared + mailbox to perform the archived, snoozed, or other role, which + might cause mail to be written to the shared mailbox, sharing + more content than intended. + This is likely to be an unusual situation, made more unusual because + if the target already has an non-shared mailbox with that role, + role duplication suppression will prevent the update. + - CVE-2026-61911: Sieve mailbox existence oracle + An authenticated user could install a Sieve script that probed + whether another user's private mailbox existed, or read the value + of shared mailbox annotations, by observing which fileinto branch + fired during LMTP delivery. + - CVE-2026-61915: VPATCH BYPARAM double-free + An authenticated calendar user could crash a Cyrus CalDAV worker + with a PATCH containing PATCH-ACTION="BYPARAM@..." against a + resource with two or more properties of the matched kind. The + memory holding the selector would be freed once on each iteration + over the properties. + * ical_support.c: fix pre-existing double-free of a removed-by-ref + parameter (not a CVE; needed for the CVE-2026-61915 regression test + to pass). + + -- Edmund Lodewijks <[email protected]> Thu, 10 Sep 2026 11:19:22 +0200 + cyrus-imapd (3.10.2-1+deb13u2) trixie; urgency=medium * Backport security fixes from upstream 3.10.3 (Closes: #1142925) diff --git a/debian/patches/0029-Sieve-mailboxexists-metadata-bypass-ACL-lookup-check.patch b/debian/patches/0029-Sieve-mailboxexists-metadata-bypass-ACL-lookup-check.patch new file mode 100644 index 00000000..ed322c83 --- /dev/null +++ b/debian/patches/0029-Sieve-mailboxexists-metadata-bypass-ACL-lookup-check.patch @@ -0,0 +1,287 @@ +Description: Sieve: mailboxexists/metadata bypass ACL lookup check (CVE-2026-61911) + Add ACL_LOOKUP check to getmailboxexists() and getmetadata() in both + lmtp_sieve.c and jmap_sieve.c before answering Sieve mailboxexists, + metadata, and metadataexists tests against another user's mailbox. + . + Without this check, an authenticated user could install a Sieve script + that probed whether another user's private mailbox existed, or tested + the value of shared mailbox annotations, by observing which fileinto + branch fired during LMTP delivery. + . + This is CYR-2909, CVE-2026-61911 + . + This problem was reported by Michael Lynch (mtlynch.io). + . + Co-Authored-By: Claude <[email protected]> +Author: Ricardo Signes <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/ec97e3ab4a96e65c92d3edb2d00cf2d564a1fe66 +Bug: https://security-tracker.debian.org/tracker/CVE-2026-61911 +Bug-CVE: CVE-2026-61911 +Forwarded: not-needed +Last-Update: 2026-09-10 + + .../tiny-tests/Sieve/mailboxexists_acl_bypass | 42 ++++++++++ + .../Sieve/specialuse_exists_check_acl | 76 +++++++++++++++++++ + changes/next/CVE-2026-61911 | 29 +++++++ + imap/jmap_sieve.c | 21 ++++- + imap/lmtp_sieve.c | 23 +++++- + 5 files changed, 189 insertions(+), 2 deletions(-) + create mode 100644 cassandane/tiny-tests/Sieve/mailboxexists_acl_bypass + create mode 100644 cassandane/tiny-tests/Sieve/specialuse_exists_check_acl + create mode 100644 changes/next/CVE-2026-61911 + +diff --git a/cassandane/tiny-tests/Sieve/mailboxexists_acl_bypass b/cassandane/tiny-tests/Sieve/mailboxexists_acl_bypass +new file mode 100644 +index 00000000000..f3c5ac41b9b +--- /dev/null ++++ b/cassandane/tiny-tests/Sieve/mailboxexists_acl_bypass +@@ -0,0 +1,42 @@ ++#!perl ++use Cassandane::Tiny; ++ ++sub test_mailboxexists_acl_bypass ++ :min_version_3_0 :NoAltNameSpace ++{ ++ my ($self) = @_; ++ my $talk = $self->{store}->get_client(); ++ ++ # Create a victim user with a private folder not shared with the attacker ++ $self->{instance}->create_user('victim'); ++ my $victimstore = $self->{instance}->get_service('imap')->create_store( ++ username => 'victim'); ++ my $victimtalk = $victimstore->get_client(); ++ $victimtalk->create('INBOX.secret-project'); ++ $self->assert_str_equals('ok', $victimtalk->get_last_completion_response()); ++ ++ my $hitfolder = 'INBOX.oracle-hit'; ++ my $missfolder = 'INBOX.oracle-miss'; ++ $talk->create($hitfolder); ++ $talk->create($missfolder); ++ ++ # Install a Sieve script that uses mailboxexists against another user's mailbox. ++ # In standard (non-altnamespace) mode, other users' folders appear as user.victim.* ++ $self->{instance}->install_sieve_script(<<'EOF', username => 'cassandane'); ++require ["fileinto", "mailbox"]; ++if mailboxexists "user.victim.secret-project" { ++ fileinto "INBOX.oracle-hit"; ++} else { ++ fileinto "INBOX.oracle-miss"; ++} ++EOF ++ ++ # Deliver a message -- mailboxexists must not reveal the victim's folder ++ my $msg = $self->{gen}->generate(subject => "Probe"); ++ $self->{instance}->deliver($msg, users => ['cassandane']); ++ ++ # Without the fix the message lands in oracle-hit (the bug leaks existence). ++ # With the fix the message must land in oracle-miss. ++ $self->{store}->set_folder($missfolder); ++ $self->check_messages({ 1 => $msg }, check_guid => 0); ++} +diff --git a/cassandane/tiny-tests/Sieve/specialuse_exists_check_acl b/cassandane/tiny-tests/Sieve/specialuse_exists_check_acl +new file mode 100644 +index 00000000000..8fa8a1491bb +--- /dev/null ++++ b/cassandane/tiny-tests/Sieve/specialuse_exists_check_acl +@@ -0,0 +1,76 @@ ++#!perl ++use Cassandane::Tiny; ++ ++sub test_specialuse_exists_check_acl ++ :NoAltNamespace ++{ ++ my ($self) = @_; ++ my $talk = $self->{store}->get_client(); ++ my $admintalk = $self->{adminstore}->get_client(); ++ ++ xlog $self, "Create sharer user with two folders"; ++ $self->{instance}->create_user("sharer", ++ subdirs => [ 'shared', 'hidden' ]); ++ ++ xlog $self, "Grant the sharee lookup access to both of the sharer's folders"; ++ $admintalk->setacl("user.sharer.shared", 'cassandane' => 'lrs') ++ or die "Cannot setacl user.sharer.shared: $@"; ++ $admintalk->setacl("user.sharer.hidden", 'cassandane' => 'lrs') ++ or die "Cannot setacl user.sharer.hidden: $@"; ++ ++ xlog $self, "Sharee assigns a private special-use flag to each shared folder"; ++ $talk->setmetadata("user.sharer.shared", "/private/specialuse", "\\Junk"); ++ $self->assert_str_equals('ok', $talk->get_last_completion_response()); ++ $talk->setmetadata("user.sharer.hidden", "/private/specialuse", "\\Sent"); ++ $self->assert_str_equals('ok', $talk->get_last_completion_response()); ++ ++ xlog $self, "Revoke the sharee's access to the hidden folder (annotation persists)"; ++ $admintalk->deleteacl("user.sharer.hidden", 'cassandane') ++ or die "Cannot deleteacl user.sharer.hidden: $@"; ++ ++ xlog $self, "Sharee owns a folder carrying a special-use flag (positive control)"; ++ $talk->create("INBOX.archive", "(USE (\\Archive))") ++ or die "Cannot create INBOX.archive: $@"; ++ ++ xlog $self, "Create result folders for Sieve script"; ++ my @results = qw( ++ INBOX.own-hit ++ INBOX.shared-hit ++ INBOX.hidden-hit ++ ); ++ $talk->create($_) or die "Cannot create $_: $@" for @results; ++ ++ xlog $self, "Install a Sieve script with specialuse_exists checks"; ++ $self->{instance}->install_sieve_script(<<'EOF' ++require ["fileinto", "copy", "special-use"]; ++if specialuse_exists "INBOX.archive" "\\Archive" { ++ fileinto :copy "INBOX.own-hit"; ++} ++if specialuse_exists "user.sharer.shared" "\\Junk" { ++ fileinto :copy "INBOX.shared-hit"; ++} ++if specialuse_exists "user.sharer.hidden" "\\Sent" { ++ fileinto :copy "INBOX.hidden-hit"; ++} ++keep; ++EOF ++ ); ++ ++ xlog $self, "Deliver a message to the sharee"; ++ my $msg = $self->{gen}->generate(subject => "test"); ++ $self->{instance}->deliver($msg, users => [ 'cassandane' ]); ++ ++ xlog $self, "Assert specialuse_exists is gated by the lookup right"; ++ my %expected = ( ++ 'INBOX' => 1, # keep ++ 'INBOX.own-hit' => 1, # owner has lookup + own special-use ++ 'INBOX.shared-hit' => 1, # sharee has lookup + own special-use ++ 'INBOX.hidden-hit' => 0, # special-use set, but sharee lost lookup ++ ); ++ foreach my $folder (sort keys %expected) { ++ $talk->select($folder); ++ $self->assert_str_equals('ok', $talk->get_last_completion_response()); ++ $self->assert_num_equals($expected{$folder}, ++ $talk->get_response_code('exists')); ++ } ++} +diff --git a/changes/next/CVE-2026-61911 b/changes/next/CVE-2026-61911 +new file mode 100644 +index 00000000000..44120a48050 +--- /dev/null ++++ b/changes/next/CVE-2026-61911 +@@ -0,0 +1,29 @@ ++Description: ++ ++:cve:`CVE-2026-61911` Sieve mailbox existence oracle ++ ++An authenticated user could install a Sieve script that probed whether another ++user's private mailbox existed, or read the value of shared mailbox ++annotations, by observing which fileinto branch fired during LMTP delivery. ++ ++Reported by: Michael Lynch (mtlynch.io). ++ ++ ++Documentation: ++ ++None ++ ++ ++Config changes: ++ ++None ++ ++ ++Upgrade instructions: ++ ++None ++ ++ ++GitHub issue: ++ ++CVE-2026-61911 +diff --git a/imap/jmap_sieve.c b/imap/jmap_sieve.c +index 29aa68995d9..ea9ba4d3aae 100644 +--- a/imap/jmap_sieve.c ++++ b/imap/jmap_sieve.c +@@ -1374,8 +1374,12 @@ static int getmailboxexists(void *sc, const char *extname) + { + script_data_t *sd = (script_data_t *) sc; + char *intname = mboxname_from_external(extname, sd->ns, sd->userid); +- int r = mboxlist_lookup(intname, NULL, NULL); ++ mbentry_t *mbentry = NULL; ++ int r = mboxlist_lookup(intname, &mbentry, NULL); + ++ if (!r && !(cyrus_acl_myrights(sd->authstate, mbentry->acl) & ACL_LOOKUP)) ++ r = IMAP_MAILBOX_NONEXISTENT; ++ mboxlist_entry_free(&mbentry); + free(intname); + return r ? 0 : 1; /* 0 => exists */ + } +@@ -1451,6 +1455,21 @@ static int getmetadata(void *sc, const char *extname, + mboxname_from_external(extname, sd->ns, sd->userid); + int r; + ++ /* Require lookup rights on the named mailbox before probing metadata. */ ++ if (extname) { ++ mbentry_t *mbentry = NULL; ++ r = mboxlist_lookup(intname, &mbentry, NULL); ++ if (!r && !(cyrus_acl_myrights(sd->authstate, mbentry->acl) & ACL_LOOKUP)) ++ r = IMAP_MAILBOX_NONEXISTENT; ++ mboxlist_entry_free(&mbentry); ++ if (r) { ++ *res = NULL; ++ free(intname); ++ buf_free(&attrib); ++ return 0; ++ } ++ } ++ + if (!strncmp(keyname, "/private/", 9)) { + r = annotatemore_lookup(intname, keyname+8, sd->userid, &attrib); + } +diff --git a/imap/lmtp_sieve.c b/imap/lmtp_sieve.c +index b85bf1ad2b9..3aa44c5fabe 100644 +--- a/imap/lmtp_sieve.c ++++ b/imap/lmtp_sieve.c +@@ -55,6 +55,7 @@ + #include <sys/types.h> + #include <sys/wait.h> + ++#include "acl.h" + #include "annotate.h" + #include "append.h" + #include "assert.h" +@@ -182,8 +183,12 @@ static int getmailboxexists(void *sc, const char *extname) + script_data_t *sd = (script_data_t *)sc; + char *intname = mboxname_from_external(extname, sd->ns, + mbname_userid(sd->mbname)); +- int r = mboxlist_lookup(intname, NULL, NULL); ++ mbentry_t *mbentry = NULL; ++ int r = mboxlist_lookup(intname, &mbentry, NULL); + free(intname); ++ if (!r && !(cyrus_acl_myrights(sd->authstate, mbentry->acl) & ACL_LOOKUP)) ++ r = IMAP_MAILBOX_NONEXISTENT; ++ mboxlist_entry_free(&mbentry); + return r ? 0 : 1; /* 0 => exists */ + } + +@@ -256,6 +261,22 @@ static int getmetadata(void *sc, const char *extname, const char *keyname, char + char *intname = !extname ? xstrdup("") : + mboxname_from_external(extname, sd->ns, mbname_userid(sd->mbname)); + int r; ++ ++ /* Require lookup rights on the named mailbox before probing metadata. */ ++ if (extname) { ++ mbentry_t *mbentry = NULL; ++ r = mboxlist_lookup(intname, &mbentry, NULL); ++ if (!r && !(cyrus_acl_myrights(sd->authstate, mbentry->acl) & ACL_LOOKUP)) ++ r = IMAP_MAILBOX_NONEXISTENT; ++ mboxlist_entry_free(&mbentry); ++ if (r) { ++ *res = NULL; ++ free(intname); ++ buf_free(&attrib); ++ return 0; ++ } ++ } ++ + if (!strncmp(keyname, "/private/", 9)) { + r = annotatemore_lookup(intname, keyname+8, mbname_userid(sd->mbname), &attrib); + } + diff --git a/debian/patches/0030-jmap_mailbox_c-Mailbox-set-must-not-let-sharees-change.patch b/debian/patches/0030-jmap_mailbox_c-Mailbox-set-must-not-let-sharees-change.patch new file mode 100644 index 00000000..2a28a962 --- /dev/null +++ b/debian/patches/0030-jmap_mailbox_c-Mailbox-set-must-not-let-sharees-change.patch @@ -0,0 +1,137 @@ +Description: jmap_mailbox.c: Mailbox/set must not let sharees change owner-scoped role (CVE-2026-61910) + The specialuse annotation is written under req->accountid (the account + owner), so a sharee who holds JACL_SETKEYWORDS could redirect the + owner's Archive, Snoozed, Scheduled, or similar special-use destination + to the shared mailbox. + . + Reject specialuse changes in Mailbox/set when the acting user is not the + account owner. + . + This is CYR-2911, CVE-2026-61910. + . + This problem originally reported by Michael Lynch (mtlynch.io). + . + Co-Authored-By: Claude <[email protected]> +Author: Ricardo Signes <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/a1a2a8373f369e5fef9b7842c210f458972d33df +Bug: https://security-tracker.debian.org/tracker/CVE-2026-61910 +Bug-CVE: CVE-2026-61910 +Forwarded: not-needed +Last-Update: 2026-09-10 + + cassandane/Cassandane/Cyrus/JMAPMailbox.pm | 42 ++++++++++++++++++++++ + changes/next/CVE-2026-61910 | 35 ++++++++++++++++++ + imap/jmap_mailbox.c | 6 ++++ + 3 files changed, 83 insertions(+) + create mode 100644 changes/next/CVE-2026-61910 + +diff --git a/cassandane/Cassandane/Cyrus/JMAPMailbox.pm b/cassandane/Cassandane/Cyrus/JMAPMailbox.pm +index 5fd7d8c7b26..2943702cb3c 100644 +--- a/cassandane/Cassandane/Cyrus/JMAPMailbox.pm ++++ b/cassandane/Cassandane/Cyrus/JMAPMailbox.pm +@@ -5479,4 +5479,46 @@ sub test_mailbox_set_destroy_twice + $self->assert_str_equals("notFound", $res->[0][1]{notDestroyed}{$id}{type}); + } + ++sub test_mailbox_set_role_sharee ++ :min_version_3_1 :NoAltNameSpace :needs_component_jmap ++{ ++ my ($self) = @_; ++ my $jmap = $self->{jmap}; ++ my $admintalk = $self->{adminstore}->get_client(); ++ ++ # Create a victim user and a mailbox we'll share ++ $self->{instance}->create_user('victim'); ++ $admintalk->create('user.victim.target'); ++ # Grant the attacker (cassandane) lrw rights -- enough for maySetKeywords ++ $admintalk->setacl('user.victim', 'cassandane', 'lr') or die; ++ $admintalk->setacl('user.victim.target', 'cassandane', 'lrw') or die; ++ ++ # Find the target mailbox ID in the victim's account ++ my $res = $jmap->CallMethods([ ++ ['Mailbox/get', { accountId => 'victim' }, 'R1'], ++ ]); ++ my ($target) = grep { $_->{name} eq 'target' } @{$res->[0][1]{list}}; ++ $self->assert_not_null($target); ++ my $targetId = $target->{id}; ++ ++ # Verify the target has no role to start ++ $self->assert_null($target->{role}); ++ ++ # As attacker, try to set role on the victim's mailbox ++ $res = $jmap->CallMethods([ ++ ['Mailbox/set', { ++ accountId => 'victim', ++ update => { ++ $targetId => { role => 'archive' }, ++ }, ++ }, 'R2'], ++ ]); ++ ++ # We must forbid a sharee from changing the role on a shared mailbox when ++ # they have minimal rights (like maySetKeywords). ++ $self->assert_null($res->[0][1]{updated}{$targetId}); ++ $self->assert_not_null($res->[0][1]{notUpdated}{$targetId}); ++ $self->assert_str_equals('forbidden', $res->[0][1]{notUpdated}{$targetId}{type}); ++} ++ + 1; +diff --git a/changes/next/CVE-2026-61910 b/changes/next/CVE-2026-61910 +new file mode 100644 +index 00000000000..ed5f946252e +--- /dev/null ++++ b/changes/next/CVE-2026-61910 +@@ -0,0 +1,35 @@ ++Description: ++ ++:cve:`CVE-2026-61910` Mailbox/set let sharee change special-use role on shared mailboxes ++ ++An authenticated user with maySetKeywords on another user's mailbox could ++change that mailbox's specialuse annotation. This could allow the sharee to ++change the shared mailbox to perform the archived, snoozed, or other role, ++which might cause mail mail to be written to the shared mailbox, sharing more ++content than intended. ++ ++This is likely to be an unusual situation, made more unusual because if the ++target already has an non-shared mailbox with that role, role duplication ++suppression will prevent the update. ++ ++Reported by: Michael Lynch (mtlynch.io). ++ ++ ++Documentation: ++ ++N/A ++ ++ ++Config changes: ++ ++None ++ ++ ++Upgrade instructions: ++ ++None ++ ++ ++GitHub issue: ++ ++CVE-2026-61910 +diff --git a/imap/jmap_mailbox.c b/imap/jmap_mailbox.c +index 9147f7f793d..b1adfb2d0aa 100644 +--- a/imap/jmap_mailbox.c ++++ b/imap/jmap_mailbox.c +@@ -2716,6 +2716,12 @@ static void _mbox_update(jmap_req_t *req, struct mboxset_args *args, + result->err = json_pack("{s:s}", "type", "forbidden"); + goto done; + } ++ /* specialuse is an owner-scoped annotation; sharees must not set it */ ++ if (args->specialuse && strcmp(req->userid, req->accountid)) { ++ mboxlist_entry_free(&mbentry); ++ result->err = json_pack("{s:s}", "type", "forbidden"); ++ goto done; ++ } + set_annots = 1; + } + if (args->sortorder >= 0 || args->color || args->show_as_label >= 0) { + diff --git a/debian/patches/0031-jmap_mail_c-bounds-check-blob_headers-index-in-email_header-blob-id.patch b/debian/patches/0031-jmap_mail_c-bounds-check-blob_headers-index-in-email_header-blob-id.patch new file mode 100644 index 00000000..b427ff56 --- /dev/null +++ b/debian/patches/0031-jmap_mail_c-bounds-check-blob_headers-index-in-email_header-blob-id.patch @@ -0,0 +1,158 @@ +Description: jmap_mail.c: bounds-check blob_headers index in email-header blob id (CVE-2026-61908) + The bimi blob ID has the form H<emailid>-<index> where <index> is a + position in the internal blob_headers array. Only index 0 is valid, + and the array is terminated by a {NULL, NULL} sentinel. + . + Reject any index that would land on or beyond the sentinel. Also + widen the local from 'unsigned' to 'unsigned long' to match strtoul's + return type. Add a regression test that constructs blob IDs with index + 1, 99, and ULONG_MAX, and asserts that the download endpoint rejects + each with 400 Bad Request. + . + This is CYR-2954, CVE-2026-61908. + . + Reported by Ahmed Said. + . + Co-Authored-By: Claude <[email protected]> +Author: Ricardo Signes <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/06fb993e9d984841d3e2ba9d549652aea1660d34 +Bug: https://security-tracker.debian.org/tracker/CVE-2026-61908 +Bug-CVE: CVE-2026-61908 +Forwarded: not-needed +Last-Update: 2026-09-10 + + .../JMAPEmail/email_bimi_blob_oob_index | 62 +++++++++++++++++++ + changes/next/CVE-2026-61908 | 28 +++++++++ + imap/jmap_mail.c | 8 ++- + 3 files changed, 97 insertions(+), 1 deletion(-) + create mode 100644 cassandane/tiny-tests/JMAPEmail/email_bimi_blob_oob_index + create mode 100644 changes/next/CVE-2026-61908 + +diff --git a/cassandane/tiny-tests/JMAPEmail/email_bimi_blob_oob_index b/cassandane/tiny-tests/JMAPEmail/email_bimi_blob_oob_index +new file mode 100644 +index 00000000000..4e63f54d79f +--- /dev/null ++++ b/cassandane/tiny-tests/JMAPEmail/email_bimi_blob_oob_index +@@ -0,0 +1,62 @@ ++#!perl ++use Cassandane::Tiny; ++ ++sub test_email_bimi_blob_oob_index ++ :min_version_3_3 :needs_component_sieve ++ :JMAPExtensions ++{ ++ my ($self) = @_; ++ my $jmap = $self->{jmap}; ++ ++ # we need 'https://cyrusimap.org/ns/jmap/mail' capability for ++ # bimiBlobId property ++ my @using = @{ $jmap->DefaultUsing() }; ++ push @using, 'https://cyrusimap.org/ns/jmap/mail'; ++ $jmap->DefaultUsing(\@using); ++ ++ my $binary = slurp_file(abs_path('data/FM_BIMI.svg')); ++ ++ $self->make_message("foo", ++ mime_type => 'text/plain', ++ extra_headers => [ ++ ['BIMI-Indicator', encode_base64($binary, '')], ++ ], ++ body => 'foo', ++ ) || die; ++ ++ my $res = $jmap->CallMethods([ ++ ['Email/query', {}, "R1"], ++ ['Email/get', { ++ '#ids' => { ++ resultOf => 'R1', ++ name => 'Email/query', ++ path => '/ids', ++ }, ++ properties => ['bimiBlobId'], ++ }, "R2"], ++ ]); ++ my $blobid = $res->[1][1]{list}[0]{bimiBlobId}; ++ $self->assert_not_null($blobid); ++ ++ # The legitimate blobid format is "H<emailid>-<index>" where <index> ++ # is a position in the internal blob_headers array. Only one index ++ # is valid; the rest of the array is a {NULL, NULL} sentinel. ++ # ++ # Pre-fix, _decode_emailheader_blobid in jmap_mail.c parsed the ++ # attacker-supplied integer with strtoul and used it as a direct ++ # array index with no bounds check, returning out-of-array data ++ # for any index beyond zero. ++ ++ my ($prefix) = $blobid =~ /^(H.+-)\d+\z/ ++ or die "unexpected bimiBlobId format: $blobid"; ++ ++ for my $index (1, 99, '4294967295') { ++ my $bad = "$prefix$index"; ++ my $resp = $jmap->Download( ++ { accept => '*/*' }, ++ 'cassandane', ++ $bad, ++ ); ++ $self->assert_num_equals(400, $resp->{status}); ++ } ++} +diff --git a/changes/next/CVE-2026-61908 b/changes/next/CVE-2026-61908 +new file mode 100644 +index 00000000000..04f439c8071 +--- /dev/null ++++ b/changes/next/CVE-2026-61908 +@@ -0,0 +1,28 @@ ++Description: ++ ++:cve:`CVE-2026-61908`: JMAP email-header blob ID out-of-bounds index ++ ++An authenticated user could attempt to download a specially crafted JMAP blob ++ID of the form ``H<emailid>-<index>``, which could read past the end of the ++internal blob_headers array during download, exposing adjacent heap memory. ++ ++Reported by: Ahmed Said. ++ ++ ++Documentation: ++ ++N/A ++ ++Config changes: ++ ++None ++ ++ ++Upgrade instructions: ++ ++None ++ ++ ++GitHub issue: ++ ++CVE-2026-61908 +diff --git a/imap/jmap_mail.c b/imap/jmap_mail.c +index cf1bd66b0cd..feb2aef9495 100644 +--- a/imap/jmap_mail.c ++++ b/imap/jmap_mail.c +@@ -14205,13 +14205,19 @@ static int _decode_emailheader_blobid(const char *blobid, + + /* Decode hdrname */ + if (*base == '\0') goto done; +- unsigned index; ++ unsigned long index; + char *endptr = NULL; + errno = 0; + index = strtoul(base, &endptr, 10); + if (errno == ERANGE || *endptr) goto done; + base = endptr; + ++ /* blob_headers is NULL-terminated; reject any index that lands on ++ * the sentinel (or past it). */ ++ unsigned long max_index = ++ sizeof(blob_headers) / sizeof(blob_headers[0]) - 1; ++ if (index >= max_index) goto done; ++ + /* All done */ + *emailidptr = emailid; + *hdrnameptr = blob_headers[index].name; + diff --git a/debian/patches/0032-ical_support_c-do-not-free-parameters-removed-by-ref.patch b/debian/patches/0032-ical_support_c-do-not-free-parameters-removed-by-ref.patch new file mode 100644 index 00000000..26495bc4 --- /dev/null +++ b/debian/patches/0032-ical_support_c-do-not-free-parameters-removed-by-ref.patch @@ -0,0 +1,25 @@ +Description: ical_support.c: do not free parameters that were removed by ref + Their memory still is managed within libical for the iCalendar + component and remove_by_ref keeps their reference count unchanged. + . + Pre-existing double-free, present since at least 3.10.2. Not + previously triggered because no existing test exercised the DQUOTEd + BYPARAM patch-action parsing path in apply_property_updates() -- the + regression test added by the CVE-2026-61915 backport (see + 0033-ical_support_c-fix-VPATCH-BYPARAM-selector-double-free.patch) is + the first thing that does, and requires this fix to pass. +Author: Robert Stepanek <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/4f9fd773047cb8d2f73b00cee4bc2adc1893fb65 +Forwarded: not-needed +Last-Update: 2026-09-10 + +--- a/imap/ical_support.c ++++ b/imap/ical_support.c +@@ -1978,7 +1978,6 @@ static void apply_property_updates(struct patch_data_t *patch, + } + + icalproperty_remove_parameter_by_ref(newprop, actionp); +- icalparameter_free(actionp); + } + + if (action != ICAL_PATCHACTION_CREATE) { diff --git a/debian/patches/0033-ical_support_c-fix-VPATCH-BYPARAM-selector-double-free.patch b/debian/patches/0033-ical_support_c-fix-VPATCH-BYPARAM-selector-double-free.patch new file mode 100644 index 00000000..61438b50 --- /dev/null +++ b/debian/patches/0033-ical_support_c-fix-VPATCH-BYPARAM-selector-double-free.patch @@ -0,0 +1,188 @@ +Description: ical_support.c: fix VPATCH BYPARAM selector double-free (CVE-2026-61915) + apply_property_updates() parsed a quoted PATCH-ACTION="BYPARAM@..." + once before iterating each existing property of the matched kind, + but free()'d the parsed selector strings *inside* the loop without + nulling the pointers. When the patch target had two or more + properties of the matched kind, the second iteration read + (use-after-free) and then freed again (double-free) the same + selector strings. Trigger paths included a direct CalDAV PATCH and + the per-user-calendar-data replay during shared reads / calalarmd + scans, the latter of which a sharee with read-only access could + plant via the per-user data annotation. + . + Free byparam.prop.{param,value} once after the inner loop, not on + every iteration. + . + This is CYR-2965, CVE-2026-61915. + . + Reported by Michael Lynch (mtlynch.io). + . + Co-Authored-By: Claude <[email protected]> +Author: Ricardo Signes <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/fd66232b5ab231601d4a36dddfc480e1473e31f7 +Bug: https://security-tracker.debian.org/tracker/CVE-2026-61915 +Bug-CVE: CVE-2026-61915 +Forwarded: not-needed +Last-Update: 2026-09-10 + + changes/next/CVE-2026-61915 | 30 +++++++++++++ + cunit/ical_support.testc | 87 +++++++++++++++++++++++++++++++++++++ + imap/ical_support.c | 12 +++-- + 3 files changed, 126 insertions(+), 3 deletions(-) + create mode 100644 changes/next/CVE-2026-61915 + +--- /dev/null ++++ b/changes/next/CVE-2026-61915 +@@ -0,0 +1,30 @@ ++Description: ++ ++:cve:`CVE-2026-61915`: VPATCH BYPARAM double-free ++ ++An authenticated calendar user could crash a Cyrus CalDAV worker with a PATCH ++containing ``PATCH-ACTION="BYPARAM@..."`` against a resource with two or more ++properties of the matched kind. The memory holding the selector would be freed ++once on each iteration over the properties. ++ ++Reporter: Michael Lynch (mtlynch.io). ++ ++ ++Documentation: ++ ++N/A ++ ++ ++Config changes: ++ ++None ++ ++ ++Upgrade instructions: ++ ++None ++ ++ ++GitHub issue: ++ ++CVE-2026-61915 +--- a/cunit/ical_support.testc ++++ b/cunit/ical_support.testc +@@ -145,3 +145,90 @@ static void test_icalrecurrenceset_get_u + + free(eternitystr); + } ++ ++/* Regression test for CYR-2965. ++ * ++ * apply_property_updates() in imap/ical_support.c parses a quoted ++ * PATCH-ACTION="BYPARAM@..." once before iterating each existing ++ * property of the matched kind, but free()s the parsed selector ++ * strings *inside* the loop without nulling the pointers. When the ++ * patch target has two or more properties of the matched kind, the ++ * second iteration reads (use-after-free) and then frees again ++ * (double-free) the same selector strings. ++ * ++ * This test feeds a VCALENDAR containing a VEVENT with two ATTENDEE ++ * properties, plus a VPATCH whose update uses ++ * PATCH-ACTION="BYPARAM@CN=Alice", to icalcomponent_apply_vpatch(). ++ * Pre-fix this aborts under glibc malloc detection or ASAN. ++ */ ++static void test_icalcomponent_apply_vpatch_byparam_double_free(void) ++{ ++ init_caldav(); ++ ++ struct buf buf = BUF_INITIALIZER; ++ buf_setcstr(&buf, ++ "BEGIN:VCALENDAR\r\n" ++ "VERSION:2.0\r\n" ++ "PRODID:-//Test//CYR-2965//EN\r\n" ++ "CALSCALE:GREGORIAN\r\n" ++ "BEGIN:VEVENT\r\n" ++ "UID:cyr-2965-event\r\n" ++ "DTSTAMP:20260101T120000Z\r\n" ++ "DTSTART:20260901T150000Z\r\n" ++ "DTEND:20260901T160000Z\r\n" ++ "SUMMARY:two attendees\r\n" ++ "ORGANIZER;CN=Org:MAILTO:[email protected]\r\n" ++ "ATTENDEE;CN=Alice:MAILTO:[email protected]\r\n" ++ "ATTENDEE;CN=Bob:MAILTO:[email protected]\r\n" ++ "END:VEVENT\r\n" ++ "END:VCALENDAR\r\n"); ++ ++ icalcomponent *ical = ical_string_as_icalcomponent(&buf); ++ CU_ASSERT_PTR_NOT_NULL_FATAL(ical); ++ ++ /* Note: PATCH-ACTION="BYPARAM@CN=Alice" -- the DQUOTEd value is ++ * what libical surfaces as an X-action with xvalue, which ++ * apply_property_updates() decodes into byparam.prop.{param,value}. ++ */ ++ buf_setcstr(&buf, ++ "BEGIN:VCALENDAR\r\n" ++ "VERSION:2.0\r\n" ++ "PRODID:-//Test//CYR-2965//EN\r\n" ++ "BEGIN:VPATCH\r\n" ++ "VERSION:1\r\n" ++ "PATCH-VERSION:1\r\n" ++ "UID:cyr-2965-vpatch\r\n" ++ "DTSTAMP:20260101T120100Z\r\n" ++ "BEGIN:PATCH\r\n" ++ "PATCH-TARGET:/VCALENDAR/VEVENT[UID=cyr-2965-event]\r\n" ++ "ATTENDEE;PATCH-ACTION=\"BYPARAM@CN=Alice\";" ++ "PARTSTAT=ACCEPTED:MAILTO:[email protected]\r\n" ++ "END:PATCH\r\n" ++ "END:VPATCH\r\n" ++ "END:VCALENDAR\r\n"); ++ ++ /* icalcomponent_apply_vpatch expects the VPATCH component, not ++ * the wrapping VCALENDAR. caldav_patch() pulls it out the same ++ * way before calling apply_vpatch. */ ++ icalcomponent *pdoc = ical_string_as_icalcomponent(&buf); ++ CU_ASSERT_PTR_NOT_NULL_FATAL(pdoc); ++ icalcomponent *vpatch = icalcomponent_get_first_real_component(pdoc); ++ CU_ASSERT_PTR_NOT_NULL_FATAL(vpatch); ++ ++ int num_changes = 0; ++ const char *errstr = NULL; ++ ++ /* The bug: this call double-frees the BYPARAM selector strings ++ * when the second ATTENDEE iteration runs. Under glibc malloc ++ * detection or ASAN, the process aborts here. Post-fix it ++ * returns cleanly. ++ */ ++ int r = icalcomponent_apply_vpatch(ical, vpatch, &num_changes, &errstr); ++ CU_ASSERT_EQUAL(r, 0); ++ ++ icalcomponent_free(ical); ++ icalcomponent_free(pdoc); ++ buf_free(&buf); ++} ++ ++/* vim: set ft=c: */ +--- a/imap/ical_support.c ++++ b/imap/ical_support.c +@@ -1996,10 +1996,11 @@ static void apply_property_updates(struc + icalproperty_get_value_as_string(prop)); + } + else if (action == ICAL_PATCHACTION_BYPARAM) { +- /* Check param-match */ ++ /* Check param-match. byparam.prop.{param,value} ++ * are owned by the outer iteration and reused ++ * for each property; free them after the inner ++ * loop, not inside it. */ + match = apply_param_match(prop, &byparam); +- free(byparam.prop.param); +- free(byparam.prop.value); + } + if (!match) continue; + +@@ -2008,6 +2009,11 @@ static void apply_property_updates(struc + } + } + ++ if (action == ICAL_PATCHACTION_BYPARAM) { ++ free(byparam.prop.param); ++ free(byparam.prop.value); ++ } ++ + *num_changes += 1; + icalcomponent_add_property(parent, newprop); + } diff --git a/debian/patches/0034-http_dav_c-re-check-ACL-on-each-href-in-calendar-addressbook-multiget.patch b/debian/patches/0034-http_dav_c-re-check-ACL-on-each-href-in-calendar-addressbook-multiget.patch new file mode 100644 index 00000000..a64d7d22 --- /dev/null +++ b/debian/patches/0034-http_dav_c-re-check-ACL-on-each-href-in-calendar-addressbook-multiget.patch @@ -0,0 +1,184 @@ +Description: http_dav.c: re-check ACL on each href in calendar/addressbook-multiget (CVE-2026-61909) + The CalDAV/CardDAV multiget REPORT lets the client supply a list of + hrefs identifying resources to fetch. report_multiget doesn't check + that the authenticated user has the required privileges on the mailbox + each href resolves to. + . + Add the same httpd_myrights check that propfind_by_collection performs, + and emit 403 Forbidden for any href whose target the user cannot read. + . + Add a regression test: cassandane creates an event in their own + calendar, victim creates an event in theirs, and cassandane (with no + ACL on victim's calendar) issues a calendar-multiget naming both + hrefs. The test asserts that victim's SUMMARY does not appear in + the response. + . + This is CYR-2946, CVE-2026-61909. + . + Reported by Ahmed Said. + . + Co-Authored-By: Claude <[email protected]> +Author: Ricardo Signes <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/34bd09a9b4cb7c996d0931992da0bdfc92c7cd0b +Bug: https://security-tracker.debian.org/tracker/CVE-2026-61909 +Bug-CVE: CVE-2026-61909 +Forwarded: not-needed +Last-Update: 2026-09-10 + + cassandane/Cassandane/Cyrus/Caldav.pm | 88 +++++++++++++++++++++++++++ + changes/next/CVE-2026-61909 | 29 +++++++++ + imap/http_dav.c | 7 +++ + 3 files changed, 124 insertions(+) + create mode 100644 changes/next/CVE-2026-61909 + +diff --git a/cassandane/Cassandane/Cyrus/Caldav.pm b/cassandane/Cassandane/Cyrus/Caldav.pm +index f1c6b3115fb..2899efc1132 100644 +--- a/cassandane/Cassandane/Cyrus/Caldav.pm ++++ b/cassandane/Cassandane/Cyrus/Caldav.pm +@@ -6310,4 +6310,92 @@ EOF + } + } + ++sub test_multiget_acl_bypass ++ :NoAltNameSpace ++{ ++ my ($self) = @_; ++ my $admintalk = $self->{adminstore}->get_client(); ++ ++ xlog $self, "Create victim user with their own private calendar"; ++ $admintalk->create("user.victim"); ++ $admintalk->setacl("user.victim", "victim" => "lrswipkxtean"); ++ ++ my $service = $self->{instance}->get_service("http"); ++ my $victimtalk = Net::CalDAVTalk->new( ++ user => "victim", ++ password => 'pass', ++ host => $service->host(), ++ port => $service->port(), ++ scheme => 'http', ++ url => '/', ++ expandurl => 1, ++ ); ++ ++ # NewEvent returns a path like "Default/<uid>.ics" relative to ++ # victim's calendar home. ++ my $victim_rel = $victimtalk->NewEvent('Default', { ++ timeZone => 'Etc/UTC', ++ start => '2025-01-01T12:00:00', ++ duration => 'PT1H', ++ title => 'victim-secret-meeting', ++ }); ++ ++ my $victim_shared_rel = "victim.$victim_rel"; ++ ++ my $victim_shared_abs = "/dav/calendars/user/cassandane/$victim_shared_rel"; ++ ++ xlog $self, "Cassandane has their own event in their own calendar"; ++ my $caldav = $self->{caldav}; ++ my $cass_rel = $caldav->NewEvent('Default', { ++ timeZone => 'Etc/UTC', ++ start => '2025-01-01T12:00:00', ++ duration => 'PT1H', ++ title => 'cass-public-meeting', ++ }); ++ my $cass_abs = "/dav/calendars/user/cassandane/$cass_rel"; ++ ++ # No sharing: cassandane has zero rights on user.victim.#calendars.Default ++ ++ xlog $self, "Cassandane sends calendar-multiget naming victim's event"; ++ my $xml = <<EOF; ++<?xml version="1.0" encoding="utf-8" ?> ++<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> ++ <D:prop> ++ <C:calendar-data/> ++ </D:prop> ++ <D:href>$cass_abs</D:href> ++ <D:href>$victim_shared_abs</D:href> ++</C:calendar-multiget> ++EOF ++ ++ my $res = $caldav->Request('REPORT', ++ "/dav/calendars/user/cassandane/Default", ++ $xml, ++ 'Content-Type' => 'application/xml', ++ ); ++ ++ my @responses = ($res->{'{DAV:}response'} // [])->@*; ++ $self->assert_num_equals(2, scalar @responses); ++ ++ # First href: cassandane's own event -- returned successfully. ++ $self->assert_str_equals($cass_abs, ++ $responses[0]{'{DAV:}href'}{content}); ++ my $r0_data = $responses[0]{'{DAV:}propstat'}[0]{'{DAV:}prop'} ++ {'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content}; ++ $self->assert_matches(qr/cass-public-meeting/, $r0_data); ++ ++ # Second href: victim's event. ++ $self->assert_str_equals($victim_shared_abs, ++ $responses[1]{'{DAV:}href'}{content}); ++ my $r1_data = $responses[1]{'{DAV:}propstat'}[0]{'{DAV:}prop'} ++ {'{urn:ietf:params:xml:ns:caldav}calendar-data'}{content} ++ // ''; ++ $self->assert_does_not_match(qr/victim-secret-meeting/, $r1_data); ++ ++ $self->assert_str_equals( ++ 'HTTP/1.1 403 Forbidden', ++ $responses[1]{'{DAV:}status'}{content}, ++ ); ++} ++ + 1; +diff --git a/changes/next/CVE-2026-61909 b/changes/next/CVE-2026-61909 +new file mode 100644 +index 00000000000..a074ae296dc +--- /dev/null ++++ b/changes/next/CVE-2026-61909 +@@ -0,0 +1,29 @@ ++Description: ++ ++:cve:`CVE-2026-61909`: CalDAV/CardDAV multiget bypasses per-href ACL ++ ++An authenticated DAV user with some shared access to another user's calendar ++or address book could read even unshared events or contacts by including the ++target hrefs in a calendar-multiget or addressbook-multiget REPORT. ++ ++Reported by: Ahmed Said. ++ ++ ++Documentation: ++ ++N/A ++ ++ ++Config changes: ++ ++None ++ ++ ++Upgrade instructions: ++ ++None ++ ++ ++GitHub issue: ++ ++CVE-2026-61909 +diff --git a/imap/http_dav.c b/imap/http_dav.c +index e255b15ffcb..987dceaa1fe 100644 +--- a/imap/http_dav.c ++++ b/imap/http_dav.c +@@ -7472,6 +7472,13 @@ int report_multiget(struct transaction_t *txn, struct meth_params *rparams, + + fctx->mbentry = tgt.mbentry; + ++ /* Check ACL for multiget URL */ ++ int rights = httpd_myrights(httpd_authstate, tgt.mbentry); ++ if ((rights & fctx->reqd_privs) != fctx->reqd_privs) { ++ xml_add_response(fctx, HTTP_FORBIDDEN, 0, NULL, NULL); ++ goto next; ++ } ++ + /* Check if we already have this mailbox open */ + if (!mailbox || strcmp(mailbox_name(mailbox), tgt.mbentry->name)) { + if (mailbox) mailbox_close(&mailbox); + diff --git a/debian/patches/0035-jmap_mail_c-enforce-destination-ACL-on-snooze.patch b/debian/patches/0035-jmap_mail_c-enforce-destination-ACL-on-snooze.patch new file mode 100644 index 00000000..8f52ab50 --- /dev/null +++ b/debian/patches/0035-jmap_mail_c-enforce-destination-ACL-on-snooze.patch @@ -0,0 +1,395 @@ +Description: jmap_mail.c: enforce destination ACL on snooze (CVE-2026-61907) + Validate the destination at JMAP staging time, using the *actor's* + ACL. Require JACL_LOOKUP|JACL_ADDITEMS on the target mailbox, if any. + When absent, require the same on the account's INBOX (the fallback the + alarm worker would use). Apply the same check on Email/set update (full + and patched) and on Email/import. + . + This doesn't stop this scenario: + . + * sharee has +p to sharer's snoozed and inbox + * sharee puts a message into sharer's snoozed, destined for inbox + * during snooze time, sharer removes sharee access to sharer's inbox + * at awaken time, message is written to sharer inbox + . + This is an intentional choice, and by design. + . + This is CYR-2963, CVE-2026-61907. + . + Reported by Michael Lynch (mtlynch.io). + . + Co-Authored-By: Claude <[email protected]> +Author: Ricardo Signes <[email protected]> +Origin: upstream, https://github.com/cyrusimap/cyrus-imapd/commit/16e851ccf6491f3d83e2f079c24eaf837b1398a6 +Bug: https://security-tracker.debian.org/tracker/CVE-2026-61907 +Bug-CVE: CVE-2026-61907 +Forwarded: not-needed +Last-Update: 2026-09-10 + + .../JMAPEmail/email_set_snooze_acl_bypass | 162 ++++++++++++++++++ + changes/next/CVE-2026-61907 | 30 ++++ + imap/jmap_mail.c | 87 ++++++++-- + 3 files changed, 267 insertions(+), 12 deletions(-) + create mode 100644 cassandane/tiny-tests/JMAPEmail/email_set_snooze_acl_bypass + create mode 100644 changes/next/CVE-2026-61907 + +diff --git a/cassandane/tiny-tests/JMAPEmail/email_set_snooze_acl_bypass b/cassandane/tiny-tests/JMAPEmail/email_set_snooze_acl_bypass +new file mode 100644 +index 00000000000..22327decea6 +--- /dev/null ++++ b/cassandane/tiny-tests/JMAPEmail/email_set_snooze_acl_bypass +@@ -0,0 +1,162 @@ ++#!perl ++use Cassandane::Tiny; ++ ++# If "sharer" shares \Snoozed with "cassandane", that still isn't enough to ++# really use snooze: cassandane also needs to have +i for the eventual target ++# mailbox. Otherwise, this is a back door to append to any mailbox, at least ++# if you know the mailboxId. ++sub test_email_set_snooze_acl_bypass ++ :min_version_3_5 :needs_component_calalarmd ++ :needs_component_sieve :JMAPExtensions :NoAltNameSpace ++{ ++ my ($self) = @_; ++ my $jmap = $self->{jmap}; ++ my $admin = $self->{adminstore}->get_client; ++ ++ # we need 'https://cyrusimap.org/ns/jmap/mail' capability for ++ # snoozed property ++ my @using = @{ $jmap->DefaultUsing() }; ++ push @using, 'https://cyrusimap.org/ns/jmap/mail'; ++ $jmap->DefaultUsing(\@using); ++ ++ xlog $self, "create sharer account"; ++ $admin->create("user.sharer") or die; ++ ++ my $service = $self->{instance}->get_service("http"); ++ my $sharer_jmap = Mail::JMAPTalk->new( ++ user => 'sharer', ++ password => 'pass', ++ host => $service->host(), ++ port => $service->port(), ++ scheme => 'http', ++ url => '/jmap/', ++ ); ++ ++ @using = @{ $sharer_jmap->DefaultUsing() }; ++ push @using, 'https://cyrusimap.org/ns/jmap/mail'; ++ $sharer_jmap->DefaultUsing(\@using); ++ ++ xlog $self, "sharer creates a Snoozed mailbox with role=snoozed"; ++ my $res = $sharer_jmap->CallMethods([ ++ ['Mailbox/set', { ++ create => { ++ snoozed => { ++ name => 'Snoozed', ++ parentId => undef, ++ role => 'snoozed', ++ }, ++ }, ++ }, "R1"], ++ ]); ++ $self->assert_str_equals('Mailbox/set', $res->[0][0]); ++ $self->assert_str_equals('R1', $res->[0][2]); ++ $self->assert_not_null($res->[0][1]{created}); ++ my $snoozedId = $res->[0][1]{created}{snoozed}{id}; ++ $self->assert_not_null($snoozedId); ++ ++ xlog $self, "sharer creates a non-snoozed mailbox to be a forbidden target"; ++ $res = $sharer_jmap->CallMethods([ ++ ['Mailbox/set', { ++ create => { ++ forbidden => { ++ name => 'Forbidden', ++ parentId => undef, ++ }, ++ }, ++ }, "R2"], ++ ]); ++ $self->assert_str_equals('Mailbox/set', $res->[0][0]); ++ $self->assert_str_equals('R2', $res->[0][2]); ++ $self->assert_not_null($res->[0][1]{created}); ++ my $forbidden_id = $res->[0][1]{created}{forbidden}{id}; ++ $self->assert_not_null($forbidden_id); ++ ++ xlog $self, "grant cassandane lookup on sharer, insert on Snoozed only"; ++ # cassandane gets lrs on user.sharer (the inbox itself) and one other ++ # mailbox -- enough to see their ids but not enough to add messages ++ $admin->setacl("user.sharer", cassandane => 'lrs') or die; ++ $admin->setacl("user.sharer.Forbidden", cassandane => 'l') or die; ++ ++ # cassandane gets full add-message rights on Snoozed. ++ $admin->setacl("user.sharer.Snoozed", cassandane => 'lrswipkxtecdn') or die; ++ ++ xlog $self, "sanity: cassandane cannot create directly into sharer's Inbox"; ++ $res = $jmap->CallMethods([ ++ ['Mailbox/get', { accountId => 'sharer' }, "R3"], ++ ]); ++ $self->assert_str_equals('Mailbox/get', $res->[0][0]); ++ $self->assert_str_equals('R3', $res->[0][2]); ++ $self->assert_not_null($res->[0][1]{list}); ++ ++ my %mbox_by_role = map { $_->{role} ? ($_->{role} => $_->{id}) : () } ++ @{$res->[0][1]{list}}; ++ my $sharer_inbox_id = $mbox_by_role{inbox}; ++ $self->assert_not_null($sharer_inbox_id); ++ ++ $res = $jmap->CallMethods([ ++ ['Email/set', { ++ accountId => 'sharer', ++ create => { ++ direct => { ++ mailboxIds => { $sharer_inbox_id => JSON::true }, ++ from => [{ email => '[email protected]' }], ++ to => [{ email => '[email protected]' }], ++ subject => 'direct attempt', ++ bodyStructure => { ++ type => 'text/plain', ++ partId => 'p1', ++ }, ++ bodyValues => { ++ p1 => { value => 'direct write should fail' }, ++ }, ++ }, ++ }, ++ }, "R4"], ++ ]); ++ $self->assert_str_equals('Email/set', $res->[0][0]); ++ $self->assert_str_equals('R4', $res->[0][2]); ++ $self->assert_null($res->[0][1]{created}{direct}); ++ $self->assert_not_null($res->[0][1]{notCreated}{direct}); ++ ++ my $time_str = DateTime->now->strftime('%Y-%m-%dT%TZ'); ++ ++ # Both shapes of the bug: omitting moveToMailboxId (fall back to the ++ # owner's Inbox) and naming an explicit destination cassandane has no ++ # insert rights ++ for my $target_id (undef, $forbidden_id) { ++ my $snoozed = { until => $time_str }; ++ $snoozed->{moveToMailboxId} = $target_id if defined $target_id; ++ ++ xlog $self, "cassandane snoozes a message" . ++ (defined $target_id ++ ? " with moveToMailboxId=$target_id" ++ : " without moveToMailboxId"); ++ ++ $res = $jmap->CallMethods([ ++ ['Email/set', { ++ accountId => 'sharer', ++ create => { ++ planted => { ++ mailboxIds => { '$snoozed' => JSON::true }, ++ from => [{ email => '[email protected]' }], ++ to => [{ email => '[email protected]' }], ++ subject => 'snooze planted', ++ snoozed => $snoozed, ++ bodyStructure => { ++ type => 'text/plain', ++ partId => 'p1', ++ }, ++ bodyValues => { ++ p1 => { value => 'planted via snooze' }, ++ }, ++ }, ++ }, ++ }, "R5"], ++ ]); ++ ++ $self->assert_str_equals('Email/set', $res->[0][0]); ++ $self->assert_str_equals('R5', $res->[0][2]); ++ $self->assert_null($res->[0][1]{created}{planted}); ++ $self->assert_not_null($res->[0][1]{notCreated}{planted}); ++ } ++} +diff --git a/changes/next/CVE-2026-61907 b/changes/next/CVE-2026-61907 +new file mode 100644 +index 00000000000..287314f0e40 +--- /dev/null ++++ b/changes/next/CVE-2026-61907 +@@ -0,0 +1,30 @@ ++Description: ++ ++:cve:`CVE-2026-61907`: JMAP snooze bypasses destination-mailbox ACL ++ ++An authenticated user with insert permissions on another user's snoozed mailbox ++could cause insertion of mail to that user's inbox, or any other of their ++mailboxes whose id was known to the user, despite having no insert permissions ++to the target mailbox. ++ ++Reported by: Michael Lynch (mtlynch.io). ++ ++ ++Documentation: ++ ++N/A ++ ++ ++Config changes: ++ ++None ++ ++ ++Upgrade instructions: ++ ++None ++ ++ ++GitHub issue: ++ ++CVE-2026-61907 +diff --git a/imap/jmap_mail.c b/imap/jmap_mail.c +index feb2aef9495..1135905f27b 100644 +--- a/imap/jmap_mail.c ++++ b/imap/jmap_mail.c +@@ -10216,11 +10216,14 @@ static void _email_parse_bodies(jmap_req_t *req, + } + } + +-static void _email_snoozed_parse(json_t *snoozed, ++static void _email_snoozed_parse(jmap_req_t *req, ++ json_t *snoozed, + struct jmap_parser *parser) + { + const char *field; + json_t *jval; ++ json_t *jmove = NULL; ++ int saw_move = 0; + + jmap_parser_push(parser, "snoozed"); + json_object_foreach(snoozed, field, jval) { +@@ -10242,10 +10245,41 @@ static void _email_snoozed_parse(json_t *snoozed, + } + jmap_parser_pop(parser); + } +- else if (strcmp(field, "moveToMailboxId")) { ++ else if (!strcmp(field, "moveToMailboxId")) { ++ saw_move = 1; ++ jmove = jval; ++ } ++ else { + jmap_parser_invalid(parser, field); + } + } ++ ++ /* The caller must have JACL_ADDITEMS on the eventual destination, since ++ * the deferred move at awaken time runs under the *owner's* authority. */ ++ int need_rights = JACL_LOOKUP|JACL_ADDITEMS; ++ if (saw_move && JNOTNULL(jmove)) { ++ const mbentry_t *mbentry = NULL; ++ const char *destid = json_string_value(jmove); ++ if (!destid) { ++ jmap_parser_invalid(parser, "moveToMailboxId"); ++ } ++ else { ++ mbentry = jmap_mbentry_by_uniqueid(req, destid); ++ if (!mbentry || (mbentry->mbtype & MBTYPE_DELETED) || ++ mboxname_isdeletedmailbox(mbentry->name, NULL) || ++ !jmap_hasrights_mbentry(req, mbentry, need_rights)) { ++ jmap_parser_invalid(parser, "moveToMailboxId"); ++ } ++ } ++ } ++ else { ++ const mbentry_t *inbox_mbe = NULL; ++ if (jmap_findmbox_role(req, "inbox", &inbox_mbe) || ++ !inbox_mbe || ++ !jmap_hasrights_mbentry(req, inbox_mbe, need_rights)) { ++ jmap_parser_invalid(parser, "moveToMailboxId"); ++ } ++ } + jmap_parser_pop(parser); + } + +@@ -10465,7 +10499,7 @@ static void _parse_email(jmap_req_t *req, + /* Is snoozed being set? */ + json_t *snoozed = json_object_get(jemail, "snoozed"); + if (json_is_object(snoozed)) { +- _email_snoozed_parse(snoozed, parser); ++ _email_snoozed_parse(req, snoozed, parser); + } + else if (JNOTNULL(snoozed)) { + jmap_parser_invalid(parser, "snoozed"); +@@ -11238,7 +11272,8 @@ struct email_bulkupdate { + ptrarray_new() \ + } + +-static void _email_update_parse(json_t *jemail, ++static void _email_update_parse(jmap_req_t *req, ++ json_t *jemail, + struct jmap_parser *parser, + struct email_update *update) + { +@@ -11372,7 +11407,25 @@ static void _email_update_parse(json_t *jemail, + !jmap_email_keyword_is_valid(keyword)) invalid = 1; + } + else if (!strcmp(subfield, "moveToMailboxId")) { +- if (!json_is_string(jval)) invalid = 1; ++ if (!json_is_string(jval)) { ++ invalid = 1; ++ } ++ else { ++ /* Same destination ACL check the create path ++ * performs in _email_snoozed_parse: the deferred ++ * move runs as the *owner* of the source ++ * mailbox, so without this check a sharee can ++ * land messages in mailboxes they cannot write. ++ * -- claude, 2026-05-07 */ ++ int need_rights = JACL_LOOKUP|JACL_ADDITEMS; ++ const mbentry_t *mbentry = ++ jmap_mbentry_by_uniqueid(req, json_string_value(jval)); ++ if (!mbentry || (mbentry->mbtype & MBTYPE_DELETED) || ++ mboxname_isdeletedmailbox(mbentry->name, NULL) || ++ !jmap_hasrights_mbentry(req, mbentry, need_rights)) { ++ invalid = 1; ++ } ++ } + } + else invalid = 1; + +@@ -11391,7 +11444,7 @@ static void _email_update_parse(json_t *jemail, + } + } + else if (json_is_object(snoozed)) { +- _email_snoozed_parse(snoozed, parser); ++ _email_snoozed_parse(req, snoozed, parser); + } + else if (JNOTNULL(snoozed)) { + jmap_parser_invalid(parser, "snoozed"); +@@ -12966,7 +13019,7 @@ static void _email_update_bulk(jmap_req_t *req, + struct jmap_parser parser = JMAP_PARSER_INITIALIZER; + struct email_update *update = xzmalloc(sizeof(struct email_update)); + update->email_id = email_id; +- _email_update_parse(jval, &parser, update); ++ _email_update_parse(req, jval, &parser, update); + + /* Validate patched mailbox ids */ + if (update->patch_mailboxids && !json_array_size(parser.invalid)) { +@@ -13494,12 +13547,22 @@ static int jmap_email_import(jmap_req_t *req) + jmap_parser_invalid(&parser, "mailboxIds"); + } + +- /* Validate snoozed + mailboxIds */ ++ /* Validate snoozed + mailboxIds. Run the same destination ++ * ACL check the create/update paths do, since the deferred ++ * move at wake time runs as the *owner* of the source ++ * mailbox -- a sharee with insert rights on Snoozed could ++ * otherwise land messages anywhere in the owner's namespace. ++ * -- claude, 2026-05-07 */ + json_t *snoozed = json_object_get(jemail_import, "snoozed"); +- if (JNOTNULL(snoozed) && +- !(json_is_utcdate(json_object_get(snoozed, "until")) && +- have_snoozed_mboxid)) { +- jmap_parser_invalid(&parser, "snoozed"); ++ if (JNOTNULL(snoozed)) { ++ if (!(json_is_object(snoozed) && ++ json_is_utcdate(json_object_get(snoozed, "until")) && ++ have_snoozed_mboxid)) { ++ jmap_parser_invalid(&parser, "snoozed"); ++ } ++ else { ++ _email_snoozed_parse(req, snoozed, &parser); ++ } + } + + json_t *invalid = json_incref(parser.invalid); + diff --git a/debian/patches/series b/debian/patches/series index d8640fd6..3d5537c6 100644 --- a/debian/patches/series +++ b/debian/patches/series @@ -17,3 +17,10 @@ eventsource-without-websocket.patch 0026-imapd.c-re-check-URLAUTH-authorizer-access-just-in-t.patch 0027-imapd-require-read-access-on-mailbox-in-GENURLAUTH.patch 0028-imapd-fix-cmd_delete-force-handling.patch +0029-Sieve-mailboxexists-metadata-bypass-ACL-lookup-check.patch +0030-jmap_mailbox_c-Mailbox-set-must-not-let-sharees-change.patch +0031-jmap_mail_c-bounds-check-blob_headers-index-in-email_header-blob-id.patch +0032-ical_support_c-do-not-free-parameters-removed-by-ref.patch +0033-ical_support_c-fix-VPATCH-BYPARAM-selector-double-free.patch +0034-http_dav_c-re-check-ACL-on-each-href-in-calendar-addressbook-multiget.patch +0035-jmap_mail_c-enforce-destination-ACL-on-snooze.patch
OpenPGP_signature.asc
Description: OpenPGP digital signature

