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

Attachment: OpenPGP_signature.asc
Description: OpenPGP digital signature

Reply via email to