Version in base suite: 2.4.1+dfsg1-6+deb13u6 Base version: dovecot_2.4.1+dfsg1-6+deb13u6 Target version: dovecot_2.4.1+dfsg1-6+deb13u7 Base file: /srv/ftp-master.debian.org/ftp/pool/main/d/dovecot/dovecot_2.4.1+dfsg1-6+deb13u6.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/d/dovecot/dovecot_2.4.1+dfsg1-6+deb13u7.dsc changelog | 52 patches/0001-auth-db-oauth2-Reduce-nesting-in-token_in_scope-via-.patch | 71 + patches/0001-commin-Rename-LOGIN_PROXY_FAILURE_TYPE_AUTH-to-LOGIN.patch | 143 ++ patches/0001-dsync-Avoid-potentially-excessive-data-stack-growth-.patch | 40 patches/0001-global-Fix-spelling.patch | 34 patches/0001-imap-Extract-side-channel-ostream-creation-into-help.patch | 82 + patches/0001-imap-Restrict-COMPRESS-to-DEFLATE-only.patch | 67 + patches/0001-imap-hibernate-Fix-out-of-bounds-read-when-parsing-D.patch | 35 patches/0001-imap-login-Add-comments-to-internal-ID-command-param.patch | 33 patches/0001-imap-login-Fix-excessive-memory-growth-with-pre-logi.patch | 86 + patches/0001-lib-Add-XXH64-hash-implementation.patch | 331 +++++ patches/0001-lib-Add-str_equals_timing_safe.patch | 124 + patches/0001-lib-compression-Don-t-calculate-crc32-if-using-zlib-.patch | 45 patches/0001-lib-compression-istream-lz4-Ensure-uncompressed-chun.patch | 98 + patches/0001-lib-compression-istream-lz4-Try-again-if-no-data-was.patch | 60 patches/0001-lib-compression-istream-zlib-Use-container_of-macro.patch | 56 patches/0001-lib-compression-wrap-_read-in-for-loop-reindent-only.patch | 611 +++++++++ patches/0001-lib-i_close_fd-Document-that-it-preserves-errno.patch | 25 patches/0001-lib-imap-imap-match-Fix-excessive-CPU-usage-caused-b.patch | 524 ++++++++ patches/0001-lib-imap-urlauth-Fix-leaking-uninitialized-memory-in.patch | 35 patches/0001-lib-lda-mail-deliver-Autocreate-delivery-mailbox-via.patch | 63 + patches/0001-lib-mail-Reset-charset-translation-buffer-between-MI.patch | 105 + patches/0001-lib-mail-o_stream_dot_sendv-Use-I_MIN.patch | 23 patches/0001-lib-mail-ostream-dot-Optimize-stream-writing.patch | 60 patches/0001-lib-managesieve-managesieve-parser-Fix-handling-of-l.patch | 30 patches/0001-lib-sieve-Add-per-user-sieve-rusage-file-for-cumulat.patch | 595 +++++++++ patches/0001-lib-sieve-actions-Split-mailbox-creation-into-sieve_.patch | 124 + patches/0001-lib-sieve-sieve-binary-code-Fix-single-byte-oob-stac.patch | 41 patches/0001-lib-sieve-sieve-storage-Add-is_personal-bit.patch | 72 + patches/0001-lib-sieve-sieve-storage-Fix-panic-caused-by-flawed-d.patch | 53 patches/0001-lib-sieve-storage-file-Refuse-symlinks-escaping-pers.patch | 267 ++++ patches/0001-lib-sieve-util-edit-mail-Fix-writing-to-freed-memory.patch | 128 ++ patches/0001-lib-sql-Add-sql_result_new_error-helper.patch | 66 + patches/0001-lib-sql-Use-strchr-based-while-loop-to-fill-template.patch | 100 + patches/0001-lib-sql-driver-sqlite-Use-sqlite3_snprintf-to-quote-.patch | 51 patches/0001-lib-sql-sql-api-Implement-async-calls-for-drivers-th.patch | 247 +++ patches/0001-lib-storage-Allow-NULL-path-in-mailbox_list_mkdir_ro.patch | 44 patches/0001-lib-storage-thread-Avoid-excessive-data-stack-growth.patch | 55 patches/0001-lib-test-Add-test_assert_memcmp.patch | 104 + patches/0001-lib-test-Require-both-lengths-in-test_assert_memcmp.patch | 45 patches/0001-lib-xxh64-fix-byte-ordering-bug-on-big-endian-system.patch | 45 patches/0001-login-Add-LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED.patch | 99 + patches/0001-login-common-Fix-crash-when-XCLIENT-FORWARD-base64-c.patch | 42 patches/0001-login-common-client-common-Add-client_disconnect-vfu.patch | 36 patches/0001-login-common-login-Add-proxy_dest_connection_limit-e.patch | 134 ++ patches/0001-managesieve-login-Adjust-to-core-s-login_proxy_failu.patch | 30 patches/0001-managesieve-login-Support-LOGIN_PROXY_FAILURE_TYPE_A.patch | 12 patches/0001-submission-Fix-settings-leak-on-error-paths-in-clien.patch | 33 patches/0002-auth-Fix-prefixing-forward_fields-without-a-value-fr.patch | 30 patches/0002-auth-db-oauth2-Fix-scope-check-to-require-all-config.patch | 49 patches/0002-doveadm-Avoid-leaking-doveadm_password-or-doveadm_ap.patch | 49 patches/0002-doveadm-compress-connect-Don-t-hang-when-server-reje.patch | 120 + patches/0002-dsync-Fix-escaping-mail-or-attribute-value-that-begi.patch | 37 patches/0002-imap-Recreate-multiplex-ostream-side-channel-after-u.patch | 33 patches/0002-imap-Stream-BODYSTRUCTURE-to-client-without-ostream-.patch | 79 + patches/0002-imap-hibernate-Use-imap-parser-API-for-parsing-DONE-.patch | 152 ++ patches/0002-lib-Replace-str_hash-strcase_hash-with-xxh64.patch | 80 + patches/0002-lib-compression-Iterate-instead-of-recursing-on-zero.patch | 187 ++ patches/0002-lib-mail-o_stream_dot_sendv-Fix-boundary-off-by-one.patch | 62 patches/0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch | 406 ++++++ patches/0002-lib-sieve-Drop-binary-header-resource-usage-tracking.patch | 304 ++++ patches/0002-lib-sieve-actions-Create-fileinto-mailbox-without-th.patch | 146 ++ patches/0002-lib-sieve-storage-file-Validate-script-stat-path-thr.patch | 129 ++ patches/0002-lib-sieve-util-test-edit-mail-Adjust-to-changes-in-d.patch | 104 + patches/0002-lib-sql-Extract-sql_query_callback_delayed-helper.patch | 53 patches/0002-lib-storage-Remove-MAILBOX_FLAG_AUTO_CREATE-and-MAIL.patch | 70 + patches/0002-lib-storage-Simplify-mailbox_mkdir-using-mailbox_lis.patch | 31 patches/0002-lib-storage-thread-Limit-References-header-msgid-cou.patch | 47 patches/0002-login-common-Avoid-array_front-panic-on-empty-forwar.patch | 50 patches/0002-managesieve-login-client_skip_line-Discard-data-when.patch | 29 patches/0002-submission-login-client-Fix-panic-occurring-at-mail_.patch | 78 + patches/0003-auth-db-oauth2-Add-oauth2_audience-setting-deprecate.patch | 131 ++ patches/0003-doveadm-compress-Delay-failing-if-used-COMPRESS-algo.patch | 41 patches/0003-imap-Stream-ENVELOPE-to-client-without-ostream-memor.patch | 75 + patches/0003-lib-Add-str_stable_hash.patch | 43 patches/0003-lib-Add-t_openat_safe_dir.patch | 129 ++ patches/0003-lib-compression-Add-o_stream_deflate_reset_dict.patch | 104 + patches/0003-lib-compression-istream-zstd-Guard-against-no-progre.patch | 39 patches/0003-lib-mail-o_stream_dot_sendv-Use-enumeration-to-defin.patch | 91 + patches/0003-lib-sieve-Rename-on-disk-header-rusage-fields-to-unu.patch | 71 + patches/0003-lib-sieve-edit_mail_headers_parse-Fix-info-leak-via-.patch | 271 ++++ patches/0003-lib-smtp-smtp-server-Expose-smtp_server_connection_r.patch | 51 patches/0003-lib-sql-Add-sql_commit_schedule_delayed-helper.patch | 50 patches/0003-lib-storage-thread-Limit-ancestor-chain-traversal-de.patch | 92 + patches/0004-acl-dovecot-acl-list-is-never-supposed-to-be-a-symli.patch | 26 patches/0004-imap-login-Add-dict_reset-side-channel-command-to-im.patch | 55 patches/0004-lib-Key-str_hash-strcase_hash-with-a-random-seed-to-.patch | 120 + patches/0004-lib-compression-test-Add-zero-len-frame-test-for-all.patch | 72 + patches/0004-lib-mail-message-parser-from-parts-Enforce-50-MB-all.patch | 52 patches/0004-lib-mail-o_stream_dot_sendv-Do-not-send-unguarded-.-.patch | 216 +++ patches/0004-lib-sieve-Switch-sieve_max_cpu_time-enforcement-to-t.patch | 294 ++++ patches/0004-lib-var-expand-Change-escape-func-signature-to-retur.patch | 305 ++++ patches/0004-submission-login-client-authenticate-Reply-421-4.7.0.patch | 51 patches/0005-imap-Add-imap_compress_on_proxy-hidden-setting.patch | 62 patches/0005-lib-Add-comments-about-memory-allocations-and-string.patch | 81 + patches/0005-lib-index-mail-index-strmap-Fix-OOB-read-from-trunca.patch | 27 patches/0005-lib-mail-Add-count-field-to-struct-message_address_l.patch | 56 patches/0005-lib-sieve-file-storage-Remove-orphan-compiled-binari.patch | 55 patches/0005-lib-sql-Make-escape_string-return-int-with-error_r-f.patch | 629 ++++++++++ patches/0005-submission-login-submission-proxy-Recognize-421-4.7..patch | 50 patches/0006-auth-passdb_sql-connect-before-expanding-query-varia.patch | 35 patches/0006-imap-Reset-DEFLATE-dictionary-after-every-tagged-rep.patch | 61 patches/0006-lib-index-Rename-crc32-variables-parameters-to-hash-.patch | 366 +++++ patches/0006-lib-sieve-Log-a-warning-when-cumulative-CPU-exceeds-.patch | 55 patches/0006-lib-storage-mbox-Explicitly-disable-the-header-block.patch | 50 patches/0007-lib-index-Add-keyed-xxh64-strmap-format-v2-gated-by-.patch | 364 +++++ patches/0007-lib-mail-istream-header-filter-Use-container_of-for-.patch | 65 + patches/0007-lib-sieve-Log-an-info-line-when-opening-a-CPU-disabl.patch | 52 patches/0007-lib-sql-Run-sql-statement-queries-in-their-own-data-.patch | 104 + patches/0008-doveadm-Fix-ubsan-unsigned-integer-overflow-error.patch | 29 patches/0008-lib-mail-Make-istream-header-filter-s-per-header-siz.patch | 91 + patches/0008-lib-sieve-Reduce-CPU-limit-check-interval-in-contain.patch | 40 patches/0009-config-Assume-dovecot_config_version-0.0.0-is-the-sa.patch | 92 + patches/0009-lib-storage-Cap-per-header-size-in-index_mail_get_he.patch | 42 patches/0010-lib-mail-Clamp-hdr-value-cumulatively-against-header.patch | 123 + patches/0010-lib-master-Move-config_version_find-from-src-config-.patch | 123 + patches/0011-lib-lib-master-Move-version_-to-lib-version.-ch.patch | 591 +++++++++ patches/0011-lib-mail-Introduce-struct-message_part_data_limits-a.patch | 323 +++++ patches/0012-config-doveconf-dF-Add-dovecot_storage_version.patch | 63 + patches/0012-lib-mail-Limit-total-address-count-per-message-to-10.patch | 71 + patches/0013-lib-mail-Limit-total-Content-Language-tag-count-per-.patch | 99 + patches/0014-lib-mail-Limit-total-MIME-parameter-count-per-messag.patch | 130 ++ patches/lib-mail-istream-header-filter-Fix-potential-assert-.patch | 84 + patches/series | 146 ++ rules | 3 salsa-ci.yml | 6 126 files changed, 14122 insertions(+), 6 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpcy056wps/dovecot_2.4.1+dfsg1-6+deb13u6.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpcy056wps/dovecot_2.4.1+dfsg1-6+deb13u7.dsc: no acceptable signature found diff -Nru dovecot-2.4.1+dfsg1/debian/changelog dovecot-2.4.1+dfsg1/debian/changelog --- dovecot-2.4.1+dfsg1/debian/changelog 2026-05-18 20:03:51.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/changelog 2026-09-16 19:06:35.000000000 +0000 @@ -1,3 +1,55 @@ +dovecot (1:2.4.1+dfsg1-6+deb13u7) trixie-security; urgency=medium + + * Import upstream fixes for multiple security issues. (Closes: #1146018) + - CVE-2026-27852: DoS by sending mail with bad header + - CVE-2026-33263: submission-login: Panic when + mail_max_userip_connections is reached: Panic: epoll_ctl(del, 8) + failed: Bad file descriptor + - CVE-2026-33604: SMTP Smuggling via Missing Dot-Stuffing After Bare + Carriage Return + - CVE-2026-33605: managesieve-login: Pre-auth crash + - CVE-2026-33607: Dovecot IMAP LIST match_sub() Exponential + Backtracking — CPU Denial of Service + - CVE-2026-40013: pigeonhole: Stack Buffer Underflow in Pigeonhole + ManageSieve CHECKSCRIPT/PUTSCRIPT + - CVE-2026-40014: IMAP THREAD REFERENCES O(N²) CPU DoS via Crafted + References Header (index-thread-links.c) + - CVE-2026-40015: imap-hibernate can be crashed + - CVE-2026-40017: IMAP THREAD O(M³) CPU DoS via CRC32 Hash Collision + in strmap (mail-index-strmap.c / hash2.c) + - CVE-2026-40018: MySQL multi-byte escaping wrong + - CVE-2026-40204: acl: lda_mailbox_autocreate can bypass acl + restrictions + - CVE-2026-42007: Sieve editheader RCE + - CVE-2026-42391: imap: Pre-login memory/CPU growth with ID command + - CVE-2026-42394: sieve: symlink traversal flaw could result in + arbitrary file disclosure + - CVE-2026-52681: Sieve resource usage tracking lost when active + script changes + - CVE-2026-52687: IMAP: COMPRESS ZSTD can cause excessive memory usage + - CVE-2026-73209: imap-login crash: Self-recursion on zero-output + decompress chunks + - CVE-2026-33606: dsync: Mail content can cause dsync protocol + injection + - CVE-2026-40203: IMAP Compression Can Reveal Whether a Small Synced + Email Body Matches Sender-Chosen Text + - CVE-2026-42008: XCLIENT FORWARD= bare token not namespaced, allows + nopassword injection via trusted proxy + - CVE-2026-42392: imap-urlauth leaks memory into user-visible error + messages + - CVE-2026-42393: doveadm_password or api key length can still be + leaked with timing comparisons + - CVE-2026-42395: Single NUL-Byte XCLIENT FORWARD Payload Crashes + - CVE-2026-40205: OAuth2 passdb scope enforcement bypass via OR + semantics in remote validation path + - CVE-2026-73208: auth: db-oauth2: aud claim used as fallback for + missing scope claim + * lib-mail: istream-header-filter - Fix potential assert-crash + (Closes: #1144639) + * CI: Disable test-build-twice job, which is known to fail on trixie + + -- Noah Meyerhans Wed, 16 Sep 2026 15:06:35 -0400 + dovecot (1:2.4.1+dfsg1-6+deb13u6) trixie-security; urgency=medium * Security update (Closes: #1136444) diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-auth-db-oauth2-Reduce-nesting-in-token_in_scope-via-.patch dovecot-2.4.1+dfsg1/debian/patches/0001-auth-db-oauth2-Reduce-nesting-in-token_in_scope-via-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-auth-db-oauth2-Reduce-nesting-in-token_in_scope-via-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-auth-db-oauth2-Reduce-nesting-in-token_in_scope-via-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,71 @@ +From 4c8919f9470ee61efe318fa4f7d410ba12098eed Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Wed, 3 Jun 2026 12:56:13 +0000 +Subject: [PATCH 1/4] auth: db-oauth2: Reduce nesting in token_in_scope via + early return + +--- + src/auth/db-oauth2.c | 48 ++++++++++++++++++++++---------------------- + 1 file changed, 24 insertions(+), 24 deletions(-) + +Index: trixie/src/auth/db-oauth2.c +=================================================================== +--- trixie.orig/src/auth/db-oauth2.c ++++ trixie/src/auth/db-oauth2.c +@@ -553,32 +553,32 @@ static bool + db_oauth2_token_in_scope(struct db_oauth2_request *req, + enum passdb_result *result_r, const char **error_r) + { +- bool found = TRUE; +- if (!array_is_empty(&req->db->set->scope)) { +- found = FALSE; +- const char *value = auth_fields_find(req->fields, "scope"); +- bool has_scope = value != NULL; +- if (!has_scope) +- value = auth_fields_find(req->fields, "aud"); +- e_debug(authdb_event(req->auth_request), +- "Token scope(s): %s", +- value); +- if (value != NULL) { +- const char *wanted_scope; +- const char *const *entries = has_scope ? +- t_strsplit_spaces(value, " ") : +- t_strsplit_tabescaped(value); +- array_foreach_elem(&req->db->set->scope, wanted_scope) { +- if ((found = str_array_find(entries, wanted_scope))) +- break; +- } +- } +- if (!found) { +- *error_r = t_strdup_printf("Token is not valid for scope '%s'", +- req->db->oauth2_set.scope); +- *result_r = PASSDB_RESULT_USER_DISABLED; ++ if (array_is_empty(&req->db->set->scope)) ++ return TRUE; ++ ++ bool found = FALSE; ++ const char *value = auth_fields_find(req->fields, "scope"); ++ bool has_scope = value != NULL; ++ if (!has_scope) ++ value = auth_fields_find(req->fields, "aud"); ++ e_debug(authdb_event(req->auth_request), ++ "Token scope(s): %s", ++ value); ++ if (value != NULL) { ++ const char *wanted_scope; ++ const char *const *entries = has_scope ? ++ t_strsplit_spaces(value, " ") : ++ t_strsplit_tabescaped(value); ++ array_foreach_elem(&req->db->set->scope, wanted_scope) { ++ if ((found = str_array_find(entries, wanted_scope))) ++ break; + } + } ++ if (!found) { ++ *error_r = t_strdup_printf("Token is not valid for scope '%s'", ++ t_array_const_string_join(&req->db->set->scope, " ")); ++ *result_r = PASSDB_RESULT_USER_DISABLED; ++ } + return found; + } + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-commin-Rename-LOGIN_PROXY_FAILURE_TYPE_AUTH-to-LOGIN.patch dovecot-2.4.1+dfsg1/debian/patches/0001-commin-Rename-LOGIN_PROXY_FAILURE_TYPE_AUTH-to-LOGIN.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-commin-Rename-LOGIN_PROXY_FAILURE_TYPE_AUTH-to-LOGIN.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-commin-Rename-LOGIN_PROXY_FAILURE_TYPE_AUTH-to-LOGIN.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,143 @@ +From d1b2e9e028b5bb8501b054c13c5208fbb4eff21f Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 18 Jul 2025 14:26:21 +0300 +Subject: [PATCH] *-commin: Rename LOGIN_PROXY_FAILURE_TYPE_AUTH to + LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED + +--- + src/imap-login/imap-proxy.c | 4 ++-- + src/login-common/client-common-auth.c | 2 +- + src/login-common/client-common.c | 2 +- + src/login-common/login-proxy.c | 4 ++-- + src/login-common/login-proxy.h | 2 +- + src/pop3-login/pop3-proxy.c | 4 ++-- + src/submission-login/submission-proxy.c | 4 ++-- + 7 files changed, 11 insertions(+), 11 deletions(-) + +Index: trixie/src/imap-login/imap-proxy.c +=================================================================== +--- trixie.orig/src/imap-login/imap-proxy.c ++++ trixie/src/imap-login/imap-proxy.c +@@ -427,7 +427,7 @@ int imap_proxy_parse_line(struct client + const char *log_line = line; + (void)str_begins_icase(log_line, "NO ", &log_line); + enum login_proxy_failure_type failure_type = +- LOGIN_PROXY_FAILURE_TYPE_AUTH; ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED; + #define STR_NO_IMAP_RESP_CODE_AUTHFAILED "NO ["IMAP_RESP_CODE_AUTHFAILED"]" + if (str_begins_with(line, STR_NO_IMAP_RESP_CODE_AUTHFAILED)) { + /* the remote sent a generic "authentication failed" +@@ -586,7 +586,7 @@ imap_proxy_send_failure_reply(struct ima + client_send_raw(&imap_client->common, t_strconcat( + imap_client->cmd_tag, " NO ", reason, "\r\n", NULL)); + break; +- case LOGIN_PROXY_FAILURE_TYPE_AUTH: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: + /* reply was already sent */ + break; + } +Index: trixie/src/login-common/client-common-auth.c +=================================================================== +--- trixie.orig/src/login-common/client-common-auth.c ++++ trixie/src/login-common/client-common-auth.c +@@ -426,7 +426,7 @@ proxy_redirect_reauth_callback(struct au + /* Disconnect from the original backend */ + login_proxy_failed(client->login_proxy, + login_proxy_get_event(client->login_proxy), +- LOGIN_PROXY_FAILURE_TYPE_AUTH, ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED, + t_strdup_printf("Redirected to %s", reply.proxy.host)); + return; + } +Index: trixie/src/login-common/client-common.c +=================================================================== +--- trixie.orig/src/login-common/client-common.c ++++ trixie/src/login-common/client-common.c +@@ -1385,7 +1385,7 @@ bool client_get_extra_disconnect_reason( + event_reason = "protocol_failure"; + last_reason = "protocol failure"; + break; +- case LOGIN_PROXY_FAILURE_TYPE_AUTH: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: + event_reason = "auth_failed"; + last_reason = "authentication failure"; + break; +Index: trixie/src/login-common/login-proxy.c +=================================================================== +--- trixie.orig/src/login-common/login-proxy.c ++++ trixie/src/login-common/login-proxy.c +@@ -829,7 +829,7 @@ bool login_proxy_failed(struct login_pro + case LOGIN_PROXY_FAILURE_TYPE_PROTOCOL: + log_prefix = "Remote server sent invalid input: "; + break; +- case LOGIN_PROXY_FAILURE_TYPE_AUTH: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: + log_prefix = ""; + try_reconnect = FALSE; + break; +@@ -854,7 +854,7 @@ bool login_proxy_failed(struct login_pro + return TRUE; + } + +- if (type != LOGIN_PROXY_FAILURE_TYPE_AUTH && ++ if (type != LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED && + type != LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL) + e_error(event, "%s%s", log_prefix, reason); + else if (proxy->client->set->auth_verbose) +Index: trixie/src/login-common/login-proxy.h +=================================================================== +--- trixie.orig/src/login-common/login-proxy.h ++++ trixie/src/login-common/login-proxy.h +@@ -34,7 +34,7 @@ enum login_proxy_failure_type { + LOGIN_PROXY_FAILURE_TYPE_PROTOCOL, + /* Authentication failed to backend. The LOGIN/AUTH command reply was + already sent to the client. */ +- LOGIN_PROXY_FAILURE_TYPE_AUTH, ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED, + /* Authentication failed with a temporary failure code. Attempting it + again might work. */ + LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL, +Index: trixie/src/pop3-login/pop3-proxy.c +=================================================================== +--- trixie.orig/src/pop3-login/pop3-proxy.c ++++ trixie/src/pop3-login/pop3-proxy.c +@@ -322,7 +322,7 @@ int pop3_proxy_parse_line(struct client + shouldn't be a real problem since of course everyone will + be using only Dovecot as their backend :) */ + enum login_proxy_failure_type failure_type = +- LOGIN_PROXY_FAILURE_TYPE_AUTH; ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED; + if (!str_begins_with(line, "-ERR ")) { + client_send_reply(client, POP3_CMD_REPLY_ERROR, + AUTH_FAILED_MSG); +@@ -373,7 +373,7 @@ pop3_proxy_send_failure_reply(struct cli + /* [SYS/TEMP] prefix is already in the reason string */ + client_send_reply(client, POP3_CMD_REPLY_ERROR, reason); + break; +- case LOGIN_PROXY_FAILURE_TYPE_AUTH: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: + /* reply was already sent */ + break; + } +Index: trixie/src/submission-login/submission-proxy.c +=================================================================== +--- trixie.orig/src/submission-login/submission-proxy.c ++++ trixie/src/submission-login/submission-proxy.c +@@ -693,7 +693,7 @@ int submission_proxy_parse_line(struct c + shouldn't be a real problem since of course everyone will + be using only Dovecot as their backend :) */ + enum login_proxy_failure_type failure_type = +- LOGIN_PROXY_FAILURE_TYPE_AUTH; ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED; + if ((status / 100) == 4) + failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL; + else if (!submission_proxy_handle_redirect( +@@ -749,7 +749,7 @@ submission_proxy_send_failure_reply(stru + i_assert(subm_client->proxy_reply != NULL); + smtp_server_reply_submit(subm_client->proxy_reply); + break; +- case LOGIN_PROXY_FAILURE_TYPE_AUTH: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: + /* reply was already sent */ + i_assert(cmd == NULL); + break; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-dsync-Avoid-potentially-excessive-data-stack-growth-.patch dovecot-2.4.1+dfsg1/debian/patches/0001-dsync-Avoid-potentially-excessive-data-stack-growth-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-dsync-Avoid-potentially-excessive-data-stack-growth-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-dsync-Avoid-potentially-excessive-data-stack-growth-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,40 @@ +From b40043ec2e45b79e9cb185009cffadfb1448da7e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 24 Mar 2026 12:02:00 +0200 +Subject: [PATCH 1/2] dsync: Avoid potentially excessive data stack growth for + mailbox attribute sending + +The attribute value can be large. +--- + src/doveadm/dsync/dsync-ibc-stream.c | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/src/doveadm/dsync/dsync-ibc-stream.c b/src/doveadm/dsync/dsync-ibc-stream.c +index 472d1fc517..0b3038201d 100644 +--- a/src/doveadm/dsync/dsync-ibc-stream.c ++++ b/src/doveadm/dsync/dsync-ibc-stream.c +@@ -1492,12 +1492,12 @@ dsync_ibc_stream_send_mailbox_attribute(struct dsync_ibc *_ibc, + { + struct dsync_ibc_stream *ibc = (struct dsync_ibc_stream *)_ibc; + struct dsync_serializer_encoder *encoder; +- string_t *str = t_str_new(128); + char type[2]; + + if (ibc->minor_version < DSYNC_PROTOCOL_MINOR_HAVE_ATTRIBUTES) + return; + ++ string_t *str = str_new(default_pool, 128); + str_append_c(str, items[ITEM_MAILBOX_ATTRIBUTE].chr); + encoder = dsync_ibc_send_encode_begin(ibc, ITEM_MAILBOX_ATTRIBUTE); + +@@ -1538,6 +1538,7 @@ dsync_ibc_stream_send_mailbox_attribute(struct dsync_ibc *_ibc, + i_stream_ref(ibc->value_output); + (void)dsync_ibc_stream_send_value_stream(ibc); + } ++ str_free(&str); + } + + static enum dsync_ibc_recv_ret +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-global-Fix-spelling.patch dovecot-2.4.1+dfsg1/debian/patches/0001-global-Fix-spelling.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-global-Fix-spelling.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-global-Fix-spelling.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,34 @@ +Index: trixie/src/lib-compression/istream-zlib.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-zlib.c ++++ trixie/src/lib-compression/istream-zlib.c +@@ -32,7 +32,7 @@ struct zlib_istream { + bool header_read:1; + bool trailer_read:1; + bool zs_closed:1; +- bool starting_concated_output:1; ++ bool starting_concatenated_output:1; + }; + + static void i_stream_zlib_init(struct zlib_istream *zstream); +@@ -193,9 +193,9 @@ static ssize_t i_stream_zlib_read(struct + return -1; + } + /* Multiple gz streams concatenated together */ +- zstream->starting_concated_output = TRUE; ++ zstream->starting_concatenated_output = TRUE; + } +- if (zstream->starting_concated_output) { ++ if (zstream->starting_concatenated_output) { + /* make sure there actually is something in parent stream. + we don't want to reset the stream unless we actually see + some concatenated output. */ +@@ -217,7 +217,7 @@ static ssize_t i_stream_zlib_read(struct + zstream->header_read = FALSE; + zstream->trailer_read = FALSE; + zstream->crc32 = 0; +- zstream->starting_concated_output = FALSE; ++ zstream->starting_concatenated_output = FALSE; + + (void)inflateEnd(&zstream->zs); + i_stream_zlib_init(zstream); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Extract-side-channel-ostream-creation-into-help.patch dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Extract-side-channel-ostream-creation-into-help.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Extract-side-channel-ostream-creation-into-help.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Extract-side-channel-ostream-creation-into-help.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,82 @@ +From da6075cd4803a36ae06ae0401184aeed128e4320 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 1 May 2026 17:40:08 +0000 +Subject: [PATCH 1/5] imap: Extract side-channel ostream creation into helper + +Add client_create_side_channel_output() and use it in cmd_compress() +in place of the inline channel-creation code. The helper will also be +used by the imap state import path to recreate the side channel after +unhibernation. No functional change. +--- + src/imap/cmd-compress.c | 10 ++-------- + src/imap/imap-client.c | 9 +++++++++ + src/imap/imap-client.h | 5 +++++ + 3 files changed, 16 insertions(+), 8 deletions(-) + +diff --git a/src/imap/cmd-compress.c b/src/imap/cmd-compress.c +index 363d050f47..18aa988892 100644 +--- a/src/imap/cmd-compress.c ++++ b/src/imap/cmd-compress.c +@@ -4,7 +4,6 @@ + #include "imap-commands.h" + #include "istream.h" + #include "ostream.h" +-#include "ostream-multiplex.h" + #include "iostream-rawlog.h" + #include "str.h" + #include "strescape.h" +@@ -92,13 +91,8 @@ bool cmd_compress(struct client_command_context *cmd) + /* Let imap-login process handle the COMPRESS. It's the one + that will send the tagged reply to the client. */ + client->compress_handler = handler; +- if (client->side_channel_output == NULL) { +- client->side_channel_output = +- o_stream_multiplex_add_channel( +- client->multiplex_output, 1); +- o_stream_set_no_error_handling( +- client->side_channel_output, TRUE); +- } ++ if (client->side_channel_output == NULL) ++ client_create_side_channel_output(client); + string_t *str = t_str_new(64); + str_append(str, "compress\t"); + str_append_tabescaped(str, handler->name); +diff --git a/src/imap/imap-client.c b/src/imap/imap-client.c +index b65a8c9fdf..4c8115f9a0 100644 +--- a/src/imap/imap-client.c ++++ b/src/imap/imap-client.c +@@ -678,6 +678,15 @@ void client_send_tagline(struct client_command_context *cmd, const char *data) + cmd->client->v.send_tagline(cmd, data); + } + ++void client_create_side_channel_output(struct client *client) ++{ ++ i_assert(client->multiplex_output != NULL); ++ i_assert(client->side_channel_output == NULL); ++ client->side_channel_output = ++ o_stream_multiplex_add_channel(client->multiplex_output, 1); ++ o_stream_set_no_error_handling(client->side_channel_output, TRUE); ++} ++ + static void + client_default_send_tagline(struct client_command_context *cmd, const char *data) + { +diff --git a/src/imap/imap-client.h b/src/imap/imap-client.h +index fd1153d97f..a4f2e4f3fb 100644 +--- a/src/imap/imap-client.h ++++ b/src/imap/imap-client.h +@@ -287,6 +287,11 @@ void client_add_istream_prefix(struct client *client, + const unsigned char *data, size_t size); + void client_destroy(struct client *client, const char *reason) ATTR_NULL(2); + ++/* Add the side-channel ostream used to send commands (e.g. dict_reset) back ++ to the imap-login proxy. Must only be called when multiplex_output is set ++ and the channel hasn't been created yet. */ ++void client_create_side_channel_output(struct client *client); ++ + /* Disconnect client connection */ + void client_disconnect(struct client *client, const char *reason); + void client_disconnect_with_error(struct client *client, +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Restrict-COMPRESS-to-DEFLATE-only.patch dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Restrict-COMPRESS-to-DEFLATE-only.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Restrict-COMPRESS-to-DEFLATE-only.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-imap-Restrict-COMPRESS-to-DEFLATE-only.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,67 @@ +From 18f076b165d480cfdf20d81b5683ce7929f02eb4 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sat, 4 Jul 2026 21:57:15 +0000 +Subject: [PATCH 1/3] imap: Restrict COMPRESS to DEFLATE only + +cmd_compress() and the imap-login proxy side channel looked up any +registered compression handler by name, so a client could request e.g. +COMPRESS ZSTD even though only COMPRESS=DEFLATE is advertised (RFC 4978). + +Non-DEFLATE algorithms can require far more memory to decompress (a zstd +decoder alone can hold a 16+ MB window per connection versus ~40 KB for +inflate). This matters now that imap-login handles COMPRESS for proxied +connections: an attacker opening many connections could drive the shared +login process into its vsz_limit and crash it, affecting all clients on +that process. + +Only accept DEFLATE on both the backend and the login proxy path. +--- + src/imap-login/imap-proxy.c | 11 ++++++----- + src/imap/cmd-compress.c | 8 ++++++-- + 2 files changed, 12 insertions(+), 7 deletions(-) + +Index: trixie/src/imap-login/imap-proxy.c +=================================================================== +--- trixie.orig/src/imap-login/imap-proxy.c ++++ trixie/src/imap-login/imap-proxy.c +@@ -606,13 +606,14 @@ proxy_side_cmd_compress(struct client *c + login_proxy_get_client_ostream(client->login_proxy); + const struct compression_handler *handler; + int ret = compression_lookup_handler(t_str_lcase(args[0]), &handler); +- if (ret <= 0) { +- /* IMAP backend normally checks this already. If we get here, +- there is a mismatch between what algorithms proxy and +- backend supports. */ ++ /* Only DEFLATE is permitted. The IMAP backend restricts this already; ++ enforce it here too, since imap-login handles COMPRESS for proxied ++ connections and non-DEFLATE algorithms can be far more memory-heavy ++ to decompress. */ ++ if (ret <= 0 || strcmp(handler->name, "deflate") != 0) { + o_stream_nsend_str(client_output, t_strdup_printf( + "%s NO %s compression mechanism\r\n", args[1], +- ret == 0 ? "Unsupported" : "Unknown")); ++ ret < 0 ? "Unknown" : "Unsupported")); + return 0; + } + +Index: trixie/src/imap/cmd-compress.c +=================================================================== +--- trixie.orig/src/imap/cmd-compress.c ++++ trixie/src/imap/cmd-compress.c +@@ -67,10 +67,14 @@ bool cmd_compress(struct client_command_ + return TRUE; + } + int ret = compression_lookup_handler(t_str_lcase(mechanism), &handler); +- if (ret <= 0) { ++ /* Only DEFLATE is advertised (RFC 4978) and accepted here. Other ++ algorithms (e.g. zstd) can require far more memory to decompress, ++ which especially matters now that imap-login can handle COMPRESS for ++ proxied connections and an attacker could open many of them. */ ++ if (ret <= 0 || strcmp(handler->name, "deflate") != 0) { + const char * tagline = + t_strdup_printf("NO %s compression mechanism", +- ret == 0 ? "Unsupported" : "Unknown"); ++ ret < 0 ? "Unknown" : "Unsupported"); + client_send_tagline(cmd, tagline); + return TRUE; + } diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-imap-hibernate-Fix-out-of-bounds-read-when-parsing-D.patch dovecot-2.4.1+dfsg1/debian/patches/0001-imap-hibernate-Fix-out-of-bounds-read-when-parsing-D.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-imap-hibernate-Fix-out-of-bounds-read-when-parsing-D.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-imap-hibernate-Fix-out-of-bounds-read-when-parsing-D.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,35 @@ +From b4da247c728ecddc125c2d4021b9b7f70ff1f549 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 23:12:13 +0200 +Subject: [PATCH 1/2] imap-hibernate: Fix out-of-bounds read when parsing DONE + command tag + +The tag-skipping loop was missing a size>0 guard, so a malformed +DONE command with no space/CR/tab terminator after the tag would +read one byte past the end of the buffer. Also add a \0 check to +stop on embedded null bytes, which are not valid tag characters. +--- + src/imap-hibernate/imap-client.c | 6 ++++-- + 1 file changed, 4 insertions(+), 2 deletions(-) + +diff --git a/src/imap-hibernate/imap-client.c b/src/imap-hibernate/imap-client.c +index 5c11dd1dde..2110fe8e8e 100644 +--- a/src/imap-hibernate/imap-client.c ++++ b/src/imap-hibernate/imap-client.c +@@ -362,9 +362,11 @@ imap_client_input_parse(const unsigned char *data, size_t size, const char **tag + tag_start = data; + + /* skip over tag */ +- while(data[0] != ' ' && ++ while(size > 0 && ++ data[0] != ' ' && + data[0] != '\r' && +- data[0] != '\t' ) { data++; size--; } ++ data[0] != '\t' && ++ data[0] != '\0') { data++; size--; } + + tag_end = data; + +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Add-comments-to-internal-ID-command-param.patch dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Add-comments-to-internal-ID-command-param.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Add-comments-to-internal-ID-command-param.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Add-comments-to-internal-ID-command-param.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,33 @@ +From 277e5c880a7f6a7741e717036570812910fff7b7 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 2 Feb 2026 11:44:58 +0200 +Subject: [PATCH] imap-login: Add comments to internal ID command parameter + handling + +--- + src/imap-login/imap-login-cmd-id.c | 9 +++++++-- + 1 file changed, 7 insertions(+), 2 deletions(-) + +diff --git a/src/imap-login/imap-login-cmd-id.c b/src/imap-login/imap-login-cmd-id.c +index 1cd30bd1bc..e51e9c1e72 100644 +--- a/src/imap-login/imap-login-cmd-id.c ++++ b/src/imap-login/imap-login-cmd-id.c +@@ -183,8 +183,13 @@ static bool cmd_id_handle_keyvalue(struct imap_client *client, + imap_id_param_handler_find(key); + bool is_login_id_param = handler != NULL; + +- if (is_login_id_param && client->common.connection_trusted && +- !client->id_logged && value != NULL) { ++ if (!is_login_id_param) { ++ /* not an internal key */ ++ } else if (client->id_logged) { ++ /* using ID multiple times - ignore */ ++ } else if (value == NULL) { ++ /* there should have been a value - ignore */ ++ } else if (client->common.connection_trusted) { + if (!handler->callback(client->cmd_id->params, key, value)) { + e_debug(client->common.event, + "Client sent invalid ID parameter '%s'", key); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Fix-excessive-memory-growth-with-pre-logi.patch dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Fix-excessive-memory-growth-with-pre-logi.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Fix-excessive-memory-growth-with-pre-logi.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-imap-login-Fix-excessive-memory-growth-with-pre-logi.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,86 @@ +From 4f3471057a754d93af368527e08af3db3d3e82c9 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sat, 4 Jul 2026 16:04:04 +0000 +Subject: [PATCH] imap-login: Fix excessive memory growth with pre-login ID + command + +The pre-login ID parser reset the per-argument line and list limits after +every argument, so nothing bounded the number of key/value pairs. Each +external key grew log_reply and added a distinct event field (a linear +scan, making it O(N^2)), allowing a pre-auth CPU/memory amplification. + +Account at most 30 pairs (per RFC 2971) into the logging and client_id +bookkeeping. Internal x-* parameter handlers still run for pairs beyond +the limit, since a trusted proxy may legitimately forward more fields. +--- + src/imap-login/imap-login-client.h | 4 ++++ + src/imap-login/imap-login-cmd-id.c | 20 ++++++++++++++++++-- + 2 files changed, 22 insertions(+), 2 deletions(-) + +Index: trixie/src/imap-login/imap-login-client.h +=================================================================== +--- trixie.orig/src/imap-login/imap-login-client.h ++++ trixie/src/imap-login/imap-login-client.h +@@ -53,6 +53,10 @@ struct imap_client_cmd_id { + struct event *params_event; + struct imap_id_params *params; + string_t *log_reply; ++ /* Number of external (non internal x-*) key/value pairs accounted so ++ far. Used to bound the pre-login accounting (logging, event fields, ++ client_id) done per external pair. */ ++ unsigned int processed_pairs_count; + }; + + struct imap_client { +Index: trixie/src/imap-login/imap-login-cmd-id.c +=================================================================== +--- trixie.orig/src/imap-login/imap-login-cmd-id.c ++++ trixie/src/imap-login/imap-login-cmd-id.c +@@ -9,6 +9,12 @@ + #include "imap-login-settings.h" + #include "imap-login-client.h" + ++/* RFC 2971 says a client SHOULD NOT send more than 30 field-value pairs. ++ Allow that many to be accounted for (logged / kept in client_id); pairs ++ beyond this are still parsed and internal x-* handlers still run, but they ++ are not added to the logging/client_id accounting that grows per pair. */ ++#define IMAP_ID_MAX_ACCOUNTED_PAIRS 30 ++ + struct imap_id_params_forward { + const char *key; + const char *value; +@@ -183,6 +189,16 @@ static bool cmd_id_handle_keyvalue(struc + imap_id_param_handler_find(key); + bool is_login_id_param = handler != NULL; + ++ /* Only external pairs are logged / kept in client_id, and only those ++ grow per-pair with count, so only they need bounding to avoid a ++ pre-login CPU/memory DoS. Internal x-* handlers must keep running ++ regardless (e.g. a proxy may legitimately forward more than 30 ++ fields) and are not counted against the limit. */ ++ bool account_pair = !is_login_id_param && ++ client->cmd_id->processed_pairs_count < IMAP_ID_MAX_ACCOUNTED_PAIRS; ++ if (account_pair) ++ client->cmd_id->processed_pairs_count++; ++ + if (!is_login_id_param) { + /* not an internal key */ + } else if (client->id_logged) { +@@ -197,7 +213,7 @@ static bool cmd_id_handle_keyvalue(struc + } + } + +- if (client->set->imap_id_retain && !is_login_id_param && ++ if (account_pair && client->set->imap_id_retain && + (client->common.client_id == NULL || + str_len(client->common.client_id) + kvlen < LOGIN_MAX_CLIENT_ID_LEN)) { + if (client->common.client_id == NULL) { +@@ -213,7 +229,7 @@ static bool cmd_id_handle_keyvalue(struc + imap_append_quoted(client->common.client_id, value); + } + +- if (!is_login_id_param) ++ if (account_pair) + imap_id_add_log_entry(log_entry, key, value); + return TRUE; + } diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-XXH64-hash-implementation.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-XXH64-hash-implementation.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-XXH64-hash-implementation.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-XXH64-hash-implementation.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,331 @@ +From 972acc9f9e73a8bc5b029991fb3752d9d16ef632 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 13 Apr 2026 12:30:03 +0200 +Subject: [PATCH 01/12] lib: Add XXH64 hash implementation + +Adds a streaming XXH64 (non-cryptographic) hash with init/loop/result +API, a one-shot xxh64_data(), and an xxh64_to_32() XOR-fold inline. +Registered in hash_methods[] for use via the generic hash_method API. +--- + src/lib/Makefile.am | 2 + + src/lib/hash-method.c | 2 + + src/lib/test-hash-method.c | 33 +++++++ + src/lib/xxh64.c | 184 +++++++++++++++++++++++++++++++++++++ + src/lib/xxh64.h | 30 ++++++ + 5 files changed, 251 insertions(+) + create mode 100644 src/lib/xxh64.c + create mode 100644 src/lib/xxh64.h + +diff --git a/src/lib/Makefile.am b/src/lib/Makefile.am +index 6da8a308e5..eb9b9ddda3 100644 +--- a/src/lib/Makefile.am ++++ b/src/lib/Makefile.am +@@ -177,6 +177,7 @@ liblib_la_SOURCES = \ + sha1.c \ + sha2.c \ + sha3.c \ ++ xxh64.c \ + sleep.c \ + sort.c \ + stats-dist.c \ +@@ -337,6 +338,7 @@ headers = \ + sha1.h \ + sha2.h \ + sha3.h \ ++ xxh64.h \ + sleep.h \ + sort.h \ + stats-dist.h \ +diff --git a/src/lib/hash-method.c b/src/lib/hash-method.c +index d7a6bed0ea..bdeefbadc6 100644 +--- a/src/lib/hash-method.c ++++ b/src/lib/hash-method.c +@@ -6,6 +6,7 @@ + #include "sha1.h" + #include "sha2.h" + #include "sha3.h" ++#include "xxh64.h" + #include "hash-method.h" + + const struct hash_method *hash_method_lookup(const char *name) +@@ -93,6 +94,7 @@ const struct hash_method *hash_methods[] = { + &hash_method_sha512, + &hash_method_sha3_256, + &hash_method_sha3_512, ++ &hash_method_xxh64, + &hash_method_size, + NULL + }; +diff --git a/src/lib/test-hash-method.c b/src/lib/test-hash-method.c +index 6592ec90e6..6384dc99ca 100644 +--- a/src/lib/test-hash-method.c ++++ b/src/lib/test-hash-method.c +@@ -425,6 +425,39 @@ static void test_hash_methods_fips() { + "\x6d\xf6\x54\x5a\x1c\xe8\xba\x00", + 512 / 8, + }, ++ /* xxh64 reference vectors (seed=0) */ ++ { "xxh64", ++ "", ++ 0, ++ 1, ++ "\xef\x46\xdb\x37\x51\xd8\xe9\x99", ++ 64 / 8 ++ }, ++ { "xxh64", ++ "abc", ++ 3, ++ 1, ++ "\x44\xbc\x2c\xf5\xad\x77\x09\x99", ++ 64 / 8 ++ }, ++ /* 62 bytes: exercises the >= 32-byte accumulator path */ ++ { "xxh64", ++ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" ++ "ghijklmnopqrstuvwxyz0123456789", ++ 62, ++ 1, ++ "\xba\x76\x1a\x9a\xa8\x0e\x0d\x36", ++ 64 / 8 ++ }, ++ /* 16 bytes × 3 rounds: exercises chunked input across the 32-byte block boundary */ ++ { "xxh64", ++ "\x00\x01\x02\x03\x04\x05\x06\x07" ++ "\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f", ++ 16, ++ 3, ++ "\x32\x0d\xcd\xab\xd8\x29\x7a\x9e", ++ 64 / 8 ++ }, + }; + + for(size_t i = 0; i < N_ELEMENTS(test_vectors); i++) { +diff --git a/src/lib/xxh64.c b/src/lib/xxh64.c +new file mode 100644 +index 0000000000..1c17833afd +--- /dev/null ++++ b/src/lib/xxh64.c +@@ -0,0 +1,184 @@ ++/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */ ++ ++/* XXHash64 - Fast non-cryptographic hash function. ++ * Based on Yann Collet's public domain XXHash64 algorithm. ++ * Reference: https://github.com/Cyan4973/xxHash/blob/dev/doc/xxhash_spec.md ++ */ ++ ++#include "lib.h" ++#include "xxh64.h" ++ ++#define XXH64_PRIME1 UINT64_C(0x9E3779B185EBCA87) ++#define XXH64_PRIME2 UINT64_C(0xC2B2AE3D27D4EB4F) ++#define XXH64_PRIME3 UINT64_C(0x165667B19E3779F9) ++#define XXH64_PRIME4 UINT64_C(0x85EBCA77C2B27589) ++#define XXH64_PRIME5 UINT64_C(0x27D4EB2F165667C5) ++ ++#define XXH64_ROTL(x, r) (((x) << (r)) | ((x) >> (64 - (r)))) ++ ++static inline uint64_t xxh64_read64(const void *p) ++{ ++ uint64_t v; ++ memcpy(&v, p, sizeof(v)); ++ return v; ++} ++ ++static inline uint32_t xxh64_read32(const void *p) ++{ ++ uint32_t v; ++ memcpy(&v, p, sizeof(v)); ++ return v; ++} ++ ++static uint64_t ATTR_UNSIGNED_WRAPS xxh64_round(uint64_t acc, uint64_t input) ++{ ++ acc += input * XXH64_PRIME2; ++ acc = XXH64_ROTL(acc, 31); ++ acc *= XXH64_PRIME1; ++ return acc; ++} ++ ++static uint64_t ATTR_UNSIGNED_WRAPS xxh64_merge_acc(uint64_t h64, uint64_t acc) ++{ ++ acc = xxh64_round(0, acc); ++ h64 ^= acc; ++ h64 = h64 * XXH64_PRIME1 + XXH64_PRIME4; ++ return h64; ++} ++ ++void ATTR_UNSIGNED_WRAPS xxh64_init(struct xxh64_context *ctx, uint64_t seed) ++{ ++ ctx->seed = seed; ++ ctx->v1 = seed + XXH64_PRIME1 + XXH64_PRIME2; ++ ctx->v2 = seed + XXH64_PRIME2; ++ ctx->v3 = seed; ++ ctx->v4 = seed - XXH64_PRIME1; ++ ctx->total_len = 0; ++ ctx->buf_used = 0; ++} ++ ++void ATTR_UNSIGNED_WRAPS xxh64_loop(struct xxh64_context *ctx, const void *data, size_t size) ++{ ++ const unsigned char *p = data; ++ const unsigned char *end = p + size; ++ ++ ctx->total_len += size; ++ ++ if (ctx->buf_used + size < 32) { ++ memcpy(ctx->buf + ctx->buf_used, p, size); ++ ctx->buf_used += size; ++ return; ++ } ++ ++ if (ctx->buf_used > 0) { ++ size_t fill = 32 - ctx->buf_used; ++ memcpy(ctx->buf + ctx->buf_used, p, fill); ++ p += fill; ++ ctx->v1 = xxh64_round(ctx->v1, xxh64_read64(ctx->buf)); ++ ctx->v2 = xxh64_round(ctx->v2, xxh64_read64(ctx->buf + 8)); ++ ctx->v3 = xxh64_round(ctx->v3, xxh64_read64(ctx->buf + 16)); ++ ctx->v4 = xxh64_round(ctx->v4, xxh64_read64(ctx->buf + 24)); ++ ctx->buf_used = 0; ++ } ++ ++ while (p + 32 <= end) { ++ ctx->v1 = xxh64_round(ctx->v1, xxh64_read64(p)); ++ ctx->v2 = xxh64_round(ctx->v2, xxh64_read64(p + 8)); ++ ctx->v3 = xxh64_round(ctx->v3, xxh64_read64(p + 16)); ++ ctx->v4 = xxh64_round(ctx->v4, xxh64_read64(p + 24)); ++ p += 32; ++ } ++ ++ if (p < end) { ++ ctx->buf_used = (unsigned int)(end - p); ++ memcpy(ctx->buf, p, ctx->buf_used); ++ } ++} ++ ++uint64_t ATTR_UNSIGNED_WRAPS xxh64_result(struct xxh64_context *ctx) ++{ ++ const unsigned char *p = ctx->buf; ++ const unsigned char *end = p + ctx->buf_used; ++ uint64_t h64; ++ ++ if (ctx->total_len >= 32) { ++ h64 = XXH64_ROTL(ctx->v1, 1) + XXH64_ROTL(ctx->v2, 7) + ++ XXH64_ROTL(ctx->v3, 12) + XXH64_ROTL(ctx->v4, 18); ++ h64 = xxh64_merge_acc(h64, ctx->v1); ++ h64 = xxh64_merge_acc(h64, ctx->v2); ++ h64 = xxh64_merge_acc(h64, ctx->v3); ++ h64 = xxh64_merge_acc(h64, ctx->v4); ++ } else { ++ h64 = ctx->seed + XXH64_PRIME5; ++ } ++ ++ h64 += ctx->total_len; ++ ++ while (p + 8 <= end) { ++ h64 ^= xxh64_round(0, xxh64_read64(p)); ++ h64 = XXH64_ROTL(h64, 27) * XXH64_PRIME1 + XXH64_PRIME4; ++ p += 8; ++ } ++ if (p + 4 <= end) { ++ h64 ^= (uint64_t)xxh64_read32(p) * XXH64_PRIME1; ++ h64 = XXH64_ROTL(h64, 23) * XXH64_PRIME2 + XXH64_PRIME3; ++ p += 4; ++ } ++ while (p < end) { ++ h64 ^= (uint64_t)*p * XXH64_PRIME5; ++ h64 = XXH64_ROTL(h64, 11) * XXH64_PRIME1; ++ p++; ++ } ++ ++ /* Avalanche */ ++ h64 ^= h64 >> 33; ++ h64 *= XXH64_PRIME2; ++ h64 ^= h64 >> 29; ++ h64 *= XXH64_PRIME3; ++ h64 ^= h64 >> 32; ++ ++ return h64; ++} ++ ++uint64_t xxh64_data(const void *data, size_t size, uint64_t seed) ++{ ++ struct xxh64_context ctx; ++ ++ xxh64_init(&ctx, seed); ++ xxh64_loop(&ctx, data, size); ++ return xxh64_result(&ctx); ++} ++ ++static void xxh64_hash_init(void *context) ++{ ++ xxh64_init(context, 0); ++} ++ ++static void xxh64_hash_loop(void *context, const void *data, size_t size) ++{ ++ xxh64_loop(context, data, size); ++} ++ ++static void xxh64_hash_result(void *context, unsigned char *digest_r) ++{ ++ uint64_t hash = xxh64_result(context); ++ ++ digest_r[0] = (hash >> 56) & 0xff; ++ digest_r[1] = (hash >> 48) & 0xff; ++ digest_r[2] = (hash >> 40) & 0xff; ++ digest_r[3] = (hash >> 32) & 0xff; ++ digest_r[4] = (hash >> 24) & 0xff; ++ digest_r[5] = (hash >> 16) & 0xff; ++ digest_r[6] = (hash >> 8) & 0xff; ++ digest_r[7] = (hash >> 0) & 0xff; ++} ++ ++const struct hash_method hash_method_xxh64 = { ++ .name = "xxh64", ++ .block_size = 32, ++ .context_size = sizeof(struct xxh64_context), ++ .digest_size = XXH64_RESULTLEN, ++ .init = xxh64_hash_init, ++ .loop = xxh64_hash_loop, ++ .result = xxh64_hash_result, ++}; +diff --git a/src/lib/xxh64.h b/src/lib/xxh64.h +new file mode 100644 +index 0000000000..7beb4cafb4 +--- /dev/null ++++ b/src/lib/xxh64.h +@@ -0,0 +1,30 @@ ++#ifndef XXH64_H ++#define XXH64_H ++ ++#include "hash-method.h" ++ ++#define XXH64_RESULTLEN 8 ++ ++struct xxh64_context { ++ uint64_t seed; ++ uint64_t v1, v2, v3, v4; ++ uint64_t total_len; ++ unsigned char buf[32]; ++ unsigned int buf_used; ++}; ++ ++void xxh64_init(struct xxh64_context *ctx, uint64_t seed); ++void xxh64_loop(struct xxh64_context *ctx, const void *data, size_t size); ++uint64_t xxh64_result(struct xxh64_context *ctx); ++ ++uint64_t xxh64_data(const void *data, size_t size, uint64_t seed) ATTR_PURE; ++ ++/* XOR-fold the 64-bit hash to 32 bits */ ++static inline uint32_t xxh64_to_32(uint64_t hash) ++{ ++ return (uint32_t)(hash ^ (hash >> 32)); ++} ++ ++extern const struct hash_method hash_method_xxh64; ++ ++#endif +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-str_equals_timing_safe.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-str_equals_timing_safe.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-str_equals_timing_safe.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-Add-str_equals_timing_safe.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,124 @@ +From 09237b986e2a5710a562a9750a6df1aef6d28e69 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 28 Apr 2026 01:50:56 +0300 +Subject: [PATCH 1/2] lib: Add str_equals_timing_safe() + +Constant-time string comparison that avoids the length leak in +str_equals_timing_almost_safe(). Compares HMAC-SHA256 digests of the +inputs (keyed with hash_iv) rather than the strings themselves, so +neither the contents nor the length of either input affects timing in +a way an attacker can exploit. +--- + src/lib/strfuncs.c | 29 +++++++++++++++++++++++++++++ + src/lib/strfuncs.h | 5 +++++ + src/lib/test-strfuncs.c | 27 +++++++++++++++++++++++++++ + 3 files changed, 61 insertions(+) + +Index: dovecot/src/lib/strfuncs.c +=================================================================== +--- dovecot.orig/src/lib/strfuncs.c ++++ dovecot/src/lib/strfuncs.c +@@ -7,6 +7,9 @@ + #include "printf-format-fix.h" + #include "strfuncs.h" + #include "array.h" ++#include "hash.h" ++#include "hmac.h" ++#include "sha2.h" + + #include + #include +@@ -627,6 +630,32 @@ bool str_equals_timing_almost_safe(const + return ret == 0; + } + ++bool str_equals_hash_timing_safe(const char *s1, const char *s2) ++{ ++ struct hmac_context ctx; ++ unsigned char digest1[SHA256_RESULTLEN]; ++ unsigned char digest2[SHA256_RESULTLEN]; ++ ++ /* Compare HMAC-SHA256 digests of the inputs rather than the inputs ++ themselves. The digest length is constant, so the subsequent ++ mem_equals_timing_safe() leaks no length information. The HMAC ++ times depend on the input lengths, but each side's length is either ++ a deployment constant (for the secret) or already known to the ++ attacker (for their own input), so neither leaks a useful signal. ++ hash_iv keys the HMAC to prevent precomputation. */ ++ hmac_init(&ctx, (const unsigned char *)&hash_iv, sizeof(hash_iv), ++ &hash_method_sha256); ++ hmac_update(&ctx, s1, strlen(s1)); ++ hmac_final(&ctx, digest1); ++ ++ hmac_init(&ctx, (const unsigned char *)&hash_iv, sizeof(hash_iv), ++ &hash_method_sha256); ++ hmac_update(&ctx, s2, strlen(s2)); ++ hmac_final(&ctx, digest2); ++ ++ return mem_equals_timing_safe(digest1, digest2, sizeof(digest1)); ++} ++ + size_t + str_match(const char *p1, const char *p2) + { +Index: dovecot/src/lib/strfuncs.h +=================================================================== +--- dovecot.orig/src/lib/strfuncs.h ++++ dovecot/src/lib/strfuncs.h +@@ -93,6 +93,11 @@ bool mem_equals_timing_safe(const void * + the string lengths are the same. If not, the length of the secret string may + be leaked, but otherwise the contents won't be. */ + bool str_equals_timing_almost_safe(const char *s1, const char *s2); ++/* Returns TRUE if the two strings are equal. Safe against timing attacks: ++ neither the contents nor the length of either string is leaked. ++ Implemented by HMAC-SHA256ing both inputs under a random per-process key ++ and comparing the fixed-length digests. */ ++bool str_equals_hash_timing_safe(const char *s1, const char *s2); + + size_t str_match(const char *p1, const char *p2) ATTR_PURE; + size_t str_match_icase(const char *p1, const char *p2) ATTR_PURE; +Index: dovecot/src/lib/test-strfuncs.c +=================================================================== +--- dovecot.orig/src/lib/test-strfuncs.c ++++ dovecot/src/lib/test-strfuncs.c +@@ -478,6 +478,32 @@ static void test_str_equals_timing_almos + test_end(); + } + ++static void test_str_equals_hash_timing_safe(void) ++{ ++ const struct { ++ const char *a, *b; ++ } tests[] = { ++ { "", "" }, ++ { "a", "a" }, ++ { "b", "a" }, ++ { "ab", "ab" }, ++ { "ab", "ba" }, ++ { "ab", "bc" }, ++ { "a", "" }, ++ { "a", "ab" }, ++ { "a", "abc" }, ++ { "ab", "abc" }, ++ }; ++ test_begin("str_equals_hash_timing_safe()"); ++ for (unsigned int i = 0; i < N_ELEMENTS(tests); i++) { ++ test_assert((strcmp(tests[i].a, tests[i].b) == 0) == ++ str_equals_hash_timing_safe(tests[i].a, tests[i].b)); ++ test_assert((strcmp(tests[i].a, tests[i].b) == 0) == ++ str_equals_hash_timing_safe(tests[i].b, tests[i].a)); ++ } ++ test_end(); ++} ++ + static void test_dec2str_buf(void) + { + const uintmax_t test_input[] = { +@@ -773,6 +799,7 @@ void test_strfuncs(void) + test_p_array_const_string_join(); + test_mem_equals_timing_safe(); + test_str_equals_timing_almost_safe(); ++ test_str_equals_hash_timing_safe(); + test_dec2str_buf(); + test_str_match(); + test_str_match_icase(); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-Don-t-calculate-crc32-if-using-zlib-.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-Don-t-calculate-crc32-if-using-zlib-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-Don-t-calculate-crc32-if-using-zlib-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-Don-t-calculate-crc32-if-using-zlib-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,45 @@ +From cac140a9e4e12485bde4348b8a8c94a7a96a6e5e Mon Sep 17 00:00:00 2001 +From: Michael M Slusarz +Date: Sat, 4 Oct 2025 12:04:31 -0600 +Subject: [PATCH] lib-compression: Don't calculate crc32 if using zlib in + deflate mode + +--- + src/lib-compression/istream-zlib.c | 8 +++++--- + src/lib-compression/ostream-zlib.c | 4 +++- + 2 files changed, 8 insertions(+), 4 deletions(-) + +Index: trixie/src/lib-compression/istream-zlib.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-zlib.c ++++ trixie/src/lib-compression/istream-zlib.c +@@ -264,9 +264,11 @@ static ssize_t i_stream_zlib_read(struct + ret = inflate(&zstream->zs, Z_SYNC_FLUSH); + + out_size -= zstream->zs.avail_out; +- zstream->crc32 = crc32_data_more(zstream->crc32, +- stream->w_buffer + stream->pos, +- out_size); ++ /* CRC32 is only needed for GZ trailer. */ ++ if (zstream->gz) ++ zstream->crc32 = crc32_data_more(zstream->crc32, ++ stream->w_buffer + stream->pos, ++ out_size); + stream->pos += out_size; + + size_t bytes_consumed = size - zstream->zs.avail_in; +Index: trixie/src/lib-compression/ostream-zlib.c +=================================================================== +--- trixie.orig/src/lib-compression/ostream-zlib.c ++++ trixie/src/lib-compression/ostream-zlib.c +@@ -219,7 +219,9 @@ o_stream_zlib_send_chunk(struct zlib_ost + } + size -= zs->avail_in; + +- zstream->crc = crc32_data_more(zstream->crc, data, size); ++ /* CRC32 is only needed for GZ trailer. */ ++ if (zstream->gz) ++ zstream->crc = crc32_data_more(zstream->crc, data, size); + zstream->bytes32 += size; + zstream->flushed = FALSE; + return size; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Ensure-uncompressed-chun.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Ensure-uncompressed-chun.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Ensure-uncompressed-chun.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Ensure-uncompressed-chun.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,98 @@ +From 86aba25a1159f7ff11480feec287647be78054d1 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 23 Feb 2026 13:52:36 +0200 +Subject: [PATCH] lib-compression: istream-lz4 - Ensure uncompressed chunk size + is not 0 + +--- + src/lib-compression/istream-lz4.c | 5 ++- + src/lib-compression/test-compression.c | 52 ++++++++++++++++++++++++++ + 2 files changed, 55 insertions(+), 2 deletions(-) + +Index: trixie/src/lib-compression/istream-lz4.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-lz4.c ++++ trixie/src/lib-compression/istream-lz4.c +@@ -77,9 +77,10 @@ static int i_stream_lz4_read_header(stru + zstream->max_uncompressed_chunk_size = + be32_to_cpu_unaligned(hdr->max_uncompressed_chunk_size); + buffer_set_used_size(zstream->chunk_buf, 0); +- if (zstream->max_uncompressed_chunk_size > ISTREAM_LZ4_CHUNK_SIZE) { ++ if (zstream->max_uncompressed_chunk_size == 0 || ++ zstream->max_uncompressed_chunk_size > ISTREAM_LZ4_CHUNK_SIZE) { + lz4_read_error(zstream, t_strdup_printf( +- "lz4 max chunk size too large (%u > %u)", ++ "invalid lz4 max chunk size (%u, max %u)", + zstream->max_uncompressed_chunk_size, + ISTREAM_LZ4_CHUNK_SIZE)); + zstream->istream.istream.stream_errno = EINVAL; +Index: trixie/src/lib-compression/test-compression.c +=================================================================== +--- trixie.orig/src/lib-compression/test-compression.c ++++ trixie/src/lib-compression/test-compression.c +@@ -1040,6 +1040,57 @@ static void test_lz4_small_header(void) + test_end(); + } + ++static const unsigned char lz4_chunk_crash_01[] = { ++ 0x44, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x2d, 0x4c, 0x5a, 0x34, 0x0d, ++ 0x2a, 0x9b, 0xc5, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0x7a, ++ 0x32, 0x01, 0xff, 0xff, 0x00, 0x30 ++}; ++ ++static const unsigned char lz4_chunk_crash_02[] = { ++ 0x44, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x2d, 0x4c, 0x5a, 0x34, 0x0d, ++ 0x2a, 0x9b, 0xc5, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x07, 0x7a, ++ 0x32, 0x01, 0xff, 0xff, 0x00, 0x30 ++}; ++ ++static void test_lz4_chunk_size(void) ++{ ++ const struct compression_handler *lz4; ++ struct istream *file_input, *input; ++ ++ if (compression_lookup_handler("lz4", &lz4) <= 0) ++ return; /* not compiled in or unknown */ ++ ++ test_begin("lz4 chunk size"); ++ ++ file_input = test_istream_create_data(lz4_chunk_crash_01, ++ sizeof(lz4_chunk_crash_01)); ++ input = lz4->create_istream(file_input); ++ i_stream_unref(&file_input); ++ i_stream_read(input); ++ ++ test_assert(input->eof); ++ test_assert(input->stream_errno != 0); ++ const char *error = i_stream_get_error(input); ++ test_assert(strstr(error, "invalid lz4 max chunk size") != NULL); ++ ++ i_stream_unref(&input); ++ ++ file_input = test_istream_create_data(lz4_chunk_crash_02, ++ sizeof(lz4_chunk_crash_02)); ++ input = lz4->create_istream(file_input); ++ i_stream_unref(&file_input); ++ i_stream_read(input); ++ ++ test_assert(input->eof); ++ test_assert(input->stream_errno != 0); ++ error = i_stream_get_error(input); ++ test_assert(strstr(error, "invalid lz4 max chunk size") != NULL); ++ ++ i_stream_unref(&input); ++ ++ test_end(); ++} ++ + static void test_uncompress_file(const char *path) + { + const struct compression_handler *handler; +@@ -1163,6 +1214,7 @@ int main(int argc, char *argv[]) + test_gz_header, + test_gz_large_header, + test_lz4_small_header, ++ test_lz4_chunk_size, + test_compression_ext, + test_compression_deinit, + NULL diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Try-again-if-no-data-was.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Try-again-if-no-data-was.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Try-again-if-no-data-was.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-lz4-Try-again-if-no-data-was.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,60 @@ +From f269b3d3051044aec31cef888fcf4c9ddd21c227 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 23 Feb 2026 14:09:02 +0200 +Subject: [PATCH] lib-compression: istream-lz4 - Try again if no data was + decompressed + +--- + src/lib-compression/istream-lz4.c | 3 ++- + src/lib-compression/test-compression.c | 17 +++++++++++++++++ + 2 files changed, 19 insertions(+), 1 deletion(-) + +Index: trixie/src/lib-compression/istream-lz4.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-lz4.c ++++ trixie/src/lib-compression/istream-lz4.c +@@ -198,7 +198,8 @@ static ssize_t i_stream_lz4_read(struct + lz4_read_error(zstream, "corrupted lz4 chunk"); + stream->istream.stream_errno = EINVAL; + return -1; +- } ++ } else if (ret == 0) ++ return i_stream_lz4_read(stream); + i_assert(ret > 0); + stream->pos += ret; + i_assert(stream->pos <= stream->buffer_size); +Index: trixie/src/lib-compression/test-compression.c +=================================================================== +--- trixie.orig/src/lib-compression/test-compression.c ++++ trixie/src/lib-compression/test-compression.c +@@ -1052,6 +1052,11 @@ static const unsigned char lz4_chunk_cra + 0x32, 0x01, 0xff, 0xff, 0x00, 0x30 + }; + ++static const unsigned char lz4_chunk_eof_03[] = { ++ 0x44, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x2d, 0x4c, 0x5a, 0x34, 0x0d, ++ 0x2a, 0x9b, 0xc5, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, ++}; ++ + static void test_lz4_chunk_size(void) + { + const struct compression_handler *lz4; +@@ -1088,6 +1093,18 @@ static void test_lz4_chunk_size(void) + + i_stream_unref(&input); + ++ file_input = test_istream_create_data(lz4_chunk_eof_03, ++ sizeof(lz4_chunk_eof_03)); ++ input = lz4->create_istream(file_input); ++ i_stream_unref(&file_input); ++ i_stream_read(input); ++ ++ /* no error, but no content either */ ++ test_assert(input->eof); ++ test_assert(input->stream_errno == 0); ++ ++ i_stream_unref(&input); ++ + test_end(); + } + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-zlib-Use-container_of-macro.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-zlib-Use-container_of-macro.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-zlib-Use-container_of-macro.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-istream-zlib-Use-container_of-macro.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,56 @@ +From 09df3b5b8b970439ab6b1b617b344a21cb2c07fe Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Tue, 14 Nov 2023 16:57:45 +0100 +Subject: [PATCH] lib-compression: istream-zlib - Use container_of() macro + +--- + src/lib-compression/istream-zlib.c | 12 ++++++++---- + 1 file changed, 8 insertions(+), 4 deletions(-) + +diff --git a/src/lib-compression/istream-zlib.c b/src/lib-compression/istream-zlib.c +index 93b3473071..0a1832a66d 100644 +--- a/src/lib-compression/istream-zlib.c ++++ b/src/lib-compression/istream-zlib.c +@@ -40,7 +40,8 @@ static void i_stream_zlib_init(struct zlib_istream *zstream); + static void i_stream_zlib_close(struct iostream_private *stream, + bool close_parent) + { +- struct zlib_istream *zstream = (struct zlib_istream *)stream; ++ struct zlib_istream *zstream = ++ container_of(stream, struct zlib_istream, istream.iostream); + + if (!zstream->zs_closed) { + (void)inflateEnd(&zstream->zs); +@@ -160,7 +161,8 @@ static int i_stream_zlib_read_trailer(struct zlib_istream *zstream) + + static ssize_t i_stream_zlib_read(struct istream_private *stream) + { +- struct zlib_istream *zstream = (struct zlib_istream *)stream; ++ struct zlib_istream *zstream = ++ container_of(stream, struct zlib_istream, istream); + const unsigned char *data; + uoff_t high_offset; + size_t size, out_size; +@@ -362,7 +364,8 @@ static void i_stream_zlib_reset(struct zlib_istream *zstream) + static void + i_stream_zlib_seek(struct istream_private *stream, uoff_t v_offset, bool mark) + { +- struct zlib_istream *zstream = (struct zlib_istream *) stream; ++ struct zlib_istream *zstream = ++ container_of(stream, struct zlib_istream, istream); + + if (i_stream_nonseekable_try_seek(stream, v_offset)) + return; +@@ -378,7 +381,8 @@ i_stream_zlib_seek(struct istream_private *stream, uoff_t v_offset, bool mark) + + static void i_stream_zlib_sync(struct istream_private *stream) + { +- struct zlib_istream *zstream = (struct zlib_istream *) stream; ++ struct zlib_istream *zstream = ++ container_of(stream, struct zlib_istream, istream); + const struct stat *st; + + if (i_stream_stat(stream->parent, FALSE, &st) == 0) { +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-wrap-_read-in-for-loop-reindent-only.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-wrap-_read-in-for-loop-reindent-only.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-wrap-_read-in-for-loop-reindent-only.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-compression-wrap-_read-in-for-loop-reindent-only.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,611 @@ +From aa8e26f7879895ff55bc247fb2594ffb3c34f4bc Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Wed, 10 Jun 2026 11:53:39 +0000 +Subject: [PATCH 1/4] lib-compression: wrap *_read() in for(;;) loop (reindent + only) + +Prepare for the next commit: wrap the body of i_stream_lz4_read(), +i_stream_bzlib_read(), and i_stream_zlib_read() in a for(;;) loop. +No logic change; all paths still return on the first iteration. +Separating the indent churn makes the actual fix easier to review. +--- + src/lib-compression/istream-bzlib.c | 158 ++++++++--------- + src/lib-compression/istream-lz4.c | 114 ++++++------ + src/lib-compression/istream-zlib.c | 260 ++++++++++++++-------------- + 3 files changed, 269 insertions(+), 263 deletions(-) + +Index: trixie/src/lib-compression/istream-bzlib.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-bzlib.c ++++ trixie/src/lib-compression/istream-bzlib.c +@@ -51,88 +51,90 @@ static ssize_t i_stream_bzlib_read(struc + size_t size, out_size; + int ret; + +- high_offset = stream->istream.v_offset + (stream->pos - stream->skip); +- if (zstream->eof_offset == high_offset) { +- stream->istream.eof = TRUE; +- return -1; +- } +- +- if (!zstream->marked) { +- if (!i_stream_try_alloc(stream, CHUNK_SIZE, &out_size)) +- return -2; /* buffer full */ +- } else { +- /* try to avoid compressing, so we can quickly seek backwards */ +- if (!i_stream_try_alloc_avoid_compress(stream, CHUNK_SIZE, &out_size)) +- return -2; /* buffer full */ +- } ++ for (;;) { ++ high_offset = stream->istream.v_offset + (stream->pos - stream->skip); ++ if (zstream->eof_offset == high_offset) { ++ stream->istream.eof = TRUE; ++ return -1; ++ } + +- if (i_stream_read_more(stream->parent, &data, &size) < 0) { +- if (stream->parent->stream_errno != 0) { +- stream->istream.stream_errno = +- stream->parent->stream_errno; ++ if (!zstream->marked) { ++ if (!i_stream_try_alloc(stream, CHUNK_SIZE, &out_size)) ++ return -2; /* buffer full */ + } else { +- i_assert(stream->parent->eof); +- bzlib_read_error(zstream, "unexpected EOF"); +- if (!zstream->hdr_read) +- stream->istream.stream_errno = EINVAL; +- else +- stream->istream.stream_errno = EPIPE; ++ /* try to avoid compressing, so we can quickly seek backwards */ ++ if (!i_stream_try_alloc_avoid_compress(stream, CHUNK_SIZE, &out_size)) ++ return -2; /* buffer full */ ++ } ++ ++ if (i_stream_read_more(stream->parent, &data, &size) < 0) { ++ if (stream->parent->stream_errno != 0) { ++ stream->istream.stream_errno = ++ stream->parent->stream_errno; ++ } else { ++ i_assert(stream->parent->eof); ++ bzlib_read_error(zstream, "unexpected EOF"); ++ if (!zstream->hdr_read) ++ stream->istream.stream_errno = EINVAL; ++ else ++ stream->istream.stream_errno = EPIPE; ++ } ++ return -1; ++ } ++ if (size == 0) { ++ /* no more input */ ++ i_assert(!stream->istream.blocking); ++ return 0; + } +- return -1; +- } +- if (size == 0) { +- /* no more input */ +- i_assert(!stream->istream.blocking); +- return 0; +- } + +- zstream->zs.next_in = (char *)data; +- zstream->zs.avail_in = size; ++ zstream->zs.next_in = (char *)data; ++ zstream->zs.avail_in = size; + +- zstream->zs.next_out = (char *)stream->w_buffer + stream->pos; +- zstream->zs.avail_out = out_size; +- ret = BZ2_bzDecompress(&zstream->zs); +- zstream->hdr_read = TRUE; +- +- out_size -= zstream->zs.avail_out; +- stream->pos += out_size; +- +- i_stream_skip(stream->parent, size - zstream->zs.avail_in); +- +- switch (ret) { +- case BZ_OK: +- break; +- case BZ_PARAM_ERROR: +- i_unreached(); +- case BZ_DATA_ERROR: +- bzlib_read_error(zstream, "corrupted data"); +- stream->istream.stream_errno = EINVAL; +- return -1; +- case BZ_DATA_ERROR_MAGIC: +- bzlib_read_error(zstream, +- "wrong magic in header (not bz2 file?)"); +- stream->istream.stream_errno = EINVAL; +- return -1; +- case BZ_MEM_ERROR: +- i_fatal_status(FATAL_OUTOFMEM, "bzlib.read(%s): Out of memory", +- i_stream_get_name(&stream->istream)); +- case BZ_STREAM_END: +- zstream->eof_offset = stream->istream.v_offset + +- (stream->pos - stream->skip); +- stream->cached_stream_size = zstream->eof_offset; +- if (out_size == 0) { +- stream->istream.eof = TRUE; ++ zstream->zs.next_out = (char *)stream->w_buffer + stream->pos; ++ zstream->zs.avail_out = out_size; ++ ret = BZ2_bzDecompress(&zstream->zs); ++ zstream->hdr_read = TRUE; ++ ++ out_size -= zstream->zs.avail_out; ++ stream->pos += out_size; ++ ++ i_stream_skip(stream->parent, size - zstream->zs.avail_in); ++ ++ switch (ret) { ++ case BZ_OK: ++ break; ++ case BZ_PARAM_ERROR: ++ i_unreached(); ++ case BZ_DATA_ERROR: ++ bzlib_read_error(zstream, "corrupted data"); ++ stream->istream.stream_errno = EINVAL; + return -1; ++ case BZ_DATA_ERROR_MAGIC: ++ bzlib_read_error(zstream, ++ "wrong magic in header (not bz2 file?)"); ++ stream->istream.stream_errno = EINVAL; ++ return -1; ++ case BZ_MEM_ERROR: ++ i_fatal_status(FATAL_OUTOFMEM, "bzlib.read(%s): Out of memory", ++ i_stream_get_name(&stream->istream)); ++ case BZ_STREAM_END: ++ zstream->eof_offset = stream->istream.v_offset + ++ (stream->pos - stream->skip); ++ stream->cached_stream_size = zstream->eof_offset; ++ if (out_size == 0) { ++ stream->istream.eof = TRUE; ++ return -1; ++ } ++ break; ++ default: ++ i_fatal("BZ2_bzDecompress() failed with %d", ret); + } +- break; +- default: +- i_fatal("BZ2_bzDecompress() failed with %d", ret); +- } +- if (out_size == 0) { +- /* read more input */ +- return i_stream_bzlib_read(stream); ++ if (out_size == 0) { ++ /* read more input */ ++ return i_stream_bzlib_read(stream); ++ } ++ return out_size; + } +- return out_size; + } + + static void i_stream_bzlib_init(struct bzlib_istream *zstream) +Index: trixie/src/lib-compression/istream-lz4.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-lz4.c ++++ trixie/src/lib-compression/istream-lz4.c +@@ -149,67 +149,69 @@ static ssize_t i_stream_lz4_read(struct + zstream->header_read = TRUE; + } + +- if (zstream->chunk_left == 0) { +- while ((ret = i_stream_lz4_read_chunk_header(zstream)) == 0) { +- if (!stream->istream.blocking) +- return 0; ++ for (;;) { ++ if (zstream->chunk_left == 0) { ++ while ((ret = i_stream_lz4_read_chunk_header(zstream)) == 0) { ++ if (!stream->istream.blocking) ++ return 0; ++ } ++ if (ret < 0) ++ return ret; + } +- if (ret < 0) +- return ret; +- } + +- /* read the whole compressed chunk into memory */ +- while (zstream->chunk_left > 0 && +- (ret = i_stream_read_more(zstream->istream.parent, &data, &size)) > 0) { +- if (size > zstream->chunk_left) +- size = zstream->chunk_left; +- buffer_append(zstream->chunk_buf, data, size); +- i_stream_skip(zstream->istream.parent, size); +- zstream->chunk_left -= size; +- } +- if (zstream->chunk_left > 0) { +- if (ret == -1 && zstream->istream.parent->stream_errno == 0) { +- lz4_read_error(zstream, "truncated lz4 chunk"); +- stream->istream.stream_errno = EPIPE; +- return -1; ++ /* read the whole compressed chunk into memory */ ++ while (zstream->chunk_left > 0 && ++ (ret = i_stream_read_more(zstream->istream.parent, &data, &size)) > 0) { ++ if (size > zstream->chunk_left) ++ size = zstream->chunk_left; ++ buffer_append(zstream->chunk_buf, data, size); ++ i_stream_skip(zstream->istream.parent, size); ++ zstream->chunk_left -= size; + } +- zstream->istream.istream.stream_errno = +- zstream->istream.parent->stream_errno; +- i_assert(ret != 0 || !stream->istream.blocking); +- return ret; +- } +- /* if we already have max_buffer_size amount of data, fail here */ +- if (stream->pos - stream->skip >= i_stream_get_max_buffer_size(&stream->istream)) +- return -2; +- if (i_stream_get_data_size(zstream->istream.parent) > 0) { +- /* Parent stream was only partially consumed. Set the stream's +- IO as pending to avoid hangs. */ +- i_stream_set_input_pending(&zstream->istream.istream, TRUE); +- } +- /* allocate enough space for the old data and the new +- decompressed chunk. we don't know the original compressed size, +- so just allocate the max amount of memory. */ +- void *dest = i_stream_alloc(stream, zstream->max_uncompressed_chunk_size); +- ret = LZ4_decompress_safe(zstream->chunk_buf->data, dest, +- zstream->chunk_buf->used, +- zstream->max_uncompressed_chunk_size); +- i_assert(ret <= (int)zstream->max_uncompressed_chunk_size); +- if (ret < 0) { +- lz4_read_error(zstream, "corrupted lz4 chunk"); +- stream->istream.stream_errno = EINVAL; +- return -1; +- } else if (ret == 0) +- return i_stream_lz4_read(stream); +- i_assert(ret > 0); +- stream->pos += ret; +- i_assert(stream->pos <= stream->buffer_size); ++ if (zstream->chunk_left > 0) { ++ if (ret == -1 && zstream->istream.parent->stream_errno == 0) { ++ lz4_read_error(zstream, "truncated lz4 chunk"); ++ stream->istream.stream_errno = EPIPE; ++ return -1; ++ } ++ zstream->istream.istream.stream_errno = ++ zstream->istream.parent->stream_errno; ++ i_assert(ret != 0 || !stream->istream.blocking); ++ return ret; ++ } ++ /* if we already have max_buffer_size amount of data, fail here */ ++ if (stream->pos - stream->skip >= i_stream_get_max_buffer_size(&stream->istream)) ++ return -2; ++ if (i_stream_get_data_size(zstream->istream.parent) > 0) { ++ /* Parent stream was only partially consumed. Set the stream's ++ IO as pending to avoid hangs. */ ++ i_stream_set_input_pending(&zstream->istream.istream, TRUE); ++ } ++ /* allocate enough space for the old data and the new ++ decompressed chunk. we don't know the original compressed size, ++ so just allocate the max amount of memory. */ ++ void *dest = i_stream_alloc(stream, zstream->max_uncompressed_chunk_size); ++ ret = LZ4_decompress_safe(zstream->chunk_buf->data, dest, ++ zstream->chunk_buf->used, ++ zstream->max_uncompressed_chunk_size); ++ i_assert(ret <= (int)zstream->max_uncompressed_chunk_size); ++ if (ret < 0) { ++ lz4_read_error(zstream, "corrupted lz4 chunk"); ++ stream->istream.stream_errno = EINVAL; ++ return -1; ++ } else if (ret == 0) ++ return i_stream_lz4_read(stream); ++ i_assert(ret > 0); ++ stream->pos += ret; ++ i_assert(stream->pos <= stream->buffer_size); + +- /* we are going to get next chunk after this, so reset here +- so we can reuse the chunk buf for reading next buffer prefix */ +- if (zstream->chunk_left == 0) +- buffer_set_used_size(zstream->chunk_buf, 0); ++ /* we are going to get next chunk after this, so reset here ++ so we can reuse the chunk buf for reading next buffer prefix */ ++ if (zstream->chunk_left == 0) ++ buffer_set_used_size(zstream->chunk_buf, 0); + +- return ret; ++ return ret; ++ } + } + + static void i_stream_lz4_reset(struct lz4_istream *zstream) +Index: trixie/src/lib-compression/istream-zlib.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-zlib.c ++++ trixie/src/lib-compression/istream-zlib.c +@@ -168,155 +168,157 @@ static ssize_t i_stream_zlib_read(struct + size_t size, out_size; + int ret; + +- high_offset = stream->istream.v_offset + (stream->pos - stream->skip); +- if (zstream->eof_offset == high_offset) { +- /* zlib library returned EOF. */ +- if (!zstream->gz) { +- /* deflate - ignore if there's still more data */ +- stream->istream.eof = TRUE; +- return -1; ++ for (;;) { ++ high_offset = stream->istream.v_offset + (stream->pos - stream->skip); ++ if (zstream->eof_offset == high_offset) { ++ /* zlib library returned EOF. */ ++ if (!zstream->gz) { ++ /* deflate - ignore if there's still more data */ ++ stream->istream.eof = TRUE; ++ return -1; ++ } ++ /* gz format - read the trailer */ ++ if (!zstream->trailer_read) { ++ do { ++ ret = i_stream_zlib_read_trailer(zstream); ++ } while (ret == 0 && stream->istream.blocking); ++ if (ret <= 0) ++ return ret; ++ } ++ /* See if there's another concatenated gz stream. */ ++ if (i_stream_read_eof(stream->parent)) { ++ /* EOF or error */ ++ stream->istream.stream_errno = ++ stream->parent->stream_errno; ++ stream->istream.eof = TRUE; ++ return -1; ++ } ++ /* Multiple gz streams concatenated together */ ++ zstream->starting_concatenated_output = TRUE; ++ } ++ if (zstream->starting_concatenated_output) { ++ /* make sure there actually is something in parent stream. ++ we don't want to reset the stream unless we actually see ++ some concatenated output. */ ++ ret = i_stream_read_more(stream->parent, &data, &size); ++ if (ret <= 0) { ++ if (ret == 0) ++ return 0; ++ if (stream->parent->stream_errno != 0) { ++ stream->istream.stream_errno = ++ stream->parent->stream_errno; ++ } ++ stream->istream.eof = TRUE; ++ return -1; ++ } ++ ++ /* gzip file with concatenated content */ ++ stream->cached_stream_size = UOFF_T_MAX; ++ zstream->eof_offset = UOFF_T_MAX; ++ zstream->header_read = FALSE; ++ zstream->trailer_read = FALSE; ++ zstream->crc32 = 0; ++ zstream->starting_concatenated_output = FALSE; ++ ++ (void)inflateEnd(&zstream->zs); ++ i_stream_zlib_init(zstream); + } +- /* gz format - read the trailer */ +- if (!zstream->trailer_read) { ++ ++ if (!zstream->header_read) { + do { +- ret = i_stream_zlib_read_trailer(zstream); ++ ret = i_stream_zlib_read_header(stream); + } while (ret == 0 && stream->istream.blocking); + if (ret <= 0) + return ret; ++ zstream->header_read = TRUE; + } +- /* See if there's another concatenated gz stream. */ +- if (i_stream_read_eof(stream->parent)) { +- /* EOF or error */ +- stream->istream.stream_errno = +- stream->parent->stream_errno; +- stream->istream.eof = TRUE; +- return -1; ++ ++ if (!zstream->marked) { ++ if (!i_stream_try_alloc(stream, CHUNK_SIZE, &out_size)) ++ return -2; /* buffer full */ ++ } else { ++ /* try to avoid compressing, so we can quickly seek backwards */ ++ if (!i_stream_try_alloc_avoid_compress(stream, CHUNK_SIZE, &out_size)) ++ return -2; /* buffer full */ + } +- /* Multiple gz streams concatenated together */ +- zstream->starting_concatenated_output = TRUE; +- } +- if (zstream->starting_concatenated_output) { +- /* make sure there actually is something in parent stream. +- we don't want to reset the stream unless we actually see +- some concatenated output. */ +- ret = i_stream_read_more(stream->parent, &data, &size); +- if (ret <= 0) { +- if (ret == 0) +- return 0; ++ ++ if (i_stream_read_more(stream->parent, &data, &size) < 0) { + if (stream->parent->stream_errno != 0) { + stream->istream.stream_errno = + stream->parent->stream_errno; ++ } else { ++ i_assert(stream->parent->eof); ++ zlib_read_error(zstream, "unexpected EOF"); ++ stream->istream.stream_errno = EPIPE; + } +- stream->istream.eof = TRUE; + return -1; + } +- +- /* gzip file with concatenated content */ +- stream->cached_stream_size = UOFF_T_MAX; +- zstream->eof_offset = UOFF_T_MAX; +- zstream->header_read = FALSE; +- zstream->trailer_read = FALSE; +- zstream->crc32 = 0; +- zstream->starting_concatenated_output = FALSE; +- +- (void)inflateEnd(&zstream->zs); +- i_stream_zlib_init(zstream); +- } +- +- if (!zstream->header_read) { +- do { +- ret = i_stream_zlib_read_header(stream); +- } while (ret == 0 && stream->istream.blocking); +- if (ret <= 0) +- return ret; +- zstream->header_read = TRUE; +- } +- +- if (!zstream->marked) { +- if (!i_stream_try_alloc(stream, CHUNK_SIZE, &out_size)) +- return -2; /* buffer full */ +- } else { +- /* try to avoid compressing, so we can quickly seek backwards */ +- if (!i_stream_try_alloc_avoid_compress(stream, CHUNK_SIZE, &out_size)) +- return -2; /* buffer full */ +- } +- +- if (i_stream_read_more(stream->parent, &data, &size) < 0) { +- if (stream->parent->stream_errno != 0) { +- stream->istream.stream_errno = +- stream->parent->stream_errno; +- } else { +- i_assert(stream->parent->eof); +- zlib_read_error(zstream, "unexpected EOF"); +- stream->istream.stream_errno = EPIPE; +- } +- return -1; +- } +- if (size == 0) { +- /* no more input */ +- i_assert(!stream->istream.blocking); +- return 0; +- } +- +- zstream->zs.next_in = (void *)data; +- zstream->zs.avail_in = size; +- +- zstream->zs.next_out = stream->w_buffer + stream->pos; +- zstream->zs.avail_out = out_size; +- ret = inflate(&zstream->zs, Z_SYNC_FLUSH); +- +- out_size -= zstream->zs.avail_out; +- /* CRC32 is only needed for GZ trailer. */ +- if (zstream->gz) +- zstream->crc32 = crc32_data_more(zstream->crc32, +- stream->w_buffer + stream->pos, +- out_size); +- stream->pos += out_size; +- +- size_t bytes_consumed = size - zstream->zs.avail_in; +- i_stream_skip(stream->parent, bytes_consumed); +- if (i_stream_get_data_size(stream->parent) > 0 && +- (bytes_consumed > 0 || out_size > 0)) { +- /* Parent stream was only partially consumed. Set the stream's +- IO as pending to avoid hangs. */ +- i_stream_set_input_pending(&stream->istream, TRUE); +- } +- +- switch (ret) { +- case Z_OK: +- break; +- case Z_NEED_DICT: +- zlib_read_error(zstream, "can't read file without dict"); +- stream->istream.stream_errno = EIO; +- return -1; +- case Z_DATA_ERROR: +- zlib_read_error(zstream, "corrupted data"); +- stream->istream.stream_errno = EINVAL; +- return -1; +- case Z_MEM_ERROR: +- i_fatal_status(FATAL_OUTOFMEM, "zlib.read(%s): Out of memory", +- i_stream_get_name(&stream->istream)); +- case Z_STREAM_END: +- zstream->eof_offset = stream->istream.v_offset + +- (stream->pos - stream->skip); +- stream->cached_stream_size = zstream->eof_offset; +- zstream->zs.avail_in = 0; +- +- if (!zstream->trailer_read) { +- /* try to read and verify the trailer, we might not +- be called again. */ +- if (i_stream_zlib_read_trailer(zstream) < 0) +- return -1; ++ if (size == 0) { ++ /* no more input */ ++ i_assert(!stream->istream.blocking); ++ return 0; ++ } ++ ++ zstream->zs.next_in = (void *)data; ++ zstream->zs.avail_in = size; ++ ++ zstream->zs.next_out = stream->w_buffer + stream->pos; ++ zstream->zs.avail_out = out_size; ++ ret = inflate(&zstream->zs, Z_SYNC_FLUSH); ++ ++ out_size -= zstream->zs.avail_out; ++ /* CRC32 is only needed for GZ trailer. */ ++ if (zstream->gz) ++ zstream->crc32 = crc32_data_more(zstream->crc32, ++ stream->w_buffer + stream->pos, ++ out_size); ++ stream->pos += out_size; ++ ++ size_t bytes_consumed = size - zstream->zs.avail_in; ++ i_stream_skip(stream->parent, bytes_consumed); ++ if (i_stream_get_data_size(stream->parent) > 0 && ++ (bytes_consumed > 0 || out_size > 0)) { ++ /* Parent stream was only partially consumed. Set the stream's ++ IO as pending to avoid hangs. */ ++ i_stream_set_input_pending(&stream->istream, TRUE); ++ } ++ ++ switch (ret) { ++ case Z_OK: ++ break; ++ case Z_NEED_DICT: ++ zlib_read_error(zstream, "can't read file without dict"); ++ stream->istream.stream_errno = EIO; ++ return -1; ++ case Z_DATA_ERROR: ++ zlib_read_error(zstream, "corrupted data"); ++ stream->istream.stream_errno = EINVAL; ++ return -1; ++ case Z_MEM_ERROR: ++ i_fatal_status(FATAL_OUTOFMEM, "zlib.read(%s): Out of memory", ++ i_stream_get_name(&stream->istream)); ++ case Z_STREAM_END: ++ zstream->eof_offset = stream->istream.v_offset + ++ (stream->pos - stream->skip); ++ stream->cached_stream_size = zstream->eof_offset; ++ zstream->zs.avail_in = 0; ++ ++ if (!zstream->trailer_read) { ++ /* try to read and verify the trailer, we might not ++ be called again. */ ++ if (i_stream_zlib_read_trailer(zstream) < 0) ++ return -1; ++ } ++ break; ++ default: ++ i_fatal("inflate() failed with %d", ret); ++ } ++ if (out_size == 0) { ++ /* read more input */ ++ return i_stream_zlib_read(stream); + } +- break; +- default: +- i_fatal("inflate() failed with %d", ret); +- } +- if (out_size == 0) { +- /* read more input */ +- return i_stream_zlib_read(stream); ++ return out_size; + } +- return out_size; + } + + static void i_stream_zlib_init(struct zlib_istream *zstream) diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-i_close_fd-Document-that-it-preserves-errno.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-i_close_fd-Document-that-it-preserves-errno.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-i_close_fd-Document-that-it-preserves-errno.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-i_close_fd-Document-that-it-preserves-errno.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,25 @@ +From 5e72915a0d803154ab50958364ff4ce78ea146da Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 18:04:42 +0300 +Subject: [PATCH 1/5] lib: i_close_fd*() - Document that it preserves errno + +--- + src/lib/fd-util.h | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/lib/fd-util.h b/src/lib/fd-util.h +index 54bdd63160..3f46892759 100644 +--- a/src/lib/fd-util.h ++++ b/src/lib/fd-util.h +@@ -17,7 +17,7 @@ void fd_close_maybe_stdio(int *fd_in, int *fd_out); + + /* Close the fd and set it to -1. This assert-crashes if fd == 0, and is a + no-op if fd == -1. Normally fd == 0 would happen only if an uninitialized +- fd is attempted to be closed, which is a bug. */ ++ fd is attempted to be closed, which is a bug. Preserves original errno. */ + void i_close_fd_path(int *fd, const char *path, const char *arg, + const char *func, const char *file, int line); + #define i_close_fd_path(fd, path) i_close_fd_path((fd), (path), #fd, __func__, __FILE__, __LINE__) +-- +2.39.5 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-imap-match-Fix-excessive-CPU-usage-caused-b.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-imap-match-Fix-excessive-CPU-usage-caused-b.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-imap-match-Fix-excessive-CPU-usage-caused-b.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-imap-match-Fix-excessive-CPU-usage-caused-b.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,524 @@ +From 16ff02fd5635a3b6583a276377a8b6880047d601 Mon Sep 17 00:00:00 2001 +From: Noah Meyerhans +Date: Thu, 10 Sep 2026 09:59:18 -0400 +Subject: [PATCH] lib-imap: imap-match - Fix excessive CPU usage caused by + backtracking +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Backport the NFA/non-backtracking algorithm from 2684624… while retaining the +byte-oriented matcher semantics of 2.4.1. + +Original commit message: + +Replace the recursive backtracking matcher with a Thompson-style NFA +simulation over grapheme clusters. + +Each grapheme cluster of the compressed pattern becomes one state: +LITERAL, PERCENT (consume any number of non-separator clusters) or STAR +(consume any number of clusters including separators). A virtual +ACCEPT position sits at index n_states. Simulation tracks the set of +active positions in a bitmap. Epsilon-closure (skipping a PERCENT or +STAR without consuming) is a single forward pass because the NFA is +linear: each state can only epsilon-skip to i+1. + +The resulting match is O(n_data * n_pattern) regardless of pattern +shape, with no recursion and no backtracking, so there is no way for +a malicious pattern or mailbox name to trigger exponential CPU, +excessive stack depth, or unbounded memory. + +IMAP_MATCH_YES / NO / CHILDREN / PARENT semantics are preserved: + +- YES: ACCEPT reachable after consuming all data. +- PARENT: ACCEPT was active at some point while the next data + grapheme cluster was the separator. +- CHILDREN: some active non-ACCEPT state remains after consuming all + data, and either the data ends with a separator or an + active state can still consume a separator (precomputed + as sep_accept[]). + +Inboxcase handling (case-insensitive comparison for the INBOX prefix +of data) and grapheme-cluster comparison are unchanged - the existing +match_gc logic is reused inline as literal_matches(). +pattern_compress() and pattern_is_inboxcase() are unchanged. +--- + src/lib-imap/imap-match.c | 307 ++++++++++++++++++++------------- + src/lib-imap/test-imap-match.c | 52 ++++++ + 2 files changed, 239 insertions(+), 120 deletions(-) + +diff --git a/src/lib-imap/imap-match.c b/src/lib-imap/imap-match.c +index 8603e004fb..fec5a3c242 100644 +--- a/src/lib-imap/imap-match.c ++++ b/src/lib-imap/imap-match.c +@@ -9,9 +9,44 @@ + + #include + ++/* Pattern matching is implemented as a Thompson-style NFA simulation. ++ Each byte in the compressed pattern becomes one NFA state: ++ ++ LITERAL - must consume a matching byte, with inboxcase fallback ++ PERCENT - may consume any number of non-separator bytes ++ STAR - may consume any number of bytes, including separators ++ ++ A virtual ACCEPT position sits at index n_states. Simulation tracks the ++ set of active positions in boolean arrays. Epsilon transitions (skipping ++ a PERCENT or STAR without consuming) are applied as a single forward pass, ++ because the NFA is linear: each state can only epsilon-skip to i+1. ++ ++ Complexity is O(n_data * n_pattern), regardless of wildcard count or ++ pattern shape, so there is no recursive backtracking and no way for a ++ malicious pattern or mailbox name to trigger exponential CPU usage. ++ ++ This 2.4.1 backport keeps the historical byte-oriented matching model. ++ The upstream fix operates on grapheme clusters, but backporting that would ++ require additional Unicode matching changes outside the minimal ReDoS fix. */ ++ ++enum imap_match_nfa_type { ++ IMAP_MATCH_NFA_LITERAL = 0, ++ IMAP_MATCH_NFA_PERCENT, ++ IMAP_MATCH_NFA_STAR ++}; ++ ++struct imap_match_nfa_state { ++ enum imap_match_nfa_type type; ++ bool sep_accept; ++ unsigned char ch; ++}; ++ + struct imap_match_pattern { + const char *pattern; + bool inboxcase; ++ ++ unsigned int n_states; ++ struct imap_match_nfa_state *states; + }; + + struct imap_match_glob { +@@ -23,13 +58,6 @@ struct imap_match_glob { + char patterns_data[FLEXIBLE_ARRAY_MEMBER]; + }; + +-struct imap_match_context { +- const char *inboxcase_end; +- +- char sep; +- bool inboxcase; +-}; +- + /* name of "INBOX" - must not have repeated substrings */ + static const char inbox[] = "INBOX"; + #define INBOXLEN (sizeof(inbox) - 1) +@@ -108,6 +136,48 @@ static bool pattern_is_inboxcase(const char *pattern, char separator) + return TRUE; + } + ++static void ++imap_match_compile(pool_t pool, struct imap_match_pattern *pat, char sep) ++{ ++ unsigned int i; ++ ++ pat->n_states = strlen(pat->pattern); ++ pat->states = pat->n_states == 0 ? NULL : ++ p_new(pool, struct imap_match_nfa_state, pat->n_states); ++ ++ for (i = 0; i < pat->n_states; i++) { ++ struct imap_match_nfa_state *state = &pat->states[i]; ++ unsigned char ch = (unsigned char)pat->pattern[i]; ++ ++ if (ch == '%') ++ state->type = IMAP_MATCH_NFA_PERCENT; ++ else if (ch == '*') ++ state->type = IMAP_MATCH_NFA_STAR; ++ else { ++ state->type = IMAP_MATCH_NFA_LITERAL; ++ state->ch = ch; ++ } ++ } ++ ++ for (i = pat->n_states; i > 0; i--) { ++ unsigned int idx = i - 1; ++ const struct imap_match_nfa_state *state = &pat->states[idx]; ++ bool consume_sep = ++ state->type == IMAP_MATCH_NFA_STAR || ++ (state->type == IMAP_MATCH_NFA_LITERAL && ++ state->ch == (unsigned char)sep); ++ bool eps_skippable = ++ state->type == IMAP_MATCH_NFA_PERCENT || ++ state->type == IMAP_MATCH_NFA_STAR; ++ bool next_sep_accept = ++ idx + 1 < pat->n_states ? ++ pat->states[idx + 1].sep_accept : FALSE; ++ ++ pat->states[idx].sep_accept = ++ consume_sep || (eps_skippable && next_sep_accept); ++ } ++} ++ + static struct imap_match_glob * + imap_match_init_multiple_real(pool_t pool, const char *const *patterns, + bool inboxcase, char separator) +@@ -138,7 +208,7 @@ imap_match_init_multiple_real(pool_t pool, const char *const *patterns, + glob->pool = pool; + glob->sep = separator; + +- /* copy pattern strings to our allocated memory */ ++ /* copy pattern strings to our allocated memory and compile NFAs */ + for (i = 0, pos = 0; i < patterns_count; i++) { + len = strlen(match_patterns[i].pattern) + 1; + i_assert(pos + len <= patterns_data_len); +@@ -148,6 +218,8 @@ imap_match_init_multiple_real(pool_t pool, const char *const *patterns, + match_patterns[i].pattern, len); + match_patterns[i].pattern = glob->patterns_data + pos; + pos += len; ++ ++ imap_match_compile(pool, &match_patterns[i], separator); + } + glob->patterns = match_patterns; + return glob; +@@ -172,8 +244,15 @@ imap_match_init_multiple(pool_t pool, const char *const *patterns, + + void imap_match_deinit(struct imap_match_glob **glob) + { ++ struct imap_match_pattern *p; ++ + if (glob == NULL || *glob == NULL) + return; ++ ++ for (p = (*glob)->patterns; p->pattern != NULL; p++) { ++ if (p->states != NULL) ++ p_free((*glob)->pool, p->states); ++ } + p_free((*glob)->pool, (*glob)->patterns); + p_free((*glob)->pool, *glob); + *glob = NULL; +@@ -230,148 +309,136 @@ bool imap_match_globs_equal(const struct imap_match_glob *glob1, + return p1->pattern == p2->pattern; + } + +-#define CMP_CUR_CHR(ctx, data, pattern) \ +- (*(data) == *(pattern) || \ +- (i_toupper(*(data)) == i_toupper(*(pattern)) && \ +- (data) < (ctx)->inboxcase_end)) ++static bool ++literal_matches(const struct imap_match_nfa_state *state, ++ unsigned char data_ch, bool inboxcase_pos) ++{ ++ if (state->ch == data_ch) ++ return TRUE; ++ return inboxcase_pos && ++ i_toupper(data_ch) == i_toupper(state->ch); ++} + +-static enum imap_match_result +-match_sub(struct imap_match_context *ctx, const char **data_p, +- const char **pattern_p) ++static void ++nfa_eps_close(const struct imap_match_pattern *pat, bool *bits) + { +- enum imap_match_result ret, match; + unsigned int i; +- const char *data = *data_p, *pattern = *pattern_p; +- +- /* match all non-wildcards */ +- i = 0; +- while (pattern[i] != '\0' && pattern[i] != '*' && pattern[i] != '%') { +- if (!CMP_CUR_CHR(ctx, data+i, pattern+i)) { +- if (data[i] != '\0') +- return IMAP_MATCH_NO; +- if (pattern[i] == ctx->sep) +- return IMAP_MATCH_CHILDREN; +- if (i > 0 && pattern[i-1] == ctx->sep) { +- /* data="foo/" pattern = "foo/bar/%" */ +- return IMAP_MATCH_CHILDREN; +- } +- return IMAP_MATCH_NO; +- } +- i++; +- } +- data += i; +- pattern += i; + +- if (*data == '\0' && *data_p != data && data[-1] == ctx->sep && +- *pattern != '\0') { +- /* data="/" pattern="/%..." */ +- match = IMAP_MATCH_CHILDREN; +- } else { +- match = IMAP_MATCH_NO; ++ for (i = 0; i < pat->n_states; i++) { ++ if (!bits[i]) ++ continue; ++ if (pat->states[i].type == IMAP_MATCH_NFA_PERCENT || ++ pat->states[i].type == IMAP_MATCH_NFA_STAR) ++ bits[i + 1] = TRUE; + } +- while (*pattern == '%') { +- pattern++; +- +- if (*pattern == '\0') { +- /* match, if this is the last hierarchy */ +- while (*data != '\0' && *data != ctx->sep) +- data++; +- break; +- } +- +- /* skip over this hierarchy */ +- while (*data != '\0') { +- if (CMP_CUR_CHR(ctx, data, pattern)) { +- ret = match_sub(ctx, &data, &pattern); +- if (ret == IMAP_MATCH_YES) +- break; +- +- match |= ret; +- } +- +- if (*data == ctx->sep) +- break; ++} + +- data++; +- } ++static enum imap_match_result ++imap_match_pattern_run(const struct imap_match_pattern *pat, ++ const char *data, char sep, bool inboxcase_pattern) ++{ ++ const char *inboxcase_end = data; ++ unsigned int n_bits = pat->n_states + 1; ++ enum imap_match_result result = IMAP_MATCH_NO; ++ bool parent_flag = FALSE; ++ bool data_ends_with_sep = FALSE; ++ bool *cur, *next; ++ const unsigned char *p; ++ ++ if (inboxcase_pattern && ++ strncasecmp(data, inbox, INBOXLEN) == 0 && ++ (data[INBOXLEN] == '\0' || data[INBOXLEN] == sep)) { ++ inboxcase_end += INBOXLEN; + } + +- if (*pattern != '*') { +- if (*data == '\0' && *pattern != '\0') { +- if (*pattern == ctx->sep) +- match |= IMAP_MATCH_CHILDREN; +- return match; +- } ++ cur = t_new(bool, n_bits); ++ next = t_new(bool, n_bits); ++ memset(cur, 0, n_bits * sizeof(*cur)); ++ memset(next, 0, n_bits * sizeof(*next)); + +- if (*data != '\0') { +- if (*pattern == '\0' && *data == ctx->sep) +- match |= IMAP_MATCH_PARENT; +- return match; +- } +- } ++ cur[0] = TRUE; ++ nfa_eps_close(pat, cur); + +- *data_p = data; +- *pattern_p = pattern; +- return IMAP_MATCH_YES; +-} ++ for (p = (const unsigned char *)data; *p != '\0'; p++) { ++ unsigned int i; ++ unsigned char ch = *p; ++ bool ch_is_sep = ch == (unsigned char)sep; ++ bool inboxcase_pos = (const char *)p < inboxcase_end; + +-static enum imap_match_result +-imap_match_pattern(struct imap_match_context *ctx, +- const char *data, const char *pattern) +-{ +- enum imap_match_result ret, match; ++ if (ch_is_sep && cur[pat->n_states]) ++ parent_flag = TRUE; + +- ctx->inboxcase_end = data; +- if (ctx->inboxcase && strncasecmp(data, inbox, INBOXLEN) == 0 && +- (data[INBOXLEN] == '\0' || data[INBOXLEN] == ctx->sep)) { +- /* data begins with INBOX/, use case-insensitive comparison +- for it */ +- ctx->inboxcase_end += INBOXLEN; +- } ++ memset(next, 0, n_bits * sizeof(*next)); ++ for (i = 0; i < pat->n_states; i++) { ++ const struct imap_match_nfa_state *state; + +- if (*pattern != '*') { +- /* handle the pattern up to the first '*' */ +- ret = match_sub(ctx, &data, &pattern); +- if (ret != IMAP_MATCH_YES || *pattern == '\0') +- return ret; +- } ++ if (!cur[i]) ++ continue; + +- match = IMAP_MATCH_CHILDREN; +- while (*pattern == '*') { +- pattern++; ++ state = &pat->states[i]; ++ switch (state->type) { ++ case IMAP_MATCH_NFA_LITERAL: ++ if (literal_matches(state, ch, inboxcase_pos)) ++ next[i + 1] = TRUE; ++ break; ++ case IMAP_MATCH_NFA_PERCENT: ++ if (!ch_is_sep) ++ next[i] = TRUE; ++ break; ++ case IMAP_MATCH_NFA_STAR: ++ next[i] = TRUE; ++ break; ++ } ++ } + +- if (*pattern == '\0') +- return IMAP_MATCH_YES; ++ { ++ bool *tmp = cur; ++ cur = next; ++ next = tmp; ++ } ++ nfa_eps_close(pat, cur); ++ data_ends_with_sep = ch_is_sep; ++ } + +- while (*data != '\0') { +- if (CMP_CUR_CHR(ctx, data, pattern)) { +- ret = match_sub(ctx, &data, &pattern); +- if (ret == IMAP_MATCH_YES) +- break; +- match |= ret; ++ if (cur[pat->n_states]) ++ result = IMAP_MATCH_YES; ++ else { ++ unsigned int i; ++ bool has_nonaccept = FALSE; ++ bool has_sep_accept = FALSE; ++ ++ for (i = 0; i < pat->n_states; i++) { ++ if (!cur[i]) ++ continue; ++ has_nonaccept = TRUE; ++ if (pat->states[i].sep_accept) { ++ has_sep_accept = TRUE; ++ break; + } +- +- data++; + } ++ ++ if (has_nonaccept && (data_ends_with_sep || has_sep_accept)) ++ result |= IMAP_MATCH_CHILDREN; ++ if (parent_flag) ++ result |= IMAP_MATCH_PARENT; + } + +- return *data == '\0' && *pattern == '\0' ? +- IMAP_MATCH_YES : match; ++ return result; + } + + enum imap_match_result + imap_match(struct imap_match_glob *glob, const char *data) + { +- struct imap_match_context ctx; + unsigned int i; + enum imap_match_result ret, match; + + match = IMAP_MATCH_NO; +- ctx.sep = glob->sep; + for (i = 0; glob->patterns[i].pattern != NULL; i++) { +- ctx.inboxcase = glob->patterns[i].inboxcase; +- +- ret = imap_match_pattern(&ctx, data, glob->patterns[i].pattern); ++ T_BEGIN { ++ ret = imap_match_pattern_run(&glob->patterns[i], data, ++ glob->sep, ++ glob->patterns[i].inboxcase); ++ } T_END; + if (ret == IMAP_MATCH_YES) + return IMAP_MATCH_YES; + +diff --git a/src/lib-imap/test-imap-match.c b/src/lib-imap/test-imap-match.c +index df911dae50..88459ea9c3 100644 +--- a/src/lib-imap/test-imap-match.c ++++ b/src/lib-imap/test-imap-match.c +@@ -2,6 +2,7 @@ + + #include "lib.h" + #include "imap-match.h" ++#include "str.h" + #include "test-common.h" + + struct test_imap_match { +@@ -88,6 +89,56 @@ static void test_imap_match(void) + test_end(); + } + ++static void test_imap_match_no_redos(void) ++{ ++ struct imap_match_glob *glob; ++ pool_t pool; ++ const unsigned int wildcard_count = 1000; ++ string_t *mailbox_name, *pattern; ++ unsigned int i; ++ ++ pool = pool_alloconly_create("imap match redos", 1024); ++ test_begin("imap match no redos"); ++ ++ mailbox_name = str_new(pool, 256); ++ for (i = 0; i < 255; i++) ++ str_append_c(mailbox_name, 'a'); ++ ++ pattern = str_new(pool, wildcard_count * 2 + 10); ++ for (i = 0; i < 255; i++) ++ str_append(pattern, "%a"); ++ glob = imap_match_init(pool, str_c(pattern), FALSE, '/'); ++ test_assert(imap_match(glob, str_c(mailbox_name)) == IMAP_MATCH_YES); ++ ++ str_truncate(pattern, 0); ++ for (i = 0; i < wildcard_count; i++) ++ str_append(pattern, "%a"); ++ glob = imap_match_init(pool, str_c(pattern), FALSE, '/'); ++ test_assert(imap_match(glob, str_c(mailbox_name)) == IMAP_MATCH_NO); ++ ++ str_append_c(pattern, 'b'); ++ glob = imap_match_init(pool, str_c(pattern), FALSE, '/'); ++ test_assert(imap_match(glob, str_c(mailbox_name)) == IMAP_MATCH_NO); ++ ++ str_truncate(pattern, 0); ++ for (i = 0; i < wildcard_count; i++) ++ str_append(pattern, "*a"); ++ str_append_c(pattern, 'b'); ++ glob = imap_match_init(pool, str_c(pattern), FALSE, '/'); ++ test_assert(imap_match(glob, str_c(mailbox_name)) == IMAP_MATCH_CHILDREN); ++ p_clear(pool); ++ ++ glob = imap_match_init(pool, "%a%a%a%a%ab", FALSE, '/'); ++ test_assert(imap_match(glob, "aaaaab") == IMAP_MATCH_YES); ++ p_clear(pool); ++ ++ glob = imap_match_init(pool, "%a%a%a%a%ab", FALSE, '/'); ++ test_assert(imap_match(glob, "aaaaa") == IMAP_MATCH_NO); ++ ++ pool_unref(&pool); ++ test_end(); ++} ++ + static void test_imap_match_globs_equal(void) + { + struct imap_match_glob *glob; +@@ -120,6 +171,7 @@ int main(void) + { + static void (*const test_functions[])(void) = { + test_imap_match, ++ test_imap_match_no_redos, + test_imap_match_globs_equal, + NULL + }; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-urlauth-Fix-leaking-uninitialized-memory-in.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-urlauth-Fix-leaking-uninitialized-memory-in.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-urlauth-Fix-leaking-uninitialized-memory-in.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-imap-urlauth-Fix-leaking-uninitialized-memory-in.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,35 @@ +From 366ca92f916953c2e475ff20dd7d21fcca5a1987 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 6 May 2026 14:53:41 +0000 +Subject: [PATCH] lib-imap-urlauth: Fix leaking uninitialized memory into + client error message + +imap_urlauth_fetch_parsed() called imap_msgpart_url_open_mailbox() with +client_error_r and then, on the ret==0 (mailbox-not-found) branch, +formatted a separate uninitialized local "error" pointer with +t_strdup_printf("Invalid URLAUTH: %s", error). The %s read process +stack memory until a NUL byte and sent it to the authenticated IMAP +client inside the "* NO Failed to fetch URLAUTH ..." response. + +Broken by bb193c273e63ffa42c5c0b51ecd8860398e3beab +--- + src/lib-imap-urlauth/imap-urlauth.c | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/src/lib-imap-urlauth/imap-urlauth.c b/src/lib-imap-urlauth/imap-urlauth.c +index 3cdcc21bb6..21f4520a93 100644 +--- a/src/lib-imap-urlauth/imap-urlauth.c ++++ b/src/lib-imap-urlauth/imap-urlauth.c +@@ -513,7 +513,8 @@ int imap_urlauth_fetch_parsed(struct imap_urlauth_context *uctx, + } + + if ((ret = imap_msgpart_url_open_mailbox(mpurl, &box, error_code_r, +- client_error_r)) < 0) { ++ &error)) < 0) { ++ *client_error_r = t_strdup_printf("Invalid URLAUTH: %s", error); + imap_msgpart_url_free(&mpurl); + return -1; + } +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-lda-mail-deliver-Autocreate-delivery-mailbox-via.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-lda-mail-deliver-Autocreate-delivery-mailbox-via.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-lda-mail-deliver-Autocreate-delivery-mailbox-via.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-lda-mail-deliver-Autocreate-delivery-mailbox-via.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,63 @@ +From 3a25b72230003ca7e133dc1117cc5e0a2f006ef3 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 11 Jun 2026 17:20:32 +0000 +Subject: [PATCH 1/2] lib-lda: mail-deliver - Autocreate delivery mailbox via + explicit mailbox_create() + +Instead of relying on MAILBOX_FLAG_AUTO_CREATE to have mailbox_open() +implicitly create the target, open it and, on MAIL_ERROR_NOTFOUND, +create it explicitly when lda_mailbox_autocreate is enabled. This makes +the creation go through the normal mailbox_create() path (including ACL +checks) and removes one of the two users of the autocreate mailbox flags. +--- + src/lib-lda/mail-deliver.c | 29 ++++++++++++++++++++++++----- + 1 file changed, 24 insertions(+), 5 deletions(-) + +diff --git a/src/lib-lda/mail-deliver.c b/src/lib-lda/mail-deliver.c +index 1001fed76b..605353bb8d 100644 +--- a/src/lib-lda/mail-deliver.c ++++ b/src/lib-lda/mail-deliver.c +@@ -244,16 +244,35 @@ int mail_deliver_save_open(struct mail_deliver_save_open_context *ctx, + return -1; + } + +- if (ctx->lda_mailbox_autocreate) +- flags |= MAILBOX_FLAG_AUTO_CREATE; +- if (ctx->lda_mailbox_autosubscribe) +- flags |= MAILBOX_FLAG_AUTO_SUBSCRIBE; + *box_r = box = mailbox_alloc_for_user(ctx->user, name, flags); + + if (mailbox_open(box) == 0) + return 0; + *error_str_r = mailbox_get_last_internal_error(box, error_r); +- return -1; ++ ++ /* Autocreate the mailbox if it does not exist yet and ++ lda_mailbox_autocreate is enabled. */ ++ if (*error_r != MAIL_ERROR_NOTFOUND || !ctx->lda_mailbox_autocreate) ++ return -1; ++ ++ if (mailbox_create(box, NULL, FALSE) < 0) { ++ enum mail_error error; ++ ++ *error_str_r = mailbox_get_last_internal_error(box, &error); ++ if (error != MAIL_ERROR_EXISTS) { ++ *error_r = error; ++ return -1; ++ } ++ /* Someone else just created it; fall through and open it. */ ++ } else if (ctx->lda_mailbox_autosubscribe) { ++ (void)mailbox_set_subscribed(box, TRUE); ++ } ++ ++ if (mailbox_open(box) < 0) { ++ *error_str_r = mailbox_get_last_internal_error(box, error_r); ++ return -1; ++ } ++ return 0; + } + + static bool mail_deliver_check_duplicate(struct mail_deliver_session *session, +-- +2.39.5 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-Reset-charset-translation-buffer-between-MI.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-Reset-charset-translation-buffer-between-MI.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-Reset-charset-translation-buffer-between-MI.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-Reset-charset-translation-buffer-between-MI.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,105 @@ +From 3d300ea1f26efd7f0207c5e3efd81f644ca03bf9 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sat, 28 Feb 2026 10:27:19 +0200 +Subject: [PATCH 01/14] lib-mail: Reset charset translation buffer between MIME + parts + +If MIME part ended with an incomplete charset translation, the buffer was +kept for the next MIME part. This could have produced garbage in the next +MIME part, or a crash. + +Fixes: +Panic: file message-decoder.c: line 232 (translation_buf_decode): assertion failed: (orig_size < CHARSET_MAX_PENDING_BUF_SIZE) +--- + src/lib-mail/message-decoder.c | 4 +++ + src/lib-mail/test-message-decoder.c | 52 +++++++++++++++++++++++++++++ + 2 files changed, 56 insertions(+) + +diff --git a/src/lib-mail/message-decoder.c b/src/lib-mail/message-decoder.c +index 845b3d5e3a..4eea9994a2 100644 +--- a/src/lib-mail/message-decoder.c ++++ b/src/lib-mail/message-decoder.c +@@ -257,6 +257,10 @@ message_decode_body_init_charset(struct message_decoder_context *ctx, + if (ctx->binary_input) + return; + ++ /* If some input was left untranslated in the previous MIME part, ++ it needs to be discarded now so it won't affect the next part. */ ++ ctx->translation_size = 0; ++ + if (ctx->charset_trans != NULL && ctx->content_charset != NULL && + strcasecmp(ctx->content_charset, ctx->charset_trans_charset) == 0) { + /* already have the correct translation selected */ +diff --git a/src/lib-mail/test-message-decoder.c b/src/lib-mail/test-message-decoder.c +index edf9210cda..83c267187b 100644 +--- a/src/lib-mail/test-message-decoder.c ++++ b/src/lib-mail/test-message-decoder.c +@@ -498,6 +498,57 @@ UNICODE_REPLACEMENT_CHAR_UTF8; + test_end(); + } + ++static void test_message_decoder_charset_mime_part_change(void) ++{ ++ static const unsigned char test_message_input[] = ++"Content-Type: multipart/mixed; boundary=\"1\"\n" ++"MIME-Version: 1.0\n\n" ++"--1\n" ++"Content-Type: text/plain; charset=utf-8\n\n" ++"\xc3\n" ++"--1\n" ++"Content-Type: text/plain; charset=utf-8\n\n" ++"\xa4\n" ++"--1--\n"; ++ ++ static const char *test_message_output = ++ UNICODE_REPLACEMENT_CHAR_UTF8; ++ ++ test_begin("message decoder charset - mime part change"); ++ ++ const struct message_parser_settings parser_set = { .flags = 0, }; ++ struct message_parser_ctx *parser; ++ struct message_decoder_context *decoder; ++ struct message_part *parts; ++ struct message_block input, output; ++ struct istream *istream; ++ string_t *str_out = t_str_new(20); ++ int ret; ++ ++ pool_t pool = pool_alloconly_create("message parser", 10240); ++ istream = test_istream_create_data(test_message_input, ++ sizeof(test_message_input)-1); ++ parser = message_parser_init(pool, istream, &parser_set); ++ decoder = message_decoder_init(NULL, 0); ++ ++ while ((ret = message_parser_parse_next_block(parser, &input)) > 0) { ++ message_part_data_parse_from_header(pool, input.part, input.hdr); ++ if (message_decoder_decode_next_block(decoder, &input, &output) && ++ output.hdr == NULL && output.size > 0) ++ str_append_data(str_out, output.data, output.size); ++ } ++ ++ test_assert(ret == -1); ++ test_assert_strcmp(test_message_output, str_c(str_out)); ++ message_decoder_deinit(&decoder); ++ message_parser_deinit(&parser, &parts); ++ test_assert(istream->stream_errno == 0); ++ ++ i_stream_unref(&istream); ++ pool_unref(&pool); ++ test_end(); ++} ++ + int main(void) + { + static void (*const test_functions[])(void) = { +@@ -507,6 +558,7 @@ int main(void) + test_message_decoder_content_transfer_encoding, + test_message_decoder_invalid_content_transfer_encoding, + test_message_decoder_charset, ++ test_message_decoder_charset_mime_part_change, + NULL + }; + return test_run(test_functions); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-o_stream_dot_sendv-Use-I_MIN.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-o_stream_dot_sendv-Use-I_MIN.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-o_stream_dot_sendv-Use-I_MIN.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-o_stream_dot_sendv-Use-I_MIN.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,23 @@ +From fc74e9b79107879c9f6f714c2c8becb533ba07d6 Mon Sep 17 00:00:00 2001 +From: Marco Bettini +Date: Fri, 10 Apr 2026 08:53:36 +0000 +Subject: [PATCH 1/4] lib-mail: o_stream_dot_sendv() - Use I_MIN() + +--- + src/lib-mail/ostream-dot.c | 3 +-- + 1 file changed, 1 insertion(+), 2 deletions(-) + +Index: trixie/src/lib-mail/ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/ostream-dot.c ++++ trixie/src/lib-mail/ostream-dot.c +@@ -198,8 +198,7 @@ o_stream_dot_sendv(struct ostream_privat + if (max_bytes == 0) + break; + i_assert(p <= pend); +- chunk = ((size_t)(p-data) >= max_bytes ? +- max_bytes : (size_t)(p - data)); ++ chunk = I_MIN((size_t)(p - data), max_bytes); + if (chunk > 0) { + iovn.iov_base = data; + iovn.iov_len = chunk; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-ostream-dot-Optimize-stream-writing.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-ostream-dot-Optimize-stream-writing.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-ostream-dot-Optimize-stream-writing.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-mail-ostream-dot-Optimize-stream-writing.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,60 @@ +From e94a6c3cb984f9d1e5fe51714ea5015205d79fd1 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 26 Jan 2026 14:55:08 +0200 +Subject: [PATCH] lib-mail: ostream-dot - Optimize stream writing + +Use i_memcspn() to figure out how much we can skip. +--- + src/lib-mail/ostream-dot.c | 14 +++++++++++++- + 1 file changed, 13 insertions(+), 1 deletion(-) + +Index: trixie/src/lib-mail/ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/ostream-dot.c ++++ trixie/src/lib-mail/ostream-dot.c +@@ -108,9 +108,17 @@ o_stream_dot_sendv(struct ostream_privat + for (; p < pend && (size_t)(p-data)+2 < max_bytes; p++) { + char add = 0; + ++ size = pend - p; + switch (dstream->state) { + /* none */ +- case STREAM_STATE_NONE: ++ case STREAM_STATE_NONE: { ++ size_t maxlen = I_MIN(size, max_bytes - ((size_t)(p - data) + 2)); ++ p = CONST_PTR_OFFSET(p, i_memcspn(p, maxlen, "\r\n", 2)); ++ i_assert(p <= pend); ++ if (p == pend) { ++ p--; ++ continue; ++ } + switch (*p) { + case '\n': + dstream->state = STREAM_STATE_CRLF; +@@ -122,8 +130,10 @@ o_stream_dot_sendv(struct ostream_privat + break; + } + break; ++ } + /* got CR */ + case STREAM_STATE_CR: ++ i_assert(p < pend); + switch (*p) { + case '\r': + break; +@@ -138,6 +148,7 @@ o_stream_dot_sendv(struct ostream_privat + /* got CRLF, or the first line */ + case STREAM_STATE_INIT: + case STREAM_STATE_CRLF: ++ i_assert(p < pend); + switch (*p) { + case '\r': + dstream->state = STREAM_STATE_CR; +@@ -186,6 +197,7 @@ o_stream_dot_sendv(struct ostream_privat + + if (max_bytes == 0) + break; ++ i_assert(p <= pend); + chunk = ((size_t)(p-data) >= max_bytes ? + max_bytes : (size_t)(p - data)); + if (chunk > 0) { diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-managesieve-managesieve-parser-Fix-handling-of-l.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-managesieve-managesieve-parser-Fix-handling-of-l.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-managesieve-managesieve-parser-Fix-handling-of-l.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-managesieve-managesieve-parser-Fix-handling-of-l.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,30 @@ +From b949a172750084895a9b6e8317787a2229f6a8f0 Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Sat, 11 Apr 2026 14:12:08 +0200 +Subject: [PATCH 1/2] lib-managesieve: managesieve-parser - Fix handling of + lone CR character + +--- + src/lib-managesieve/managesieve-parser.c | 9 +++++++++ + 1 file changed, 9 insertions(+) + +Index: trixie/pigeonhole/src/lib-managesieve/managesieve-parser.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-managesieve/managesieve-parser.c ++++ trixie/pigeonhole/src/lib-managesieve/managesieve-parser.c +@@ -441,6 +441,15 @@ static bool managesieve_parser_read_arg( + + switch (data[0]) { + case '\r': ++ if (data_size == 1) { ++ /* Wait for LF */ ++ return FALSE; ++ } ++ if (data[1] != '\n') { ++ parser->error = "CR sent without LF"; ++ return FALSE; ++ } ++ /* Fall through */ + case '\n': + /* Unexpected end of line */ + parser->eol = TRUE; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-Add-per-user-sieve-rusage-file-for-cumulat.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-Add-per-user-sieve-rusage-file-for-cumulat.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-Add-per-user-sieve-rusage-file-for-cumulat.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-Add-per-user-sieve-rusage-file-for-cumulat.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,595 @@ +From da28fa5a900dc521a7f6cadf9320650dcad3cf18 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 5 May 2026 16:18:34 +0000 +Subject: [PATCH 1/7] lib-sieve: Add per-user sieve-rusage file for cumulative + CPU tracking + +Cumulative resource usage was previously stored in each compiled Sieve +binary's (.svbin) header. Because the binary file is keyed by script +name, a user could reset their CPU budget by uploading the same script +under a different name (PUTSCRIPT newname + SETACTIVE newname), +RENAMESCRIPT, or DELETESCRIPT followed by PUTSCRIPT under another +name. Each such operation produced a fresh .svbin with zero counter, +allowing the sieve_max_cpu_time limit to be bypassed indefinitely. + +Track cumulative CPU per user instead, in /sieve-rusage. +The file persists across all script renames, deletions, and re-uploads, +since it is not tied to any particular script name. Tracking is enabled +only for personal storages where the user's INBOX namespace has a +filesystem path; admin/default storages are unaffected here. + +The on-disk format is a single ASCII line: + + V1 + +CPU time is rounded up to whole seconds when persisted; sub-second +precision is preserved only for the in-memory accumulation within a +single execution. The file is updated by writing a new copy via +file_dotlock_open_mode() and replacing the original via +file_dotlock_replace(). Concurrent writers are serialized through the +dotlock; readers do not need a lock because the rename is atomic. + +The .svbin header still carries its original resource_usage struct as +the active fallback when no per-user file is available; a follow-up +commit drops that path. +--- + src/lib-sieve/Makefile.am | 2 + + src/lib-sieve/sieve-binary-file.c | 44 +++++ + src/lib-sieve/sieve-binary.c | 35 ++++ + src/lib-sieve/sieve-binary.h | 5 + + src/lib-sieve/sieve-rusage.c | 266 ++++++++++++++++++++++++++ + src/lib-sieve/sieve-rusage.h | 32 ++++ + src/lib-sieve/sieve-storage-private.h | 11 ++ + src/lib-sieve/sieve-storage.c | 2 + + src/lib-sieve/sieve.c | 5 + + 9 files changed, 402 insertions(+) + create mode 100644 src/lib-sieve/sieve-rusage.c + create mode 100644 src/lib-sieve/sieve-rusage.h + +Index: trixie/pigeonhole/src/lib-sieve/Makefile.am +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/Makefile.am ++++ trixie/pigeonhole/src/lib-sieve/Makefile.am +@@ -109,6 +109,7 @@ libdovecot_sieve_la_SOURCES = \ + sieve-ast.c \ + sieve-binary.c \ + sieve-binary-file.c \ ++ sieve-rusage.c \ + sieve-binary-code.c \ + sieve-binary-debug.c \ + sieve-parser.c \ +@@ -158,6 +159,7 @@ headers = \ + sieve-ast.h \ + sieve-binary.h \ + sieve-binary-private.h \ ++ sieve-rusage.h \ + sieve-parser.h \ + sieve-address.h \ + sieve-validator.h \ +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-file.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +@@ -18,6 +18,9 @@ + #include "sieve-extensions.h" + #include "sieve-code.h" + #include "sieve-script.h" ++#include "sieve-script-private.h" ++#include "sieve-storage-private.h" ++#include "sieve-rusage.h" + + #include "sieve-binary-private.h" + +@@ -1008,15 +1011,56 @@ sieve_binary_file_do_update_resource_usa + return ret; + } + ++static int ++sieve_binary_storage_update_resource_usage(struct sieve_binary *sbin, ++ struct sieve_storage *storage, ++ enum sieve_error *error_code_r) ++{ ++ struct sieve_resource_usage delta; ++ struct sieve_resource_usage total; ++ uint32_t flags; ++ ++ delta = sbin->rusage; ++ sieve_binary_get_resource_usage(sbin, &total); ++ flags = sbin->header.flags & SIEVE_BINARY_FLAG_RESOURCE_LIMIT; ++ ++ if (!HAS_ALL_BITS(flags, SIEVE_BINARY_FLAG_RESOURCE_LIMIT) && ++ !sieve_resource_usage_is_high(sbin->svinst, &total)) { ++ /* Nothing meaningful to persist */ ++ sieve_resource_usage_init(&sbin->rusage); ++ sbin->rusage_updated = FALSE; ++ return 0; ++ } ++ ++ if (sieve_rusage_storage_add(storage, &delta, flags) < 0) { ++ *error_code_r = SIEVE_ERROR_TEMP_FAILURE; ++ return -1; ++ } ++ ++ sieve_resource_usage_init(&sbin->rusage); ++ sbin->rusage_updated = FALSE; ++ return 0; ++} ++ + int sieve_binary_file_update_resource_usage(struct sieve_binary *sbin, + enum sieve_error *error_code_r) + { ++ struct sieve_storage *storage = NULL; + int fd, ret = 0; + + sieve_error_args_init(&error_code_r, NULL); + + sieve_binary_file_close(&sbin->file); + ++ if (sbin->script != NULL && sbin->script->storage != NULL && ++ sbin->script->storage->rusage_path != NULL) ++ storage = sbin->script->storage; ++ ++ if (storage != NULL) { ++ return sieve_binary_storage_update_resource_usage( ++ sbin, storage, error_code_r); ++ } ++ + if (sbin->path == NULL) + return 0; + if (sbin->header.version_major != SIEVE_BINARY_VERSION_MAJOR) { +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary.c +@@ -9,6 +9,7 @@ + #include "hash.h" + #include "array.h" + #include "ostream.h" ++#include "ioloop.h" + #include "eacces-error.h" + #include "safe-mkstemp.h" + +@@ -16,6 +17,9 @@ + #include "sieve-extensions.h" + #include "sieve-code.h" + #include "sieve-script.h" ++#include "sieve-script-private.h" ++#include "sieve-storage-private.h" ++#include "sieve-rusage.h" + + #include "sieve-binary-private.h" + +@@ -190,6 +194,37 @@ void sieve_binary_close(struct sieve_bin + * Resource usage + */ + ++static struct sieve_storage * ++sieve_binary_get_user_storage(struct sieve_binary *sbin) ++{ ++ if (sbin->script == NULL) ++ return NULL; ++ if (sbin->script->storage == NULL) ++ return NULL; ++ if (sbin->script->storage->rusage_path == NULL) ++ return NULL; ++ return sbin->script->storage; ++} ++ ++void sieve_binary_apply_persisted_rusage(struct sieve_binary *sbin) ++{ ++ struct sieve_storage *storage = sieve_binary_get_user_storage(sbin); ++ struct sieve_resource_usage rusage; ++ struct sieve_binary_header *header = &sbin->header; ++ uint32_t flags = 0; ++ ++ if (storage == NULL) ++ return; ++ if (sieve_rusage_storage_load(storage, &rusage, &flags) <= 0) ++ return; ++ ++ header->resource_usage.cpu_time_msecs = rusage.cpu_time_msecs; ++ header->resource_usage.update_time = ioloop_time; ++ header->flags |= (flags & SIEVE_BINARY_FLAG_RESOURCE_LIMIT); ++ sieve_resource_usage_init(&sbin->rusage); ++ sbin->rusage_updated = FALSE; ++} ++ + void sieve_binary_get_resource_usage(struct sieve_binary *sbin, + struct sieve_resource_usage *rusage_r) + { +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary.h +@@ -37,6 +37,11 @@ bool sieve_binary_record_resource_usage( + ATTR_NULL(1); + void sieve_binary_set_resource_usage(struct sieve_binary *sbin, + const struct sieve_resource_usage *rusage); ++ ++/* Apply persisted per-user rusage and limit flag from sieve-rusage file ++ into the binary header. No-op when the storage has no per-user file. */ ++void sieve_binary_apply_persisted_rusage(struct sieve_binary *sbin); ++ + /* + * Accessors + */ +Index: trixie/pigeonhole/src/lib-sieve/sieve-rusage.c +=================================================================== +--- /dev/null ++++ trixie/pigeonhole/src/lib-sieve/sieve-rusage.c +@@ -0,0 +1,266 @@ ++/* Copyright (c) 2026 Pigeonhole authors, see the included COPYING file ++ */ ++ ++#include "lib.h" ++#include "str.h" ++#include "strnum.h" ++#include "ioloop.h" ++#include "eacces-error.h" ++#include "file-dotlock.h" ++#include "mail-namespace.h" ++#include "mail-storage.h" ++#include "mailbox-list.h" ++ ++#include "sieve-common.h" ++#include "sieve-settings.h" ++#include "sieve-storage-private.h" ++#include "sieve-rusage.h" ++ ++#include ++#include ++#include ++#include ++ ++#define SIEVE_RUSAGE_FILENAME "sieve-rusage" ++#define SIEVE_RUSAGE_VERSION_TAG "V1" ++#define SIEVE_RUSAGE_LOCK_TIMEOUT 10 ++#define SIEVE_RUSAGE_LOCK_STALE_TIMEOUT 60 ++ ++/* Maximum size of the on-disk file. Single-line ASCII format with four ++ space-separated tokens; anything larger is treated as corrupt. */ ++#define SIEVE_RUSAGE_MAX_FILE_SIZE 128 ++ ++struct sieve_rusage_record { ++ uint32_t flags; ++ uint32_t cpu_time_msecs; ++ time_t update_time; ++}; ++ ++void sieve_rusage_storage_init(struct sieve_storage *storage, ++ struct mail_user *user) ++{ ++ struct mail_namespace *ns; ++ const char *root; ++ ++ if (!storage->is_personal || user == NULL) ++ return; ++ ++ ns = mail_namespace_find_inbox(user->namespaces); ++ if (ns == NULL) ++ return; ++ ++ if (!mailbox_list_get_root_path(ns->list, MAILBOX_LIST_PATH_TYPE_DIR, ++ &root) || root == NULL) ++ return; ++ ++ storage->user = user; ++ storage->inbox_list = ns->list; ++ storage->rusage_path = p_strconcat(storage->pool, root, "/", ++ SIEVE_RUSAGE_FILENAME, NULL); ++} ++ ++static bool ++sieve_rusage_parse(const char *line, struct sieve_rusage_record *rec) ++{ ++ const char *const *tokens; ++ uint32_t cpu_secs; ++ bool ok = FALSE; ++ ++ T_BEGIN { ++ tokens = t_strsplit_spaces(line, " \t"); ++ if (tokens[0] == NULL || ++ strcmp(tokens[0], SIEVE_RUSAGE_VERSION_TAG) != 0) ++ break; ++ if (tokens[1] == NULL || tokens[2] == NULL || ++ tokens[3] == NULL || tokens[4] != NULL) ++ break; ++ if (str_to_uint32(tokens[1], &rec->flags) < 0) ++ break; ++ if (str_to_uint32(tokens[2], &cpu_secs) < 0) ++ break; ++ if (str_to_time(tokens[3], &rec->update_time) < 0) ++ break; ++ if (cpu_secs > UINT32_MAX / 1000) ++ rec->cpu_time_msecs = UINT32_MAX; ++ else ++ rec->cpu_time_msecs = cpu_secs * 1000; ++ ok = TRUE; ++ } T_END; ++ ++ return ok; ++} ++ ++static int ++sieve_rusage_read(struct sieve_storage *storage, ++ struct sieve_rusage_record *rec_r) ++{ ++ char buf[SIEVE_RUSAGE_MAX_FILE_SIZE + 1]; ++ char *nl; ++ ssize_t ret; ++ int fd; ++ ++ i_zero(rec_r); ++ ++ fd = open(storage->rusage_path, O_RDONLY); ++ if (fd < 0) { ++ if (errno == ENOENT) ++ return 0; ++ e_error(storage->event, "open(%s) failed: %m", ++ storage->rusage_path); ++ return -1; ++ } ++ ++ ret = read(fd, buf, sizeof(buf) - 1); ++ i_close_fd(&fd); ++ if (ret < 0) { ++ e_error(storage->event, "read(%s) failed: %m", ++ storage->rusage_path); ++ return -1; ++ } ++ if (ret == 0) ++ return 0; ++ if (ret >= (ssize_t)(sizeof(buf) - 1)) { ++ e_warning(storage->event, "%s: file too large; resetting", ++ storage->rusage_path); ++ return 0; ++ } ++ buf[ret] = '\0'; ++ ++ nl = strchr(buf, '\n'); ++ if (nl != NULL) ++ *nl = '\0'; ++ ++ if (!sieve_rusage_parse(buf, rec_r)) { ++ e_warning(storage->event, "%s: malformed content; resetting", ++ storage->rusage_path); ++ i_zero(rec_r); ++ } ++ return 0; ++} ++ ++static void ++sieve_rusage_dotlock_settings(struct dotlock_settings *set_r) ++{ ++ i_zero(set_r); ++ set_r->timeout = SIEVE_RUSAGE_LOCK_TIMEOUT; ++ set_r->stale_timeout = SIEVE_RUSAGE_LOCK_STALE_TIMEOUT; ++ set_r->use_excl_lock = TRUE; ++} ++ ++int sieve_rusage_storage_load(struct sieve_storage *storage, ++ struct sieve_resource_usage *rusage_r, ++ uint32_t *flags_r) ++{ ++ struct sieve_rusage_record rec; ++ unsigned int timeout; ++ ++ sieve_resource_usage_init(rusage_r); ++ *flags_r = 0; ++ ++ if (storage->rusage_path == NULL) ++ return 0; ++ ++ if (sieve_rusage_read(storage, &rec) < 0) ++ return -1; ++ ++ timeout = storage->svinst->set->resource_usage_timeout; ++ if (rec.update_time != 0 && ++ (ioloop_time - rec.update_time) > (time_t)timeout) { ++ /* decayed */ ++ return 1; ++ } ++ ++ rusage_r->cpu_time_msecs = rec.cpu_time_msecs; ++ *flags_r = rec.flags; ++ return 1; ++} ++ ++int sieve_rusage_storage_add(struct sieve_storage *storage, ++ const struct sieve_resource_usage *delta_rusage, ++ uint32_t flags_to_set) ++{ ++ struct sieve_rusage_record rec; ++ struct dotlock_settings dlset; ++ struct dotlock *dotlock; ++ unsigned int timeout; ++ uint32_t old_cpu, cpu_secs; ++ string_t *out; ++ ssize_t wret; ++ int fd; ++ ++ if (storage->rusage_path == NULL) ++ return 0; ++ ++ sieve_rusage_dotlock_settings(&dlset); ++ ++ /* The namespace root may not exist yet on the very first update for ++ a user (e.g. LDA refused execution before any mail was delivered). ++ Create it on demand so file_dotlock_open does not fail with ENOENT. */ ++ if (storage->inbox_list != NULL && ++ mailbox_list_mkdir_root(storage->inbox_list, NULL, ++ MAILBOX_LIST_PATH_TYPE_DIR) < 0) ++ return -1; ++ ++ fd = file_dotlock_open_mode(&dlset, storage->rusage_path, 0, ++ 0600, (uid_t)-1, (gid_t)-1, &dotlock); ++ if (fd == -1) { ++ if (errno == EACCES) { ++ e_error(storage->event, "%s", ++ eacces_error_get_creating("file_dotlock_open", ++ storage->rusage_path)); ++ } else { ++ e_error(storage->event, ++ "file_dotlock_open(%s) failed: %m", ++ storage->rusage_path); ++ } ++ return -1; ++ } ++ ++ if (sieve_rusage_read(storage, &rec) < 0) { ++ file_dotlock_delete(&dotlock); ++ return -1; ++ } ++ ++ timeout = storage->svinst->set->resource_usage_timeout; ++ if (rec.update_time != 0 && ++ (ioloop_time - rec.update_time) > (time_t)timeout) { ++ /* decayed: discard previous values */ ++ rec.cpu_time_msecs = 0; ++ rec.flags = 0; ++ } ++ ++ old_cpu = rec.cpu_time_msecs; ++ if ((UINT32_MAX - old_cpu) < delta_rusage->cpu_time_msecs) ++ rec.cpu_time_msecs = UINT32_MAX; ++ else ++ rec.cpu_time_msecs = old_cpu + delta_rusage->cpu_time_msecs; ++ rec.flags |= flags_to_set; ++ rec.update_time = ioloop_time; ++ ++ /* Round CPU time up to whole seconds for persistence. */ ++ if (rec.cpu_time_msecs >= UINT32_MAX - 999) ++ cpu_secs = UINT32_MAX / 1000; ++ else ++ cpu_secs = (rec.cpu_time_msecs + 999) / 1000; ++ ++ out = t_str_new(64); ++ str_printfa(out, "%s %u %u %lld\n", SIEVE_RUSAGE_VERSION_TAG, ++ rec.flags, cpu_secs, (long long)rec.update_time); ++ ++ wret = write(fd, str_data(out), str_len(out)); ++ if (wret != (ssize_t)str_len(out)) { ++ e_error(storage->event, ++ "write(%s) failed: %m (wrote %zd/%zu)", ++ storage->rusage_path, wret, str_len(out)); ++ file_dotlock_delete(&dotlock); ++ return -1; ++ } ++ ++ if (file_dotlock_replace(&dotlock, 0) < 0) { ++ e_error(storage->event, ++ "file_dotlock_replace(%s) failed: %m", ++ storage->rusage_path); ++ return -1; ++ } ++ return 1; ++} +Index: trixie/pigeonhole/src/lib-sieve/sieve-rusage.h +=================================================================== +--- /dev/null ++++ trixie/pigeonhole/src/lib-sieve/sieve-rusage.h +@@ -0,0 +1,32 @@ ++#ifndef SIEVE_RUSAGE_H ++#define SIEVE_RUSAGE_H ++ ++#include "sieve-common.h" ++ ++struct sieve_storage; ++struct mail_user; ++ ++/* Per-user resource usage file lives at /sieve-rusage. ++ It survives script renames, deletions, and re-uploads under a different ++ name, preventing CPU-budget bypass. Tracking is enabled only for personal ++ user-controlled storage. */ ++ ++void sieve_rusage_storage_init(struct sieve_storage *storage, ++ struct mail_user *user); ++ ++/* Load persisted resource usage from the per-user file. Returns 1 on success, ++ 0 if the storage has no rusage file (no INBOX namespace path), -1 on error. ++ Applies the resource_usage_timeout decay. flags_r receives sieve binary ++ header flags (RESOURCE_LIMIT) that were persisted. */ ++int sieve_rusage_storage_load(struct sieve_storage *storage, ++ struct sieve_resource_usage *rusage_r, ++ uint32_t *flags_r); ++ ++/* Atomically add delta_rusage to and OR flags_to_set into the persisted ++ per-user rusage file. Read-modify-write under fcntl WRLCK. Returns 1 on ++ success, 0 if no rusage file is configured, -1 on error. */ ++int sieve_rusage_storage_add(struct sieve_storage *storage, ++ const struct sieve_resource_usage *delta_rusage, ++ uint32_t flags_to_set); ++ ++#endif +Index: trixie/pigeonhole/src/lib-sieve/sieve-storage-private.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-storage-private.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-storage-private.h +@@ -118,6 +118,17 @@ struct sieve_storage { + + struct mail_namespace *sync_inbox_ns; + ++ /* Per-user resource usage tracking. Set when storage is personal and ++ the user's INBOX namespace has a filesystem path. NULL otherwise. ++ inbox_list is kept so the namespace root directory can be created ++ on demand before the rusage file is updated; LDA may close the ++ sieve binary (and therefore persist rusage) before any mail has ++ been delivered, in which case the namespace root may not exist ++ yet. */ ++ struct mail_user *user; ++ struct mailbox_list *inbox_list; ++ const char *rusage_path; ++ + enum sieve_storage_flags flags; + + bool allows_synchronization:1; +Index: trixie/pigeonhole/src/lib-sieve/sieve-storage.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-storage.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-storage.c +@@ -17,6 +17,7 @@ + + #include "sieve-script-private.h" + #include "sieve-storage-private.h" ++#include "sieve-rusage.h" + + #include + #include +@@ -777,6 +778,7 @@ int sieve_storage_create_personal(struct + if (ret == 0) { + i_assert(storage->is_personal); + (void)sieve_storage_sync_init(storage, user); ++ sieve_rusage_storage_init(storage, user); + } else if (*error_code_r != SIEVE_ERROR_TEMP_FAILURE && + (flags & SIEVE_STORAGE_FLAG_SYNCHRONIZING) == 0 && + (flags & SIEVE_STORAGE_FLAG_READWRITE) == 0) { +Index: trixie/pigeonhole/src/lib-sieve/sieve.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve.c ++++ trixie/pigeonhole/src/lib-sieve/sieve.c +@@ -30,6 +30,7 @@ + #include "sieve-generator.h" + #include "sieve-interpreter.h" + #include "sieve-binary-dumper.h" ++#include "sieve-rusage.h" + + #include "sieve.h" + #include "sieve-common.h" +@@ -444,6 +445,8 @@ sieve_open_script_real(struct sieve_scri + + /* Try to open the matching binary */ + if (sieve_script_binary_load(script, &sbin, error_code_r) == 0) { ++ /* Per-user rusage file (if any) overrides binary header */ ++ sieve_binary_apply_persisted_rusage(sbin); + sieve_binary_get_resource_usage(sbin, &rusage); + + /* Ok, it exists; now let's see if it is up to date */ +@@ -474,6 +477,8 @@ sieve_open_script_real(struct sieve_scri + sieve_script_label(script)); + + sieve_binary_set_resource_usage(sbin, &rusage); ++ /* Per-user rusage file (if any) overrides any carry-forward */ ++ sieve_binary_apply_persisted_rusage(sbin); + } + + /* Check whether binary can be executed. */ diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-actions-Split-mailbox-creation-into-sieve_.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-actions-Split-mailbox-creation-into-sieve_.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-actions-Split-mailbox-creation-into-sieve_.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-actions-Split-mailbox-creation-into-sieve_.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,124 @@ +From 4010e318939b557b0cdf7da3ae0f320d408aea65 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 11 Jun 2026 19:31:45 +0000 +Subject: [PATCH 1/2] lib-sieve: actions - Split mailbox creation into + sieve_act_store_create_mailbox() + +Pure refactoring with no functional change: move the mailbox_create() / +subscribe / reopen sequence out of the "mailbox" extension's :create side +effect (seff_mailbox_create_pre_execute) into a new reusable +sieve_act_store_create_mailbox() helper. +--- + .../plugins/mailbox/tag-mailbox-create.c | 32 +--------------- + src/lib-sieve/sieve-actions.c | 38 +++++++++++++++++++ + src/lib-sieve/sieve-actions.h | 5 +++ + 3 files changed, 45 insertions(+), 30 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/plugins/mailbox/tag-mailbox-create.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/plugins/mailbox/tag-mailbox-create.c ++++ trixie/pigeonhole/src/lib-sieve/plugins/mailbox/tag-mailbox-create.c +@@ -150,36 +150,8 @@ seff_mailbox_create_pre_execute( + return SIEVE_EXEC_FAILURE; + } + +- trans->error = NULL; +- trans->error_code = MAIL_ERROR_NONE; +- +- /* Create mailbox */ +- if (mailbox_create(box, NULL, FALSE) < 0) { +- sieve_act_store_get_storage_error(aenv, trans); +- if (trans->error_code == MAIL_ERROR_EXISTS) { +- trans->error = NULL; +- trans->error_code = MAIL_ERROR_NONE; +- } else { +- return (trans->error_code == MAIL_ERROR_TEMP ? +- SIEVE_EXEC_TEMP_FAILURE : SIEVE_EXEC_FAILURE); +- } +- } +- +- /* Subscribe to it if necessary */ +- if (eenv->scriptenv->mailbox_autosubscribe) { +- (void)mailbox_list_set_subscribed( +- mailbox_get_namespace(box)->list, +- mailbox_get_name(box), TRUE); +- } +- +- /* Try opening again */ +- if (mailbox_open(box) < 0) { +- /* Failed definitively */ +- sieve_act_store_get_storage_error(aenv, trans); +- return (trans->error_code == MAIL_ERROR_TEMP ? +- SIEVE_EXEC_TEMP_FAILURE : SIEVE_EXEC_FAILURE); +- } +- return SIEVE_EXEC_OK; ++ /* Create the mailbox */ ++ return sieve_act_store_create_mailbox(aenv, trans); + } + + +Index: trixie/pigeonhole/src/lib-sieve/sieve-actions.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-actions.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-actions.c +@@ -372,6 +372,44 @@ void sieve_act_store_get_storage_error(c + &trans->error_code)); + } + ++int sieve_act_store_create_mailbox(const struct sieve_action_exec_env *aenv, ++ struct act_store_transaction *trans) ++{ ++ const struct sieve_execute_env *eenv = aenv->exec_env; ++ struct mailbox *box = trans->box; ++ ++ trans->error = NULL; ++ trans->error_code = MAIL_ERROR_NONE; ++ ++ /* Create mailbox */ ++ if (mailbox_create(box, NULL, FALSE) < 0) { ++ sieve_act_store_get_storage_error(aenv, trans); ++ if (trans->error_code == MAIL_ERROR_EXISTS) { ++ trans->error = NULL; ++ trans->error_code = MAIL_ERROR_NONE; ++ } else { ++ return (trans->error_code == MAIL_ERROR_TEMP ? ++ SIEVE_EXEC_TEMP_FAILURE : SIEVE_EXEC_FAILURE); ++ } ++ } ++ ++ /* Subscribe to it if necessary */ ++ if (eenv->scriptenv->mailbox_autosubscribe) { ++ (void)mailbox_list_set_subscribed( ++ mailbox_get_namespace(box)->list, ++ mailbox_get_name(box), TRUE); ++ } ++ ++ /* Try opening again */ ++ if (mailbox_open(box) < 0) { ++ /* Failed definitively */ ++ sieve_act_store_get_storage_error(aenv, trans); ++ return (trans->error_code == MAIL_ERROR_TEMP ? ++ SIEVE_EXEC_TEMP_FAILURE : SIEVE_EXEC_FAILURE); ++ } ++ return SIEVE_EXEC_OK; ++} ++ + static bool + act_store_mailbox_alloc(const struct sieve_action_exec_env *aenv, + const char *mailbox, struct mailbox **box_r, +Index: trixie/pigeonhole/src/lib-sieve/sieve-actions.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-actions.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-actions.h +@@ -246,6 +246,11 @@ void sieve_act_store_add_flags(const str + void sieve_act_store_get_storage_error(const struct sieve_action_exec_env *aenv, + struct act_store_transaction *trans); + ++/* Create the target mailbox of the store transaction (and subscribe to it ++ when configured). */ ++int sieve_act_store_create_mailbox(const struct sieve_action_exec_env *aenv, ++ struct act_store_transaction *trans); ++ + /* + * Redirect action + */ diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-binary-code-Fix-single-byte-oob-stac.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-binary-code-Fix-single-byte-oob-stac.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-binary-code-Fix-single-byte-oob-stac.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-binary-code-Fix-single-byte-oob-stac.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,41 @@ +From 3f0af33abcc9911b586be9b5adc0afb3e9f18d25 Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Fri, 10 Apr 2026 02:27:17 +0200 +Subject: [PATCH] lib-sieve: sieve-binary-code - Fix single byte oob stack + buffer write in sieve_binary_emit_integer() + +--- + src/lib-sieve/sieve-binary-code.c | 2 +- + tests/test-size.svtest | 9 +++++++++ + 2 files changed, 10 insertions(+), 1 deletion(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-code.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-code.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-code.c +@@ -124,7 +124,7 @@ sieve_size_t sieve_binary_emit_integer(s + sieve_number_t integer) + { + sieve_size_t address = _sieve_binary_block_get_size(sblock); +- uint8_t buffer[sizeof(sieve_number_t) + 1]; ++ uint8_t buffer[sizeof(sieve_number_t) * 8 / 7 + 1]; + int bufpos = sizeof(buffer) - 1; + + /* Encode last byte [0xxxxxxx]; msb == 0 marks the last byte */ +Index: trixie/pigeonhole/tests/test-size.svtest +=================================================================== +--- trixie.orig/pigeonhole/tests/test-size.svtest ++++ trixie/pigeonhole/tests/test-size.svtest +@@ -72,3 +72,12 @@ test "Exact size" { + } + } + ++/* ++ * TEST: Extreme size ++ */ ++ ++test "Extreme size" { ++ if size :over 18446744073709551615 { ++ test_fail "size :over matched extreme size"; ++ } ++} diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Add-is_personal-bit.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Add-is_personal-bit.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Add-is_personal-bit.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Add-is_personal-bit.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,72 @@ +From d82c2e624e4a9c364a6fa58fad0938ae5578660c Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Tue, 25 Nov 2025 05:07:16 +0100 +Subject: [PATCH] lib-sieve: sieve-storage - Add is_personal bit + +Simpler than evaluating the type and personal storage is created only through +sieve_storage_create_personal(). This mimics the bit for default storage. +--- + src/lib-sieve/sieve-storage-private.h | 1 + + src/lib-sieve/sieve-storage.c | 3 ++- + src/lib-sieve/storage/file/sieve-file-storage.c | 6 ++---- + 3 files changed, 5 insertions(+), 5 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-storage-private.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-storage-private.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-storage-private.h +@@ -121,6 +121,7 @@ struct sieve_storage { + enum sieve_storage_flags flags; + + bool allows_synchronization:1; ++ bool is_personal:1; + bool is_default:1; + }; + +Index: trixie/pigeonhole/src/lib-sieve/sieve-storage.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-storage.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-storage.c +@@ -773,6 +773,7 @@ int sieve_storage_create_personal(struct + &storage, error_code_r, NULL); + if (ret == 0) { + (void)sieve_storage_sync_init(storage, user); ++ storage->is_personal = TRUE; + } else if (*error_code_r != SIEVE_ERROR_TEMP_FAILURE && + (flags & SIEVE_STORAGE_FLAG_SYNCHRONIZING) == 0 && + (flags & SIEVE_STORAGE_FLAG_READWRITE) == 0) { +@@ -1017,7 +1018,7 @@ sieve_storage_get_default_script(struct + + if (*error_code_r != SIEVE_ERROR_NOT_FOUND || + (storage->flags & SIEVE_STORAGE_FLAG_SYNCHRONIZING) != 0 || +- !sieve_storage_is_personal(storage)) ++ !storage->is_personal) + return -1; + + /* Not found; if this name maps to the default script, +Index: trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.c ++++ trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.c +@@ -459,11 +459,9 @@ sieve_file_storage_init_from_settings( + + /* Stat storage directory */ + +- bool is_personal = sieve_storage_is_personal(storage); +- + if (storage_path != NULL && *storage_path != '\0') { + if (sieve_file_storage_stat(fstorage, storage_path) < 0) { +- if (!is_personal || ++ if (!storage->is_personal || + storage->error_code != SIEVE_ERROR_NOT_FOUND) + return -1; + if ((storage->flags & SIEVE_STORAGE_FLAG_READWRITE) == 0) { +@@ -511,7 +509,7 @@ sieve_file_storage_init_from_settings( + } + + if ((active_path == NULL || *active_path == '\0') && +- (is_personal || ++ (storage->is_personal || + (storage->flags & SIEVE_STORAGE_FLAG_READWRITE) != 0)) { + e_debug(storage->event, + "Active script path is unconfigured; " diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Fix-panic-caused-by-flawed-d.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Fix-panic-caused-by-flawed-d.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Fix-panic-caused-by-flawed-d.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-sieve-storage-Fix-panic-caused-by-flawed-d.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,53 @@ +From 4d0cbfe6260d567ebc34a747c29bb4e140124d93 Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Wed, 4 Mar 2026 21:53:36 +0100 +Subject: [PATCH] lib-sieve: sieve-storage - Fix panic caused by flawed + detection of personal storage type + +Recent commit d82c2e624e4a9c364a6fa58fad0938ae5578660c added the is_personal +flag, but it is assigned too late. Fix this issue by assigning this flag in the +earliest allocation function. + +This issue commonly surfaces when people forget to set an appropriate type for a +script storage definition, in which case the "personal" type is the default. + +Panic was: +Panic: file sieve-file-storage-active.c: line 33 +(sieve_file_storage_active_read_link): assertion failed: (fstorage->active_path != NULL) +--- + src/lib-sieve/sieve-storage.c | 7 +++++-- + 1 file changed, 5 insertions(+), 2 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-storage.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-storage.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-storage.c +@@ -232,6 +232,9 @@ sieve_storage_alloc_from_class(struct si + storage->pool, "auto:", storage->type, NULL); + } + ++ if (strcasecmp(script_type, SIEVE_STORAGE_TYPE_PERSONAL) == 0) ++ storage->is_personal = TRUE; ++ + storage->event = event; + event_ref(event); + +@@ -772,8 +775,8 @@ int sieve_storage_create_personal(struct + SIEVE_STORAGE_TYPE_PERSONAL, flags, + &storage, error_code_r, NULL); + if (ret == 0) { ++ i_assert(storage->is_personal); + (void)sieve_storage_sync_init(storage, user); +- storage->is_personal = TRUE; + } else if (*error_code_r != SIEVE_ERROR_TEMP_FAILURE && + (flags & SIEVE_STORAGE_FLAG_SYNCHRONIZING) == 0 && + (flags & SIEVE_STORAGE_FLAG_READWRITE) == 0) { +@@ -1894,7 +1897,7 @@ bool sieve_storage_is_default(const stru + + bool sieve_storage_is_personal(struct sieve_storage *storage) + { +- return (strcasecmp(storage->type, SIEVE_STORAGE_TYPE_PERSONAL) == 0); ++ return storage->is_personal; + } + + /* diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-storage-file-Refuse-symlinks-escaping-pers.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-storage-file-Refuse-symlinks-escaping-pers.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-storage-file-Refuse-symlinks-escaping-pers.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-storage-file-Refuse-symlinks-escaping-pers.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,267 @@ +From abfb0c1fe180f0edd5527c05cb7dd97840a31ea1 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 4 May 2026 13:09:37 +0000 +Subject: [PATCH 1/2] lib-sieve: storage: file - Refuse symlinks escaping + personal storage directory + +Pigeonhole's file storage followed any symlink encountered while resolving a +script path, including symlinks in personal (user-writable) storage whose +target lay outside the storage directory. In some non-recommended +configurations a user could exploit this through the include extension: +an "include :personal name;" lookup of ~/sieve/name.sieve transparently +followed a user-placed to e.g. another user's file readable by the mail +process, leaking its contents (or causing it to be parsed as Sieve). +Normally this shouldn't be possible, because sieve processes shouldn't +have any more privileges to read files than the local system user creating +the symlink. + +Open the canonical (realpath'd) personal storage directory at storage init +time and keep an O_DIRECTORY|O_CLOEXEC fd to it. Resolve script content +reads through this fd by routing sieve_file_script_get_stream() via a new +sieve_file_storage_open_safe() wrapper around t_openat_safe(), which: + + - opens each path component with O_NOFOLLOW so symlinks are detected + explicitly rather than transparently followed; + - follows symlinks only when their (recursively resolved) target stays + beneath dir_fd, refusing absolute targets and `..` past the storage + root with ELOOP; + - caps the symlink-hop count to bound resolution time. + +Anchoring at dir_fd makes the lookup TOCTOU-safe even when intermediate +path components are mutated on disk concurrently: resolution stays +relative to the original directory inode and the safe walker still rejects +any target that leaves it. + +Apply the protection only when storage->is_personal is set; admin-managed +global storage is trusted and may legitimately use cross-boundary symlinks. +Single-file storages (is_file=TRUE) keep dir_fd at -1 and fall back to the +existing open path. Also guard the dir_fd open with S_ISDIR() to handle the +autodetect quirk where storage_path can refer to a regular file even when +is_file is FALSE. +--- + .../storage/file/sieve-file-script.c | 39 ++++++++-- + .../storage/file/sieve-file-storage.c | 74 +++++++++++++++++++ + .../storage/file/sieve-file-storage.h | 32 ++++++++ + 3 files changed, 140 insertions(+), 5 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c ++++ trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c +@@ -441,15 +441,44 @@ sieve_file_script_get_stream(struct siev + { + struct sieve_file_script *fscript = + container_of(script, struct sieve_file_script, script); ++ struct sieve_file_storage *fstorage = ++ container_of(script->storage, struct sieve_file_storage, ++ storage); + struct stat st; + struct istream *result; ++ const char *error; + int fd; + +- fd = open(fscript->path, O_RDONLY); +- if (fd < 0) { +- sieve_file_script_handle_error(fscript, "open", fscript->path, +- fscript->script.name); +- return -1; ++ /* For directory-based storage, open the script via the storage ++ directory fd so that path resolution refuses to follow symlinks ++ whose (recursive) target leaves the storage directory. ++ Single-file storages have no dir_fd, so fall back to plain open(). */ ++ if (fstorage->dir_fd >= 0 && fscript->filename != NULL && ++ *fscript->filename != '\0') { ++ if (sieve_file_storage_open_safe(fstorage, fscript->filename, ++ O_RDONLY, &fd, &error) < 0) { ++ if (errno == ELOOP) { ++ sieve_script_set_critical( ++ script, ++ "Failed to open sieve script: %s", ++ error); ++ script->storage->error_code = ++ SIEVE_ERROR_NO_PERMISSION; ++ return -1; ++ } ++ sieve_file_script_handle_error(fscript, "open", ++ fscript->path, ++ fscript->script.name); ++ return -1; ++ } ++ } else { ++ fd = open(fscript->path, O_RDONLY); ++ if (fd < 0) { ++ sieve_file_script_handle_error(fscript, "open", ++ fscript->path, ++ fscript->script.name); ++ return -1; ++ } + } + + if (fstat(fd, &st) != 0) { +Index: trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.c ++++ trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.c +@@ -20,6 +20,7 @@ + #include + #include + #include ++#include + #include + #include + +@@ -42,6 +43,33 @@ sieve_file_storage_path_extend(struct si + return t_strconcat(path, "/", filename , NULL); + } + ++int sieve_file_storage_open_safe(struct sieve_file_storage *fstorage, ++ const char *path, int flags, int *fd_r, ++ const char **error_r) ++{ ++ const char *walk_error; ++ int fd; ++ ++ *fd_r = -1; ++ ++ if (fstorage->dir_fd < 0) { ++ *error_r = "storage directory fd not available"; ++ errno = ENOTSUP; ++ return -1; ++ } ++ ++ fd = t_openat_safe(fstorage->dir_fd, path, flags, &walk_error); ++ if (fd < 0) { ++ *error_r = t_strdup_printf( ++ "Failed to open '%s/%s': %s", ++ fstorage->path, path, walk_error); ++ return -1; ++ } ++ ++ *fd_r = fd; ++ return 0; ++} ++ + /* + * + */ +@@ -215,10 +243,19 @@ static struct sieve_storage *sieve_file_ + fstorage = p_new(pool, struct sieve_file_storage, 1); + fstorage->storage = sieve_file_storage; + fstorage->storage.pool = pool; ++ fstorage->dir_fd = -1; + + return &fstorage->storage; + } + ++static void sieve_file_storage_destroy(struct sieve_storage *storage) ++{ ++ struct sieve_file_storage *fstorage = ++ container_of(storage, struct sieve_file_storage, storage); ++ ++ i_close_fd(&fstorage->dir_fd); ++} ++ + static int + sieve_file_storage_get_full_path(struct sieve_file_storage *fstorage, + const char **storage_path) +@@ -436,6 +473,42 @@ sieve_file_storage_init_common(struct si + fstorage->link_path = + p_strdup(storage->pool, link_path); + } ++ ++ /* For personal (user-writable) storage, open a fd to the ++ canonical storage directory. This fd anchors TOCTOU-safe ++ path resolution for script lookups: the inode it refers to ++ cannot change underneath us, so even if the user mutates ++ intermediate path components on disk afterwards, we still ++ resolve relative to the original directory. The walker that ++ uses this fd refuses script paths that escape the storage ++ directory through symlinks or `..`, which would otherwise ++ let an unprivileged user redirect e.g. include "name" to a ++ file outside their own sieve storage. ++ ++ Non-personal (e.g. admin-configured global) storage is ++ trusted; dir_fd stays unset there so legitimate ++ admin-managed symlinks crossing the storage boundary keep ++ working. ++ ++ Only open the fd when storage_path actually resolves to a ++ directory: autodetect can leave is_file=FALSE while ++ storage_path still points at a regular file (see the ++ storage_path == active_path fallback in ++ sieve_file_storage_do_autodetect()). In that case there is ++ no directory to anchor to and lookups by name will fail ++ with ENOENT later anyway. */ ++ if (storage->is_personal && ++ S_ISDIR(fstorage->st.st_mode)) { ++ fstorage->dir_fd = open(storage_path, ++ O_RDONLY | O_DIRECTORY | ++ O_CLOEXEC); ++ if (fstorage->dir_fd < 0) { ++ sieve_storage_set_critical(storage, ++ "Failed to open storage directory: " ++ "open(%s) failed: %m", storage_path); ++ return -1; ++ } ++ } + } + + fstorage->path = p_strdup(storage->pool, storage_path); +@@ -904,6 +977,7 @@ const struct sieve_storage sieve_file_st + .v = { + .alloc = sieve_file_storage_alloc, + .init = sieve_file_storage_init, ++ .destroy = sieve_file_storage_destroy, + + .autodetect = sieve_file_storage_autodetect, + +Index: trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.h ++++ trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-storage.h +@@ -41,6 +41,13 @@ struct sieve_file_storage { + + time_t prev_mtime; + ++ /* fd referencing the canonical (realpath'd) storage directory. Used to ++ anchor TOCTOU-safe path resolution for script files: lookups that ++ escape this directory via symlinks (or `..`) are refused. -1 if the ++ storage is a single-file storage or this protection is unavailable. ++ */ ++ int dir_fd; ++ + bool is_file:1; + }; + +@@ -60,6 +67,31 @@ int sieve_file_storage_init_from_path(st + + int sieve_file_storage_pre_modify(struct sieve_storage *storage); + ++/* Open a script file that lives under the storage directory, refusing any ++ path resolution that escapes that directory through symlinks or `..`. ++ ++ The path is resolved component-by-component using openat() relative to ++ fstorage->dir_fd, with O_NOFOLLOW per component. Symlinks are followed ++ only if their (recursively resolved) target also stays beneath dir_fd; ++ absolute symlink targets and `..` past the storage root are refused. ++ ++ Anchoring the resolution at dir_fd makes the check TOCTOU-safe: even if ++ the user mutates path components on disk between calls, dir_fd still ++ refers to the original directory inode. ++ ++ `flags` is OR-ed into the openat() call for the final component (e.g. ++ O_RDONLY). O_NOFOLLOW and O_CLOEXEC are added automatically. ++ ++ Returns 0 on success and stores the new fd in *fd_r. Returns -1 on ++ failure with errno set; *error_r is set to a descriptive message. ++ errno=ELOOP indicates either too many symlinks or an attempted escape. ++ errno=ENOTSUP indicates fstorage->dir_fd is not available; the caller ++ may fall back to opening fstorage->path directly. ++ */ ++int sieve_file_storage_open_safe(struct sieve_file_storage *fstorage, ++ const char *path, int flags, int *fd_r, ++ const char **error_r); ++ + /* Active script */ + + int sieve_file_storage_active_replace_link(struct sieve_file_storage *fstorage, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-util-edit-mail-Fix-writing-to-freed-memory.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-util-edit-mail-Fix-writing-to-freed-memory.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-util-edit-mail-Fix-writing-to-freed-memory.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sieve-util-edit-mail-Fix-writing-to-freed-memory.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,128 @@ +From adcd0f9553c6d07142b97766fa0b53d56112c15c Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 14 Apr 2026 12:58:08 +0200 +Subject: [PATCH 1/3] lib-sieve: util: edit-mail - Fix writing to freed memory + +--- + Makefile.am | 1 + + src/lib-sieve/util/edit-mail.c | 12 ++- + .../editheader/deleteheader-snapshot.svtest | 76 +++++++++++++++++++ + 3 files changed, 88 insertions(+), 1 deletion(-) + create mode 100644 tests/extensions/editheader/deleteheader-snapshot.svtest + +Index: trixie/pigeonhole/Makefile.am +=================================================================== +--- trixie.orig/pigeonhole/Makefile.am ++++ trixie/pigeonhole/Makefile.am +@@ -164,6 +164,7 @@ test_cases = \ + tests/extensions/ihave/restrictions.svtest \ + tests/extensions/editheader/addheader.svtest \ + tests/extensions/editheader/deleteheader.svtest \ ++ tests/extensions/editheader/deleteheader-snapshot.svtest \ + tests/extensions/editheader/alternating.svtest \ + tests/extensions/editheader/utf8.svtest \ + tests/extensions/editheader/protected.svtest \ +Index: trixie/pigeonhole/src/lib-sieve/util/edit-mail.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/util/edit-mail.c ++++ trixie/pigeonhole/src/lib-sieve/util/edit-mail.c +@@ -890,7 +890,17 @@ static int edit_mail_headers_parse(struc + } + } + +- /* Rebuild header index */ ++ /* Rebuild header index. Reset first pointers before rebuilding so that ++ the actual first occurrence in the final list is used. Without this, ++ a snapshot's pre-set first pointer may refer to an appended field ++ that sits later in the list than a parsed field with the same header, ++ causing deleteheader to miss the parsed fields while still freeing ++ the header_index — leaving stale field_idx->header pointers. */ ++ struct _header_index *hidx = edmail->headers_head; ++ while (hidx != NULL) { ++ hidx->first = NULL; ++ hidx = hidx->next; ++ } + current = edmail->header_fields_head; + while (current != NULL) { + if (current->header->first == NULL) +Index: trixie/pigeonhole/tests/extensions/editheader/deleteheader-snapshot.svtest +=================================================================== +--- /dev/null ++++ trixie/pigeonhole/tests/extensions/editheader/deleteheader-snapshot.svtest +@@ -0,0 +1,76 @@ ++require "vnd.dovecot.testsuite"; ++require "editheader"; ++require "fileinto"; ++require "mailbox"; ++ ++/* ++ * Regression test: heap use-after-free in edit_mail_snapshot when ++ * deleteheader is called after a fileinto snapshot on an edit_mail ++ * whose headers have not yet been parsed. ++ */ ++ ++test_set "message" text: ++From: sender@example.com ++To: recipient@example.com ++Cc: original@example.com ++Subject: Test ++ ++Body. ++. ++; ++ ++test "deleteheader all: snapshot stale first pointer after parse" { ++ /* Add a Cc header while headers are not yet parsed. The snapshot ++ * created by the next fileinto has H_clone_cc->first pointing at ++ * this appended field. */ ++ addheader :last "Cc" "appended@example.com"; ++ ++ /* fileinto sets edit_snapshot=TRUE. The next call to ++ * sieve_message_edit creates a snapshot (edmail_snap) and the ++ * fileinto action retains the pre-snapshot version for delivery. */ ++ fileinto :create "folder1"; ++ ++ /* sieve_message_edit creates edmail_snap here. edit_mail_headers_parse ++ * inserts the parsed "Cc: original@example.com" before the appended ++ * field in the list. Without the fix the rebuild skips resetting ++ * ->first; the delete loop then only removes the appended field, ++ * frees H_clone_cc (index==0), and leaves the parsed field_idx with ++ * a dangling ->header pointer. */ ++ deleteheader "Cc"; ++ ++ /* A second fileinto + edit operation takes another snapshot of the ++ * same edmail_snap. edit_mail_snapshot walks header_fields_head and ++ * reads field_idx->header->header on the orphaned entry. ++ * Without the fix: use-after-free crash. ++ * With the fix: clean run, both Cc occurrences are correctly gone. */ ++ fileinto :create "folder2"; ++ deleteheader "Subject"; ++ ++ if not test_result_execute { ++ test_fail "failed to execute result"; ++ } ++ ++ /* folder1 was snapshotted before deleteheader "Cc": both Cc headers ++ * must be present. */ ++ if not test_message :folder "folder1" 0 { ++ test_fail "message not stored in folder1"; ++ } ++ ++ if not header :is "Cc" "original@example.com" { ++ test_fail "original Cc missing in folder1 snapshot"; ++ } ++ ++ if not header :is "Cc" "appended@example.com" { ++ test_fail "appended Cc missing in folder1 snapshot"; ++ } ++ ++ /* folder2 was snapshotted after deleteheader "Cc": both Cc headers ++ * must be absent. */ ++ if not test_message :folder "folder2" 0 { ++ test_fail "message not stored in folder2"; ++ } ++ ++ if exists "Cc" { ++ test_fail "Cc header not fully deleted in folder2 snapshot"; ++ } ++} diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Add-sql_result_new_error-helper.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Add-sql_result_new_error-helper.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Add-sql_result_new_error-helper.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Add-sql_result_new_error-helper.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,66 @@ +From 7aa8c147eb15009b56c85a00c33c2c0b972c1398 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 29 May 2026 11:21:00 +0000 +Subject: [PATCH] lib-sql: Add sql_result_new_error() helper + +--- + src/lib-sql/sql-api.c | 43 +++++++++++++++++++++++++++++++++++++++++++ + 1 file changed, 43 insertions(+) + +diff --git a/src/lib-sql/sql-api.c b/src/lib-sql/sql-api.c +index 3d4518c3cc..4db6913229 100644 +--- a/src/lib-sql/sql-api.c ++++ b/src/lib-sql/sql-api.c +@@ -66,6 +66,49 @@ ARRAY_TYPE(sql_drivers) sql_drivers; + static void sql_query_delayed_callback(struct sql_query_result_delayed *cb); + static void sql_commit_delayed_callback(struct sql_commit_result_delayed *cb); + ++struct sql_result_error { ++ struct sql_result result; ++ char *error; ++}; ++ ++static void sql_result_error_free(struct sql_result *_result) ++{ ++ struct sql_result_error *result = ++ container_of(_result, struct sql_result_error, result); ++ i_free(result->error); ++ i_free(result); ++} ++ ++static int sql_result_error_next_row(struct sql_result *result ATTR_UNUSED) ++{ ++ return -1; ++} ++ ++static const char * ++sql_result_error_get_error(struct sql_result *_result) ++{ ++ struct sql_result_error *result = ++ container_of(_result, struct sql_result_error, result); ++ return result->error; ++} ++ ++static const struct sql_result_vfuncs sql_result_error_vfuncs = { ++ .free = sql_result_error_free, ++ .next_row = sql_result_error_next_row, ++ .get_error = sql_result_error_get_error, ++}; ++ ++static struct sql_result *sql_result_new_error(const char *error) ATTR_UNUSED; ++static struct sql_result *sql_result_new_error(const char *error) ++{ ++ struct sql_result_error *result = i_new(struct sql_result_error, 1); ++ result->result.v = sql_result_error_vfuncs; ++ result->result.failed = TRUE; ++ result->result.refcount = 1; ++ result->error = i_strdup(error); ++ return &result->result; ++} ++ + void sql_drivers_init_without_drivers(void) + { + i_array_init(&sql_drivers, 8); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Use-strchr-based-while-loop-to-fill-template.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Use-strchr-based-while-loop-to-fill-template.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Use-strchr-based-while-loop-to-fill-template.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-Use-strchr-based-while-loop-to-fill-template.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,100 @@ +From 98282f1a02d29d6fdd0b3db63ae398d0162fe7bd Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 18 Aug 2025 08:30:42 +0300 +Subject: [PATCH] lib-sql: Use strchr() based while loop to fill template + +This is much faster than going one byte at a time. +--- + src/lib-sql/sql-api.c | 26 ++++++++++++++------------ + src/lib-sql/test-sql.c | 30 ++++++++++++++++++++++++++++++ + 2 files changed, 44 insertions(+), 12 deletions(-) + +Index: trixie/src/lib-sql/sql-api.c +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.c ++++ trixie/src/lib-sql/sql-api.c +@@ -400,22 +400,24 @@ const char *sql_statement_get_query(stru + { + string_t *query = t_str_new(128); + const char *const *args; +- unsigned int i, args_count, arg_pos = 0; ++ unsigned int args_count, arg_pos = 0; ++ const char *p0, *p1; + + args = array_get(&stmt->args, &args_count); +- +- for (i = 0; stmt->query_template[i] != '\0'; i++) { +- if (stmt->query_template[i] == '?') { +- if (arg_pos >= args_count || +- args[arg_pos] == NULL) { +- i_panic("lib-sql: Missing bind for arg #%u in statement: %s", +- arg_pos, stmt->query_template); +- } +- str_append(query, args[arg_pos++]); +- } else { +- str_append_c(query, stmt->query_template[i]); ++ p0 = stmt->query_template; ++ while ((p1 = strchr(p0, '?')) != NULL) { ++ /* append until ? */ ++ str_append_max(query, p0, (p1 - p0)); ++ if (arg_pos >= args_count || ++ args[arg_pos] == NULL) { ++ i_panic("lib-sql: Missing bind for arg #%u in statement: %s", ++ arg_pos, stmt->query_template); + } ++ str_append(query, args[arg_pos++]); ++ p0 = p1 + 1; + } ++ str_append(query, p0); ++ + if (arg_pos != args_count) { + i_panic("lib-sql: Too many bind args (%u) for statement: %s", + args_count, stmt->query_template); +Index: trixie/src/lib-sql/test-sql.c +=================================================================== +--- trixie.orig/src/lib-sql/test-sql.c ++++ trixie/src/lib-sql/test-sql.c +@@ -60,6 +60,24 @@ static void deinit_sql(struct sql_db **_ + }; \ + sql_driver_test_add_expected_result(sql, &result_1); + ++#define setup_result_2(sql) \ ++ struct test_driver_result_set rset_2 = { \ ++ .rows = 2, \ ++ .cols = 1, \ ++ .col_names = (const char *[]){"foo", NULL}, \ ++ .row_data = (const char **[]){ \ ++ (const char*[]){"value1", NULL}, \ ++ (const char*[]){"value2", NULL}, \ ++ }, \ ++ }; \ ++ struct test_driver_result result_2 = { \ ++ .nqueries = 1, \ ++ .queries = (const char *[]){"SELECT foo FROM bar WHERE baz = 'foz'"}, \ ++ .result = &rset_2 \ ++ }; \ ++ sql_driver_test_add_expected_result(sql, &result_2); ++ ++ + static void test_result_1(struct sql_result *cursor) + { + test_assert(sql_result_next_row(cursor) == SQL_RESULT_NEXT_OK); +@@ -125,6 +143,18 @@ static void test_sql_stmt_prepared_api(v + + test_result_1(cursor); + ++ sql_result_unref(cursor); ++ ++ setup_result_2(sql); ++ ++ prep_stmt = sql_prepared_statement_init(sql, "SELECT foo FROM bar WHERE baz = ?"); ++ stmt = sql_statement_init_prepared(prep_stmt); ++ sql_statement_bind_str(stmt, 0, "foz"); ++ sql_prepared_statement_unref(&prep_stmt); ++ cursor = sql_statement_query_s(&stmt); ++ ++ test_result_1(cursor); ++ + sql_result_unref(cursor); + + deinit_sql(&sql); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-driver-sqlite-Use-sqlite3_snprintf-to-quote-.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-driver-sqlite-Use-sqlite3_snprintf-to-quote-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-driver-sqlite-Use-sqlite3_snprintf-to-quote-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-driver-sqlite-Use-sqlite3_snprintf-to-quote-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,51 @@ +From ff926d0c9288f343d18a3c9ec20cf1e252d52286 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 7 Nov 2025 09:21:01 +0200 +Subject: [PATCH] lib-sql: driver-sqlite - Use sqlite3_snprintf() to quote + values + +This does it the sqlite3 way. +--- + src/lib-sql/driver-sqlite.c | 29 +++++------------------------ + 1 file changed, 5 insertions(+), 24 deletions(-) + +Index: trixie/src/lib-sql/driver-sqlite.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-sqlite.c ++++ trixie/src/lib-sql/driver-sqlite.c +@@ -225,30 +225,11 @@ static const char * + driver_sqlite_escape_string(struct sql_db *_db ATTR_UNUSED, + const char *string) + { +- const char *p; +- char *dest, *destbegin; +- +- /* find the first ' */ +- for (p = string; *p != '\''; p++) { +- if (*p == '\0') +- return t_strdup_noconst(string); +- } +- +- /* @UNSAFE: escape ' with '' */ +- dest = destbegin = t_buffer_get((p - string) + strlen(string) * 2 + 1); +- +- memcpy(dest, string, p - string); +- dest += p - string; +- +- for (; *p != '\0'; p++) { +- *dest++ = *p; +- if (*p == '\'') +- *dest++ = *p; +- } +- *dest++ = '\0'; +- t_buffer_alloc(dest - destbegin); +- +- return destbegin; ++ const size_t len = strlen(string) * 2 + 1; ++ char *escaped = t_malloc_no0(len); ++ if (sqlite3_snprintf(len, escaped, "%q", string) == NULL) ++ i_unreached(); ++ return escaped; + } + + static const char * diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-sql-api-Implement-async-calls-for-drivers-th.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-sql-api-Implement-async-calls-for-drivers-th.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-sql-api-Implement-async-calls-for-drivers-th.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-sql-sql-api-Implement-async-calls-for-drivers-th.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,247 @@ +From a78c015eae67a891a0c14b5434dd1f99838ae4cc Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 6 Oct 2025 12:39:50 +0300 +Subject: [PATCH] lib-sql: sql-api - Implement async calls for drivers that + can't + +Changes the behaviour of asynchronous functions to be truly asynchronous +even if the underlying driver isn't capable of doing this. Implemented +by adding immediate timeouts to call the callbacks after returning from the +synchronous function and ending up back to ioloop. +--- + src/lib-sql/sql-api-private.h | 7 +++ + src/lib-sql/sql-api.c | 103 ++++++++++++++++++++++++++++++++-- + src/lib-sql/sql-api.h | 4 +- + 3 files changed, 107 insertions(+), 7 deletions(-) + +Index: trixie/src/lib-sql/sql-api-private.h +=================================================================== +--- trixie.orig/src/lib-sql/sql-api-private.h ++++ trixie/src/lib-sql/sql-api-private.h +@@ -84,11 +84,15 @@ struct sql_db_vfuncs { + const char *(*escape_string)(struct sql_db *db, const char *string); + + void (*exec)(struct sql_db *db, const char *query); ++ /* Only implement this if the driver can really do asynchronous callbacks, ++ otherwise let sql API handle it. */ + void (*query)(struct sql_db *db, const char *query, + sql_query_callback_t *callback, void *context); + struct sql_result *(*query_s)(struct sql_db *db, const char *query); + + struct sql_transaction_context *(*transaction_begin)(struct sql_db *db); ++ /* Only implement this if the driver can really do asynchronous callbacks, ++ otherwise let sql API handle it. */ + void (*transaction_commit)(struct sql_transaction_context *ctx, + sql_commit_callback_t *callback, + void *context); +@@ -154,6 +158,9 @@ struct sql_db { + struct event *event; + HASH_TABLE(char *, struct sql_prepared_statement *) prepared_stmt_hash; + ++ struct sql_query_result_delayed *query_delayed_list; ++ struct sql_commit_result_delayed *commit_delayed_list; ++ + enum sql_db_state state; + /* last time we started connecting to this server + (which may or may not have succeeded) */ +Index: trixie/src/lib-sql/sql-api.c +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.c ++++ trixie/src/lib-sql/sql-api.c +@@ -4,6 +4,7 @@ + #include "array.h" + #include "ioloop.h" + #include "hash.h" ++#include "llist.h" + #include "str.h" + #include "time-util.h" + #include "settings.h" +@@ -12,6 +13,28 @@ + + #include + ++struct sql_query_result_delayed { ++ struct sql_query_result_delayed *prev, *next; ++ ++ struct sql_db *db; ++ struct timeout *to; ++ ++ struct sql_result *result; ++ sql_query_callback_t *callback; ++ void *context; ++}; ++ ++struct sql_commit_result_delayed { ++ struct sql_commit_result_delayed *prev, *next; ++ ++ struct sql_db *db; ++ struct timeout *to; ++ ++ char *error; ++ sql_commit_callback_t *callback; ++ void *context; ++}; ++ + struct event_category event_category_sql = { + .name = "sql", + }; +@@ -40,6 +63,9 @@ const struct setting_parser_info sql_set + struct sql_db_module_register sql_db_module_register = { 0 }; + ARRAY_TYPE(sql_drivers) sql_drivers; + ++static void sql_query_delayed_callback(struct sql_query_result_delayed *cb); ++static void sql_commit_delayed_callback(struct sql_commit_result_delayed *cb); ++ + void sql_drivers_init_without_drivers(void) + { + i_array_init(&sql_drivers, 8); +@@ -228,9 +254,19 @@ int sql_connect(struct sql_db *db) + return db->v.connect(db); + } + ++static void sql_call_delayed_callbacks(struct sql_db *db) ++{ ++ /* flush any pending results */ ++ while (db->query_delayed_list != NULL) ++ sql_query_delayed_callback(db->query_delayed_list); ++ while (db->commit_delayed_list != NULL) ++ sql_commit_delayed_callback(db->commit_delayed_list); ++} ++ + void sql_disconnect(struct sql_db *db) + { + timeout_remove(&db->to_reconnect); ++ sql_call_delayed_callbacks(db); + db->v.disconnect(db); + } + +@@ -250,11 +286,31 @@ void sql_exec(struct sql_db *db, const c + db->v.exec(db, query); + } + ++static void sql_query_delayed_callback(struct sql_query_result_delayed *cb) ++{ ++ timeout_remove(&cb->to); ++ DLLIST_REMOVE(&cb->db->query_delayed_list, cb); ++ cb->callback(cb->result, cb->context); ++ sql_result_unref(cb->result); ++ i_free(cb); ++} ++ + #undef sql_query + void sql_query(struct sql_db *db, const char *query, + sql_query_callback_t *callback, void *context) + { +- db->v.query(db, query, callback, context); ++ if (db->v.query != NULL) { ++ db->v.query(db, query, callback, context); ++ return; ++ } ++ ++ struct sql_query_result_delayed *cb = i_new(struct sql_query_result_delayed, 1); ++ cb->db = db; ++ cb->result = sql_query_s(db, query); ++ cb->callback = callback; ++ cb->context = context; ++ cb->to = timeout_add_short(0, sql_query_delayed_callback, cb); ++ DLLIST_PREPEND(&db->query_delayed_list, cb); + } + + struct sql_result *sql_query_s(struct sql_db *db, const char *query) +@@ -494,11 +550,21 @@ void sql_statement_query(struct sql_stat + sql_query_callback_t *callback, void *context) + { + struct sql_statement *stmt = *_stmt; +- + *_stmt = NULL; ++ + if (stmt->db->v.statement_query != NULL) + stmt->db->v.statement_query(stmt, callback, context); +- else ++ else if (stmt->db->v.statement_query_s != NULL) { ++ struct sql_db *db = stmt->db; ++ struct sql_query_result_delayed *cb = ++ i_new(struct sql_query_result_delayed, 1); ++ cb->db = db; ++ cb->callback = callback; ++ cb->context = context; ++ cb->result = sql_statement_query_s(&stmt); ++ cb->to = timeout_add_short(0, sql_query_delayed_callback, cb); ++ DLLIST_PREPEND(&db->query_delayed_list, cb); ++ } else + default_sql_statement_query(stmt, callback, context); + } + +@@ -755,14 +821,40 @@ void sql_transaction_set_non_atomic(stru + ctx->non_atomic = TRUE; + } + ++static void sql_commit_delayed_callback(struct sql_commit_result_delayed *cb) ++{ ++ struct sql_commit_result result = { ++ .error = cb->error, ++ }; ++ timeout_remove(&cb->to); ++ DLLIST_REMOVE(&cb->db->commit_delayed_list, cb); ++ cb->callback(&result, cb->context); ++ i_free(cb->error); ++ i_free(cb); ++} ++ + #undef sql_transaction_commit + void sql_transaction_commit(struct sql_transaction_context **_ctx, + sql_commit_callback_t *callback, void *context) + { + struct sql_transaction_context *ctx = *_ctx; +- ++ struct sql_db *db = ctx->db; + *_ctx = NULL; +- ctx->db->v.transaction_commit(ctx, callback, context); ++ ++ if (ctx->db->v.transaction_commit != NULL) { ++ ctx->db->v.transaction_commit(ctx, callback, context); ++ return; ++ } ++ ++ struct sql_commit_result_delayed *cb = i_new(struct sql_commit_result_delayed, 1); ++ const char *error = NULL; ++ ctx->db->v.transaction_commit_s(ctx, &error); ++ cb->db = db; ++ cb->error = i_strdup(error); ++ cb->callback = callback; ++ cb->context = context; ++ cb->to = timeout_add_short(0, sql_commit_delayed_callback, cb); ++ DLLIST_PREPEND(&db->commit_delayed_list, cb); + } + + int sql_transaction_commit_s(struct sql_transaction_context **_ctx, +@@ -900,6 +992,7 @@ void sql_wait(struct sql_db *db) + { + if (db->v.wait != NULL) + db->v.wait(db); ++ sql_call_delayed_callbacks(db); + } + + +Index: trixie/src/lib-sql/sql-api.h +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.h ++++ trixie/src/lib-sql/sql-api.h +@@ -123,7 +123,7 @@ const char *sql_escape_blob(struct sql_d + void sql_exec(struct sql_db *db, const char *query); + /* Execute SQL query and return result in callback. If fields list is given, + the returned fields are validated to be of correct type, and you can use +- sql_result_next_row_get() */ ++ sql_result_next_row_get(). The callback is never called immediately. */ + void sql_query(struct sql_db *db, const char *query, + sql_query_callback_t *callback, void *context); + #define sql_query(db, query, callback, context) \ +@@ -229,7 +229,7 @@ struct sql_transaction_context *sql_tran + /* Don't require transaction to be atomic. Currently this is implemented only + with Cassandra to use UNLOGGED BATCH operations. */ + void sql_transaction_set_non_atomic(struct sql_transaction_context *ctx); +-/* Commit transaction. */ ++/* Commit transaction. The callback is never called immediately. */ + void sql_transaction_commit(struct sql_transaction_context **ctx, + sql_commit_callback_t *callback, void *context); + #define sql_transaction_commit(ctx, callback, context) \ diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-Allow-NULL-path-in-mailbox_list_mkdir_ro.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-Allow-NULL-path-in-mailbox_list_mkdir_ro.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-Allow-NULL-path-in-mailbox_list_mkdir_ro.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-Allow-NULL-path-in-mailbox_list_mkdir_ro.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,44 @@ +From ca82388f330be27e015ad4b3c08b0156307cf369 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 23 Jun 2026 14:59:45 +0000 +Subject: [PATCH 1/2] lib-storage: Allow NULL path in mailbox_list_mkdir_root() + +When path is NULL, look up the root directory path of the given type via +mailbox_list_get_root_path() and create that. If there is no path of the +requested type, nothing is done. This avoids open-coding the common +get_root_path() + mkdir_root() pair at call sites. +--- + src/lib-storage/mailbox-list.c | 4 ++++ + src/lib-storage/mailbox-list.h | 4 +++- + 2 files changed, 7 insertions(+), 1 deletion(-) + +Index: trixie/src/lib-storage/mailbox-list.c +=================================================================== +--- trixie.orig/src/lib-storage/mailbox-list.c ++++ trixie/src/lib-storage/mailbox-list.c +@@ -1019,6 +1019,10 @@ int mailbox_list_mkdir_root(struct mailb + { + const char *error; + ++ if (path == NULL && ++ !mailbox_list_get_root_path(list, type, &path)) ++ return 0; ++ + if (mailbox_list_try_mkdir_root(list, path, type, &error) < 0) { + mailbox_list_set_critical(list, "%s", error); + return -1; +Index: trixie/src/lib-storage/mailbox-list.h +=================================================================== +--- trixie.orig/src/lib-storage/mailbox-list.h ++++ trixie/src/lib-storage/mailbox-list.h +@@ -177,7 +177,9 @@ void mailbox_list_get_permissions(struct + void mailbox_list_get_root_permissions(struct mailbox_list *list, + struct mailbox_permissions *permissions_r); + /* mkdir() a root directory of given type with proper permissions. The path can +- be either the root itself or point to a directory under the root. */ ++ be either the root itself or point to a directory under the root. If path is ++ NULL, the root path of the given type is looked up automatically; if there is ++ no such path, nothing is done. */ + int mailbox_list_mkdir_root(struct mailbox_list *list, const char *path, + enum mailbox_list_path_type type); + /* Like mailbox_list_mkdir_root(), but don't log an error if it fails. */ diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-thread-Avoid-excessive-data-stack-growth.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-thread-Avoid-excessive-data-stack-growth.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-thread-Avoid-excessive-data-stack-growth.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-storage-thread-Avoid-excessive-data-stack-growth.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,55 @@ +From 7774d50c731fa97e445fd9f4cb9e4f08e4415f8b Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 13 Apr 2026 17:28:43 +0200 +Subject: [PATCH 1/3] lib-storage: thread - Avoid excessive data stack growth + with many References msgids + +--- + src/lib-storage/index/index-thread.c | 22 +++++++++++++++------- + 1 file changed, 15 insertions(+), 7 deletions(-) + +diff --git a/src/lib-storage/index/index-thread.c b/src/lib-storage/index/index-thread.c +index 2b1ba045ac..e20a97072c 100644 +--- a/src/lib-storage/index/index-thread.c ++++ b/src/lib-storage/index/index-thread.c +@@ -93,9 +93,11 @@ mail_strmap_rec_get_msgid(struct mail_thread_context *ctx, + + /* get the nth message-id */ + msgid = message_id_get_next(&msgids); +- if (msgid != NULL) { +- for (; n > 0; n--) ++ if (msgid != NULL && n > 0) { ++ for (; n > 1; n--) T_BEGIN { + msgid = message_id_get_next(&msgids); ++ } T_END; ++ msgid = message_id_get_next(&msgids); + } + + if (msgid == NULL) { +@@ -266,12 +268,18 @@ mail_thread_map_add_mail(struct mail_thread_context *ctx, struct mail *mail) + msgid = message_id_get_next(&references); + if (msgid != NULL) { + ref_index = MAIL_THREAD_NODE_REF_REFERENCES1; ++ mail_index_strmap_view_sync_add(ctx->strmap_sync, ++ mail->uid, ref_index, msgid); + do { +- mail_index_strmap_view_sync_add(ctx->strmap_sync, +- mail->uid, +- ref_index, msgid); +- ref_index++; +- msgid = message_id_get_next(&references); ++ T_BEGIN { ++ ref_index++; ++ msgid = message_id_get_next(&references); ++ if (msgid != NULL) { ++ mail_index_strmap_view_sync_add( ++ ctx->strmap_sync, mail->uid, ++ ref_index, msgid); ++ } ++ } T_END; + } while (msgid != NULL); + } else { + /* no References:, use In-Reply-To: */ +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Add-test_assert_memcmp.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Add-test_assert_memcmp.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Add-test_assert_memcmp.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Add-test_assert_memcmp.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,104 @@ +From 4677492d038f5187363790b5a93bd5fccc989a98 Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Fri, 25 Jul 2025 03:31:44 +0200 +Subject: [PATCH] lib-test: Add test_assert_memcmp*() + +--- + src/lib-test/test-common.c | 33 +++++++++++++++++++++++++++++++++ + src/lib-test/test-common.h | 25 ++++++++++++++++++++++++- + 2 files changed, 57 insertions(+), 1 deletion(-) + +Index: trixie/src/lib-test/test-common.c +=================================================================== +--- trixie.orig/src/lib-test/test-common.c ++++ trixie/src/lib-test/test-common.c +@@ -84,6 +84,39 @@ void test_assert_failed_strcmp_idx(const + #endif + } + ++static void print_memcmp_data(const void *_data, size_t len) ++{ ++ const unsigned char *data = _data; ++ size_t i; ++ ++ for (i = 0; i < len; i++) { ++ if (data[i] >= 0x20 && data[i] < 0x7f) ++ printf("%c", data[i]); ++ else ++ printf("\\x%02X", data[i]); ++ } ++} ++ ++void test_assert_failed_memcmp_idx(const char *code, const char *file, unsigned int line, ++ const void *src, const void *dst, size_t len, long long i) ++{ ++ printf("%s:%u: Assert", file, line); ++ if (i == LLONG_MIN) ++ printf(" failed: %s\n", code); ++ else ++ printf("(#%lld) failed: %s\n", i, code); ++ printf(" \""); ++ print_memcmp_data(src, len); ++ printf("\" != \""); ++ print_memcmp_data(dst, len); ++ printf("\" (len == %zu)", len); ++ fflush(stdout); ++ test_success = FALSE; ++#ifdef STATIC_CHECKER ++ i_unreached(); ++#endif ++} ++ + void test_assert_failed_cmp_intmax_idx(const char *code, const char *file, + unsigned int line, + intmax_t src, intmax_t dst, +Index: trixie/src/lib-test/test-common.h +=================================================================== +--- trixie.orig/src/lib-test/test-common.h ++++ trixie/src/lib-test/test-common.h +@@ -40,7 +40,14 @@ void test_begin(const char *name); + test_assert_strcmp_idx(s1, s2, LLONG_MIN); \ + } STMT_END + +-/* Same as test_assert_strcmp expect that it takes an additional i as input. ++/* Additional parameters are m1 (source) and m2 (destination) memory and len ++ * in memcmp(). ++ */ ++#define test_assert_memcmp(m1, m2, len) STMT_START { \ ++ test_assert_memcmp_idx(m1, m2, len, LLONG_MIN); \ ++ } STMT_END ++ ++/* Same as test_assert_strcmp except that it takes an additional i as input. + * When i is greater than or equals 0 it is used to identify the barrage of + * tests failed like in test_assert_idx. + */ +@@ -52,6 +59,19 @@ void test_begin(const char *name); + __FILE__, __LINE__, _temp_s1, _temp_s2, i); \ + } STMT_END + ++/* Same as test_assert_memcmp except that it takes an additional i as input. ++ * When i is greater than or equals 0 it is used to identify the barrage of ++ * tests failed like in test_assert_idx. ++*/ ++#define test_assert_memcmp_idx(_m1, _m2, _len, i) STMT_START { \ ++ const void *_temp_m1 = (_m1); \ ++ const void *_temp_m2 = (_m2); \ ++ const size_t _temp_len = (_len); \ ++ if ((memcmp(_temp_m1,_temp_m2, _temp_len) != 0)) \ ++ test_assert_failed_memcmp_idx("memcmp(" #_m1 "," #_m2 "," #_len ")", \ ++ __FILE__, __LINE__, _temp_m1, _temp_m2, _temp_len, i); \ ++ } STMT_END ++ + #define test_assert_cmp_bool(_bool_value1, _op, _value2) \ + test_assert_cmp((unsigned int) _bool_value1, _op, (unsigned int _bool_value2)) + +@@ -92,6 +112,9 @@ void test_assert_failed_idx(const char * + void test_assert_failed_strcmp_idx(const char *code, const char *file, unsigned int line, + const char * src, const char * dst, long long i) + ATTR_STATIC_CHECKER_NORETURN; ++void test_assert_failed_memcmp_idx(const char *code, const char *file, unsigned int line, ++ const void *src, const void *dst, size_t len, long long i) ++ ATTR_STATIC_CHECKER_NORETURN; + void test_assert_failed_cmp_intmax_idx(const char *code, const char *file, + unsigned int line, + intmax_t src, intmax_t dst, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Require-both-lengths-in-test_assert_memcmp.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Require-both-lengths-in-test_assert_memcmp.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Require-both-lengths-in-test_assert_memcmp.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-test-Require-both-lengths-in-test_assert_memcmp.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,45 @@ +From 8f04979a7e72d3b3e04f3a93bd8088f5e2332dd9 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Tue, 27 Jan 2026 19:02:30 +0200 +Subject: [PATCH] lib-test: Require both lengths in test_assert_memcmp() + +--- + src/lib-sql/test-sql-sqlite.c | 2 +- + src/lib-test/test-common.h | 14 ++++++++------ + src/lib/test-unicode-nf.c | 16 ++++++++-------- + 3 files changed, 17 insertions(+), 15 deletions(-) + +Index: trixie/src/lib-test/test-common.h +=================================================================== +--- trixie.orig/src/lib-test/test-common.h ++++ trixie/src/lib-test/test-common.h +@@ -43,8 +43,8 @@ void test_begin(const char *name); + /* Additional parameters are m1 (source) and m2 (destination) memory and len + * in memcmp(). + */ +-#define test_assert_memcmp(m1, m2, len) STMT_START { \ +- test_assert_memcmp_idx(m1, m2, len, LLONG_MIN); \ ++#define test_assert_memcmp(m1, len1, m2, len2) STMT_START { \ ++ test_assert_memcmp_idx(m1, len1, m2, len2, LLONG_MIN); \ + } STMT_END + + /* Same as test_assert_strcmp except that it takes an additional i as input. +@@ -63,12 +63,14 @@ void test_begin(const char *name); + * When i is greater than or equals 0 it is used to identify the barrage of + * tests failed like in test_assert_idx. + */ +-#define test_assert_memcmp_idx(_m1, _m2, _len, i) STMT_START { \ ++#define test_assert_memcmp_idx(_m1, _len1, _m2, _len2, i) STMT_START { \ + const void *_temp_m1 = (_m1); \ + const void *_temp_m2 = (_m2); \ +- const size_t _temp_len = (_len); \ +- if ((memcmp(_temp_m1,_temp_m2, _temp_len) != 0)) \ +- test_assert_failed_memcmp_idx("memcmp(" #_m1 "," #_m2 "," #_len ")", \ ++ const size_t _temp_len = I_MIN((_len1), (_len2)); \ ++ if ((_len1) != (_len2)) \ ++ test_assert_failed_ucmp_intmax_idx(#_len1 " == " #_len2, __FILE__, __LINE__, _len1, _len2, "=", i); \ ++ if ((memcmp(_temp_m1, _temp_m2, _temp_len) != 0)) \ ++ test_assert_failed_memcmp_idx("memcmp(" #_m1 "," #_m2 "," #_len2 ")", \ + __FILE__, __LINE__, _temp_m1, _temp_m2, _temp_len, i); \ + } STMT_END + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-lib-xxh64-fix-byte-ordering-bug-on-big-endian-system.patch dovecot-2.4.1+dfsg1/debian/patches/0001-lib-xxh64-fix-byte-ordering-bug-on-big-endian-system.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-lib-xxh64-fix-byte-ordering-bug-on-big-endian-system.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-lib-xxh64-fix-byte-ordering-bug-on-big-endian-system.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,45 @@ +From 45406e13f1faddf341922ff73962820bc0a99015 Mon Sep 17 00:00:00 2001 +From: Noah Meyerhans +Date: Tue, 1 Sep 2026 21:12:51 -0400 +Subject: [PATCH] lib: xxh64: fix byte ordering bug on big-endian systems + +Convert from little-endian to host byte order where necessary. + +Background at https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1146449 +--- + src/lib/xxh64.c | 6 ++++-- + 1 file changed, 4 insertions(+), 2 deletions(-) + +diff --git a/src/lib/xxh64.c b/src/lib/xxh64.c +index 1c17833afd..4a151bd0c2 100644 +--- a/src/lib/xxh64.c ++++ b/src/lib/xxh64.c +@@ -8,6 +8,8 @@ + #include "lib.h" + #include "xxh64.h" + ++#include ++ + #define XXH64_PRIME1 UINT64_C(0x9E3779B185EBCA87) + #define XXH64_PRIME2 UINT64_C(0xC2B2AE3D27D4EB4F) + #define XXH64_PRIME3 UINT64_C(0x165667B19E3779F9) +@@ -20,14 +22,14 @@ static inline uint64_t xxh64_read64(const void *p) + { + uint64_t v; + memcpy(&v, p, sizeof(v)); +- return v; ++ return le64toh(v); /* Convert from little-endian to host byte order */ + } + + static inline uint32_t xxh64_read32(const void *p) + { + uint32_t v; + memcpy(&v, p, sizeof(v)); +- return v; ++ return le32toh(v); /* Convert from little-endian to host byte order */ + } + + static uint64_t ATTR_UNSIGNED_WRAPS xxh64_round(uint64_t acc, uint64_t input) +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-login-Add-LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED.patch dovecot-2.4.1+dfsg1/debian/patches/0001-login-Add-LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-login-Add-LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-login-Add-LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,99 @@ +From 3eed5e02c49c2dc1522c038869f723e7c6d93ffc Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 18 Jul 2025 14:28:47 +0300 +Subject: [PATCH] *-login: Add LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED + +--- + src/imap-login/imap-proxy.c | 1 + + src/login-common/client-common.c | 1 + + src/login-common/login-proxy.c | 2 ++ + src/login-common/login-proxy.h | 3 +++ + src/pop3-login/pop3-proxy.c | 1 + + src/submission-login/submission-proxy.c | 1 + + 6 files changed, 9 insertions(+) + +diff --git a/src/imap-login/imap-proxy.c b/src/imap-login/imap-proxy.c +index 69680ea752..03dad690e1 100644 +--- a/src/imap-login/imap-proxy.c ++++ b/src/imap-login/imap-proxy.c +@@ -586,6 +586,7 @@ imap_proxy_send_failure_reply(struct imap_client *imap_client, + break; + case LOGIN_PROXY_FAILURE_TYPE_REMOTE_CONFIG: + case LOGIN_PROXY_FAILURE_TYPE_INTERNAL_CONFIG: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED: + client_send_reply_code(&imap_client->common, IMAP_CMD_REPLY_NO, + IMAP_RESP_CODE_SERVERBUG, + LOGIN_PROXY_FAILURE_MSG); +diff --git a/src/login-common/client-common.c b/src/login-common/client-common.c +index 0c11a925f1..eeb9d6da05 100644 +--- a/src/login-common/client-common.c ++++ b/src/login-common/client-common.c +@@ -1386,6 +1386,7 @@ bool client_get_extra_disconnect_reason(struct client *client, + last_reason = "protocol failure"; + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED: + event_reason = "auth_failed"; + last_reason = "authentication failure"; + break; +diff --git a/src/login-common/login-proxy.c b/src/login-common/login-proxy.c +index 0e3e8c9be2..10c07b711d 100644 +--- a/src/login-common/login-proxy.c ++++ b/src/login-common/login-proxy.c +@@ -830,6 +830,7 @@ bool login_proxy_failed(struct login_proxy *proxy, struct event *event, + log_prefix = "Remote server sent invalid input: "; + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED: + case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: + log_prefix = ""; + try_reconnect = FALSE; +@@ -857,6 +858,7 @@ bool login_proxy_failed(struct login_proxy *proxy, struct event *event, + + if (type != LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED && + type != LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED && ++ type != LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED && + type != LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL) + e_error(event, "%s%s", log_prefix, reason); + else if (proxy->client->set->auth_verbose) +diff --git a/src/login-common/login-proxy.h b/src/login-common/login-proxy.h +index 65895f3d75..736c2eb586 100644 +--- a/src/login-common/login-proxy.h ++++ b/src/login-common/login-proxy.h +@@ -35,6 +35,9 @@ enum login_proxy_failure_type { + /* Authentication failed to backend. The LOGIN/AUTH command reply was + already sent to the client. */ + LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED, ++ /* Authentication failed to backend. The LOGIN/AUTH command reply was ++ NOT sent to the client yet. */ ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED, + /* Authentication failed with a temporary failure code. Attempting it + again might work. */ + LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL, +diff --git a/src/pop3-login/pop3-proxy.c b/src/pop3-login/pop3-proxy.c +index f8010d3d12..ad7786903b 100644 +--- a/src/pop3-login/pop3-proxy.c ++++ b/src/pop3-login/pop3-proxy.c +@@ -368,6 +368,7 @@ pop3_proxy_send_failure_reply(struct client *client, + break; + case LOGIN_PROXY_FAILURE_TYPE_INTERNAL_CONFIG: + case LOGIN_PROXY_FAILURE_TYPE_REMOTE_CONFIG: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED: + client_send_reply(client, POP3_CMD_REPLY_ERROR, + LOGIN_PROXY_FAILURE_MSG); + break; +diff --git a/src/submission-login/submission-proxy.c b/src/submission-login/submission-proxy.c +index 6f2c7f436a..f86845dcbc 100644 +--- a/src/submission-login/submission-proxy.c ++++ b/src/submission-login/submission-proxy.c +@@ -752,6 +752,7 @@ submission_proxy_send_failure_reply(struct submission_client *subm_client, + case LOGIN_PROXY_FAILURE_TYPE_REMOTE_CONFIG: + case LOGIN_PROXY_FAILURE_TYPE_PROTOCOL: + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REDIRECT: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED: + i_assert(cmd != NULL); + subm_client->pending_auth = NULL; + smtp_server_reply(cmd, 454, "4.7.0", LOGIN_PROXY_FAILURE_MSG); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-Fix-crash-when-XCLIENT-FORWARD-base64-c.patch dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-Fix-crash-when-XCLIENT-FORWARD-base64-c.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-Fix-crash-when-XCLIENT-FORWARD-base64-c.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-Fix-crash-when-XCLIENT-FORWARD-base64-c.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,42 @@ +From 7c874f2d6461e80ff8f7111fa36a7551747129f1 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sun, 3 May 2026 16:20:00 +0000 +Subject: [PATCH 1/2] login-common: Fix crash when XCLIENT FORWARD= base64 + contains NUL byte + +Sending "XCLIENT FORWARD=AA==" (base64 for a single NUL byte) followed +by a regular login crashed pop3-login (and other login services) with: + + Panic: file ../../src/lib/array.h: line 275 (array_idx_i): + assertion failed: (idx < array->buffer->used / array->element_size) + +p_strsplit_tabescaped() truncates at the first NUL, so an all-NUL +payload produced an empty fields list. forward_fields was still +created (but empty), and the later array_front() call in +sasl_server_auth_begin() then panicked on the empty array. + +Reject empty payloads and payloads containing NUL bytes during base64 +decode, so the array is never created in this case. +--- + src/login-common/client-common.c | 5 +++++ + 1 file changed, 5 insertions(+) + +diff --git a/src/login-common/client-common.c b/src/login-common/client-common.c +index eeb9d6da05..00e4f9abbf 100644 +--- a/src/login-common/client-common.c ++++ b/src/login-common/client-common.c +@@ -988,6 +988,11 @@ bool client_forward_decode_base64(struct client *client, const char *value) + string_t *str = t_str_new(MAX_BASE64_DECODED_SIZE(value_len)); + if (base64_decode(value, value_len, str) < 0) + return FALSE; ++ /* Embedded NUL would yield a created-but-empty array, panicking later ++ in array_front() during sasl_server_auth_begin(). */ ++ if (str_len(str) == 0 || ++ memchr(str_data(str), '\0', str_len(str)) != NULL) ++ return FALSE; + + char **_fields = p_strsplit_tabescaped(client->preproxy_pool, + str_c(str)); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-client-common-Add-client_disconnect-vfu.patch dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-client-common-Add-client_disconnect-vfu.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-client-common-Add-client_disconnect-vfu.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-client-common-Add-client_disconnect-vfu.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,36 @@ +From 275331da2aca21fb75483f226e72b0890d43ede4 Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Fri, 28 Nov 2025 03:32:36 +0100 +Subject: [PATCH 1/5] login-common: client-common - Add client_disconnect() + vfunc + +--- + src/login-common/client-common.c | 2 ++ + src/login-common/client-common.h | 1 + + 2 files changed, 3 insertions(+) + +Index: trixie/src/login-common/client-common.c +=================================================================== +--- trixie.orig/src/login-common/client-common.c ++++ trixie/src/login-common/client-common.c +@@ -433,6 +433,8 @@ void client_disconnect(struct client *cl + if (!client->login_success) { + bool unref = FALSE; + ++ if (client->v.disconnect != NULL) ++ client->v.disconnect(client, reason); + io_remove(&client->io); + ssl_iostream_destroy(&client->ssl_iostream); + if (client->iostream_fd_proxy != NULL) { +Index: trixie/src/login-common/client-common.h +=================================================================== +--- trixie.orig/src/login-common/client-common.h ++++ trixie/src/login-common/client-common.h +@@ -122,6 +122,7 @@ struct client_auth_reply { + struct client_vfuncs { + struct client *(*alloc)(pool_t pool); + int (*create)(struct client *client); ++ void (*disconnect)(struct client *client, const char *reason); + void (*destroy)(struct client *client); + int (*reload_config)(struct client *client, const char **error_r); + void (*notify_auth_ready)(struct client *client); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-login-Add-proxy_dest_connection_limit-e.patch dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-login-Add-proxy_dest_connection_limit-e.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-login-Add-proxy_dest_connection_limit-e.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-login-common-login-Add-proxy_dest_connection_limit-e.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,134 @@ +From db0835c84bf754a260ce0d3dca1d69eb90f87518 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Dec 2025 12:06:42 +0200 +Subject: [PATCH] login-common, *-login: Add proxy_dest_connection_limit + error_code to proxy_session_finished + +If IMAP backend returns with [LIMIT] or POP3 backend returns with [IN-USE], +use this error code rather than the generic proxy_dest_auth_failed. +Error messages are also updated. +--- + src/imap-login/imap-proxy.c | 9 +++++++++ + src/login-common/client-common.c | 4 ++++ + src/login-common/login-proxy.c | 2 ++ + src/login-common/login-proxy.h | 3 +++ + src/pop3-login/pop3-proxy.c | 3 +++ + src/submission-login/submission-proxy.c | 1 + + 6 files changed, 22 insertions(+) + +Index: dovecot/src/imap-login/imap-proxy.c +=================================================================== +--- dovecot.orig/src/imap-login/imap-proxy.c ++++ dovecot/src/imap-login/imap-proxy.c +@@ -284,6 +284,12 @@ static bool auth_resp_code_is_serverbug( + strlen(IMAP_RESP_CODE_SERVERBUG"]")) == 0; + } + ++static bool auth_resp_code_is_limit(const char *resp) ++{ ++ return strncasecmp(resp, IMAP_RESP_CODE_LIMIT"]", ++ strlen(IMAP_RESP_CODE_LIMIT"]")) == 0; ++} ++ + static bool + auth_resp_code_parse_referral(struct client *client, const char *resp, + const char **userhostport_r) +@@ -448,6 +454,8 @@ int imap_proxy_parse_line(struct client + else if (auth_resp_code_is_serverbug(line + 4)) + failure_type = LOGIN_PROXY_FAILURE_TYPE_REMOTE; + else { ++ if (auth_resp_code_is_limit(line + 4)) ++ failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED; + client_send_raw(client, t_strconcat( + imap_client->cmd_tag, " ", line, "\r\n", NULL)); + } +@@ -587,6 +595,7 @@ imap_proxy_send_failure_reply(struct ima + imap_client->cmd_tag, " NO ", reason, "\r\n", NULL)); + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: + /* reply was already sent */ + break; + } +Index: dovecot/src/login-common/client-common.c +=================================================================== +--- dovecot.orig/src/login-common/client-common.c ++++ dovecot/src/login-common/client-common.c +@@ -1397,6 +1397,10 @@ bool client_get_extra_disconnect_reason( + event_reason = "redirected"; + last_reason = "redirected"; + break; ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: ++ event_reason = "connection_limit"; ++ last_reason = "connection limit reached"; ++ break; + default: + i_unreached(); + } +Index: dovecot/src/login-common/login-proxy.c +=================================================================== +--- dovecot.orig/src/login-common/login-proxy.c ++++ dovecot/src/login-common/login-proxy.c +@@ -830,6 +830,7 @@ bool login_proxy_failed(struct login_pro + log_prefix = "Remote server sent invalid input: "; + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: + log_prefix = ""; + try_reconnect = FALSE; + break; +@@ -855,6 +856,7 @@ bool login_proxy_failed(struct login_pro + } + + if (type != LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED && ++ type != LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED && + type != LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL) + e_error(event, "%s%s", log_prefix, reason); + else if (proxy->client->set->auth_verbose) +Index: dovecot/src/login-common/login-proxy.h +=================================================================== +--- dovecot.orig/src/login-common/login-proxy.h ++++ dovecot/src/login-common/login-proxy.h +@@ -41,6 +41,9 @@ enum login_proxy_failure_type { + /* Authentication requests connecting to another host. The reason + string contains the host (and optionally :port). */ + LOGIN_PROXY_FAILURE_TYPE_AUTH_REDIRECT, ++ /* Authentication failed because user has reached some limit. ++ The LOGIN/AUTH command reply was already sent to the client. */ ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED, + }; + + struct login_proxy_settings { +Index: dovecot/src/pop3-login/pop3-proxy.c +=================================================================== +--- dovecot.orig/src/pop3-login/pop3-proxy.c ++++ dovecot/src/pop3-login/pop3-proxy.c +@@ -333,6 +333,8 @@ int pop3_proxy_parse_line(struct client + } else if (pop3_proxy_parse_referral(client, line + 5, &line)) { + failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_REDIRECT; + } else { ++ if (str_begins_with(line, "-ERR [IN-USE]")) ++ failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED; + client_send_raw(client, t_strconcat(line, "\r\n", NULL)); + line += 5; + } +@@ -374,6 +376,7 @@ pop3_proxy_send_failure_reply(struct cli + client_send_reply(client, POP3_CMD_REPLY_ERROR, reason); + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: + /* reply was already sent */ + break; + } +Index: dovecot/src/submission-login/submission-proxy.c +=================================================================== +--- dovecot.orig/src/submission-login/submission-proxy.c ++++ dovecot/src/submission-login/submission-proxy.c +@@ -750,6 +750,7 @@ submission_proxy_send_failure_reply(stru + smtp_server_reply_submit(subm_client->proxy_reply); + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: + /* reply was already sent */ + i_assert(cmd == NULL); + break; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Adjust-to-core-s-login_proxy_failu.patch dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Adjust-to-core-s-login_proxy_failu.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Adjust-to-core-s-login_proxy_failu.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Adjust-to-core-s-login_proxy_failu.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,30 @@ +Index: dovecot/pigeonhole/src/managesieve-login/managesieve-proxy.c +=================================================================== +--- dovecot.orig/pigeonhole/src/managesieve-login/managesieve-proxy.c ++++ dovecot/pigeonhole/src/managesieve-login/managesieve-proxy.c +@@ -658,7 +658,7 @@ int managesieve_proxy_parse_line(struct + &reason)) + failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_REDIRECT; + else { +- failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH; ++ failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED; + client_send_no(client, AUTH_FAILED_MSG); + } + +@@ -702,6 +702,7 @@ managesieve_proxy_send_failure_reply(str + break; + case LOGIN_PROXY_FAILURE_TYPE_INTERNAL_CONFIG: + case LOGIN_PROXY_FAILURE_TYPE_REMOTE_CONFIG: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED: + client_send_reply_code(client, MANAGESIEVE_CMD_REPLY_NO, + NULL, LOGIN_PROXY_FAILURE_MSG); + break; +@@ -709,7 +710,7 @@ managesieve_proxy_send_failure_reply(str + client_send_reply_code(client, MANAGESIEVE_CMD_REPLY_NO, + "TRYLATER", reason); + break; +- case LOGIN_PROXY_FAILURE_TYPE_AUTH: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: + /* reply was already sent */ + break; + } diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Support-LOGIN_PROXY_FAILURE_TYPE_A.patch dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Support-LOGIN_PROXY_FAILURE_TYPE_A.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Support-LOGIN_PROXY_FAILURE_TYPE_A.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-managesieve-login-Support-LOGIN_PROXY_FAILURE_TYPE_A.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,12 @@ +Index: dovecot/pigeonhole/src/managesieve-login/managesieve-proxy.c +=================================================================== +--- dovecot.orig/pigeonhole/src/managesieve-login/managesieve-proxy.c ++++ dovecot/pigeonhole/src/managesieve-login/managesieve-proxy.c +@@ -711,6 +711,7 @@ managesieve_proxy_send_failure_reply(str + "TRYLATER", reason); + break; + case LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED: ++ case LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED: + /* reply was already sent */ + break; + } diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0001-submission-Fix-settings-leak-on-error-paths-in-clien.patch dovecot-2.4.1+dfsg1/debian/patches/0001-submission-Fix-settings-leak-on-error-paths-in-clien.patch --- dovecot-2.4.1+dfsg1/debian/patches/0001-submission-Fix-settings-leak-on-error-paths-in-clien.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0001-submission-Fix-settings-leak-on-error-paths-in-clien.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,33 @@ +From 53abb9f06e9d81ea41e30c6552d0ff0047e41e9d Mon Sep 17 00:00:00 2001 +From: Markus Valentin +Date: Thu, 23 Apr 2026 12:53:07 +0200 +Subject: [PATCH 1/2] submission: Fix settings leak on error paths in + client_create_from_input() + +--- + src/submission/main.c | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/src/submission/main.c b/src/submission/main.c +index 00e6642510..3b110f4f4a 100644 +--- a/src/submission/main.c ++++ b/src/submission/main.c +@@ -208,6 +208,7 @@ client_create_from_input(const struct mail_storage_service_input *input, + "(submission_relay_host is unset)"; + send_error(fd_out, event, set->hostname, + "4.3.5", MAIL_ERRSTR_CRITICAL_MSG); ++ settings_free(set); + mail_user_deinit(&mail_user); + event_unref(&event); + return -1; +@@ -216,6 +217,7 @@ client_create_from_input(const struct mail_storage_service_input *input, + if (ret < 0) { + send_error(fd_out, event, my_hostname, + "4.7.0", MAIL_ERRSTR_CRITICAL_MSG); ++ settings_free(set); + mail_user_deinit(&mail_user); + event_unref(&event); + return -1; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-auth-Fix-prefixing-forward_fields-without-a-value-fr.patch dovecot-2.4.1+dfsg1/debian/patches/0002-auth-Fix-prefixing-forward_fields-without-a-value-fr.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-auth-Fix-prefixing-forward_fields-without-a-value-fr.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-auth-Fix-prefixing-forward_fields-without-a-value-fr.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,30 @@ +From ed04e392f97a523dd7bc75161af280df6cf3ce6f Mon Sep 17 00:00:00 2001 +From: Markus Valentin +Date: Thu, 23 Apr 2026 13:07:08 +0200 +Subject: [PATCH 2/2] auth: Fix prefixing forward_fields without a value from + client + +Bare tokens (without '=') were not prefixed, only key=value pairs were. +In practice this affected forward_fields, where a bare token such as +'nopassword' would land in extra_fields unprefixed instead of as +'forward_nopassword', allowing injection of internal auth control fields. +--- + src/auth/auth-fields.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/auth/auth-fields.c b/src/auth/auth-fields.c +index 8b92836588..6e1ccbbcab 100644 +--- a/src/auth/auth-fields.c ++++ b/src/auth/auth-fields.c +@@ -125,7 +125,7 @@ static void auth_fields_import_prefixed_args(struct auth_fields *fields, + for (; *args != NULL; args++) { + value = strchr(*args, '='); + if (value == NULL) { +- key = *args; ++ key = *prefix != '\0' ? t_strconcat(prefix, *args, NULL) : *args; + } else { + key = t_strdup_until(*args, value++); + if (*prefix != '\0') +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-auth-db-oauth2-Fix-scope-check-to-require-all-config.patch dovecot-2.4.1+dfsg1/debian/patches/0002-auth-db-oauth2-Fix-scope-check-to-require-all-config.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-auth-db-oauth2-Fix-scope-check-to-require-all-config.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-auth-db-oauth2-Fix-scope-check-to-require-all-config.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,49 @@ +From 5b894458088293ffaef32d028af81a70506b7f20 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Wed, 3 Jun 2026 12:56:20 +0000 +Subject: [PATCH 2/4] auth: db-oauth2: Fix scope check to require all + configured scopes + +Switch token-in-scope check to AND semantics: all configured scopes +must be present in the token. Behaviour now matches the JWT path. +--- + src/auth/db-oauth2.c | 17 ++++++++++------- + 1 file changed, 10 insertions(+), 7 deletions(-) + +Index: trixie/src/auth/db-oauth2.c +=================================================================== +--- trixie.orig/src/auth/db-oauth2.c ++++ trixie/src/auth/db-oauth2.c +@@ -556,22 +556,25 @@ db_oauth2_token_in_scope(struct db_oauth + if (array_is_empty(&req->db->set->scope)) + return TRUE; + +- bool found = FALSE; + const char *value = auth_fields_find(req->fields, "scope"); + bool has_scope = value != NULL; + if (!has_scope) + value = auth_fields_find(req->fields, "aud"); + e_debug(authdb_event(req->auth_request), +- "Token scope(s): %s", +- value); +- if (value != NULL) { +- const char *wanted_scope; ++ "Token scope(s): %s", value); ++ ++ bool found = FALSE; ++ if (value != NULL && *value != '\0') { + const char *const *entries = has_scope ? + t_strsplit_spaces(value, " ") : + t_strsplit_tabescaped(value); +- array_foreach_elem(&req->db->set->scope, wanted_scope) { +- if ((found = str_array_find(entries, wanted_scope))) ++ const char *wanted; ++ found = TRUE; ++ array_foreach_elem(&req->db->set->scope, wanted) { ++ if (!str_array_find(entries, wanted)) { ++ found = FALSE; + break; ++ } + } + } + if (!found) { diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-Avoid-leaking-doveadm_password-or-doveadm_ap.patch dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-Avoid-leaking-doveadm_password-or-doveadm_ap.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-Avoid-leaking-doveadm_password-or-doveadm_ap.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-Avoid-leaking-doveadm_password-or-doveadm_ap.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,49 @@ +From 3c847240b1c0afc52ee570f45409f094c546a3a0 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 28 Apr 2026 01:51:01 +0300 +Subject: [PATCH 2/2] doveadm: Avoid leaking doveadm_password or + doveadm_api_key lengths + +--- + src/doveadm/client-connection-http.c | 4 ++-- + src/doveadm/client-connection-tcp.c | 2 +- + 2 files changed, 3 insertions(+), 3 deletions(-) + +diff --git a/src/doveadm/client-connection-http.c b/src/doveadm/client-connection-http.c +index dfbc3528d7..f083ac6e14 100644 +--- a/src/doveadm/client-connection-http.c ++++ b/src/doveadm/client-connection-http.c +@@ -973,7 +973,7 @@ doveadm_http_server_auth_basic(struct client_request_http *req, + b64_value = t_base64_encode_str(0, UINT_MAX, value); + + if (creds->data != NULL && +- str_equals_timing_almost_safe(str_c(b64_value), creds->data)) ++ str_equals_hash_timing_safe(str_c(b64_value), creds->data)) + return TRUE; + + e_error(conn->conn.event, +@@ -999,7 +999,7 @@ doveadm_http_server_auth_api_key(struct client_request_http *req, + + b64_value = t_base64_encode_str(0, UINT_MAX, set->doveadm_api_key); + if (creds->data != NULL && +- str_equals_timing_almost_safe(creds->data, str_c(b64_value))) ++ str_equals_hash_timing_safe(creds->data, str_c(b64_value))) + return TRUE; + + e_error(conn->conn.event, +diff --git a/src/doveadm/client-connection-tcp.c b/src/doveadm/client-connection-tcp.c +index cb16f7214b..b374028c97 100644 +--- a/src/doveadm/client-connection-tcp.c ++++ b/src/doveadm/client-connection-tcp.c +@@ -400,7 +400,7 @@ client_connection_tcp_authenticate(struct client_connection_tcp *conn) + return -1; + } + pass = t_strndup(data + 9, size - 9); +- if (!str_equals_timing_almost_safe(pass, set->doveadm_password)) { ++ if (!str_equals_hash_timing_safe(pass, set->doveadm_password)) { + e_error(conn->conn.event, + "doveadm client authenticated with wrong password"); + return -1; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-compress-connect-Don-t-hang-when-server-reje.patch dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-compress-connect-Don-t-hang-when-server-reje.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-compress-connect-Don-t-hang-when-server-reje.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-doveadm-compress-connect-Don-t-hang-when-server-reje.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,120 @@ +From 4a731e5de4191134e27e14c62a42ae7201d26e26 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 6 Jul 2026 07:17:42 +0000 +Subject: [PATCH 2/3] doveadm: compress-connect - Don't hang when server + rejects COMPRESS + +After sending a COMPRESS command the client stopped reading stdin and +waited for the server's reply, but it only resumed on an "OK Begin +compression" reply. If the server rejected COMPRESS (e.g. a NO reply for +an unsupported mechanism), compress_waiting was never cleared: the client +waited forever for an OK that never came while the server waited for the +next command, deadlocking the session. + +Remember the COMPRESS command tag and treat any tagged reply to it that +isn't the compression-start reply as a rejection, resuming the pipelined +input. +--- + src/doveadm/doveadm-compress.c | 34 +++++++++++++++++++++++++++++++++- + 1 file changed, 33 insertions(+), 1 deletion(-) + +Index: trixie/src/doveadm/doveadm-compress.c +=================================================================== +--- trixie.orig/src/doveadm/doveadm-compress.c ++++ trixie/src/doveadm/doveadm-compress.c +@@ -20,6 +20,7 @@ enum server_input_line_type { + SERVER_INPUT_LINE_TYPE_DEFAULT, + SERVER_INPUT_LINE_TYPE_STARTTLS, + SERVER_INPUT_LINE_TYPE_COMPRESS, ++ SERVER_INPUT_LINE_TYPE_COMPRESS_FAILED, + }; + + struct client { +@@ -32,6 +33,7 @@ struct client { + struct ssl_iostream *ssl_iostream; + const struct compression_handler *handler; + char *algorithm; ++ char *compress_tag; + bool tls; + bool compressed; + bool compress_waiting; +@@ -111,17 +113,23 @@ cmd_dump_imap_compress(struct doveadm_cm + static bool + client_input_get_compress_algorithm(struct client *client, const char *line) + { +- const char *algorithm; ++ const char *algorithm, *tag = line; ++ size_t tag_len; + + /* skip tag */ + while (*line != ' ' && *line != '\0') + line++; ++ tag_len = line - tag; + if (!str_begins_icase(line, " COMPRESS ", &algorithm)) + return FALSE; + + if (compression_lookup_handler(t_str_lcase(algorithm), + &client->handler) <= 0) + i_fatal("Unsupported compression mechanism: %s", algorithm); ++ /* Remember the tag so we can tell whether the server accepted or ++ rejected this COMPRESS command. */ ++ i_free(client->compress_tag); ++ client->compress_tag = i_strndup(tag, tag_len); + return TRUE; + } + +@@ -190,6 +198,13 @@ static bool server_input_is_compress_rep + return str_begins_with(line, " OK Begin compression"); + } + ++static bool server_input_is_reply_to_tag(const char *line, const char *tag) ++{ ++ size_t tag_len = strlen(tag); ++ ++ return strncmp(line, tag, tag_len) == 0 && line[tag_len] == ' '; ++} ++ + static enum server_input_line_type + server_input_line_type(struct client *client, const char *line) + { +@@ -197,6 +212,13 @@ server_input_line_type(struct client *cl + return SERVER_INPUT_LINE_TYPE_STARTTLS; + if (!client->compressed && server_input_is_compress_reply(line)) + return SERVER_INPUT_LINE_TYPE_COMPRESS; ++ /* The server can reject COMPRESS (e.g. an unsupported mechanism). If we ++ are waiting for its reply, a tagged reply that isn't the "OK Begin ++ compression" above means the command failed - resume sending the ++ pipelined input instead of waiting forever. */ ++ if (client->compress_waiting && client->compress_tag != NULL && ++ server_input_is_reply_to_tag(line, client->compress_tag)) ++ return SERVER_INPUT_LINE_TYPE_COMPRESS_FAILED; + return SERVER_INPUT_LINE_TYPE_DEFAULT; + } + +@@ -269,9 +291,18 @@ static void server_input(struct client * + client->output = output; + client->compressed = TRUE; + client->compress_waiting = FALSE; ++ i_free_and_null(client->compress_tag); + i_stream_set_input_pending(client->stdin_input, TRUE); + break; + } ++ case SERVER_INPUT_LINE_TYPE_COMPRESS_FAILED: ++ /* Server rejected COMPRESS. Resume sending input. */ ++ e_info(client->event, ""); ++ client->handler = NULL; ++ client->compress_waiting = FALSE; ++ i_free_and_null(client->compress_tag); ++ i_stream_set_input_pending(client->stdin_input, TRUE); ++ break; + case SERVER_INPUT_LINE_TYPE_DEFAULT: + break; + } +@@ -329,6 +360,7 @@ static void cmd_compress_connect(struct + ssl_iostream_destroy(&client.ssl_iostream); + i_stream_unref(&client.stdin_input); + i_stream_unref(&client.input); ++ i_free(client.compress_tag); + o_stream_unref(&client.output); + event_unref(&client.event); + if (close(fd) < 0) diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-dsync-Fix-escaping-mail-or-attribute-value-that-begi.patch dovecot-2.4.1+dfsg1/debian/patches/0002-dsync-Fix-escaping-mail-or-attribute-value-that-begi.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-dsync-Fix-escaping-mail-or-attribute-value-that-begi.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-dsync-Fix-escaping-mail-or-attribute-value-that-begi.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,37 @@ +From a8b185e828d1a67ab66efd5c4a7d689e5eef0025 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 24 Mar 2026 12:03:08 +0200 +Subject: [PATCH 2/2] dsync: Fix escaping mail or attribute value that begins + with a "." line + +Such a mail or attribute would have escaped the value parameter and the rest +of the value would have been processed as dsync stream commands. +--- + src/doveadm/dsync/dsync-ibc-stream.c | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/doveadm/dsync/dsync-ibc-stream.c b/src/doveadm/dsync/dsync-ibc-stream.c +index 0b3038201d..390c9a50c0 100644 +--- a/src/doveadm/dsync/dsync-ibc-stream.c ++++ b/src/doveadm/dsync/dsync-ibc-stream.c +@@ -1533,7 +1533,7 @@ dsync_ibc_stream_send_mailbox_attribute(struct dsync_ibc *_ibc, + dsync_ibc_stream_send_string(ibc, str); + + if (attr->value_stream != NULL) { +- ibc->value_output_last = '\0'; ++ ibc->value_output_last = '\n'; + ibc->value_output = attr->value_stream; + i_stream_ref(ibc->value_output); + (void)dsync_ibc_stream_send_value_stream(ibc); +@@ -1913,7 +1913,7 @@ dsync_ibc_stream_send_mail(struct dsync_ibc *_ibc, + dsync_ibc_stream_send_string(ibc, str); + + if (mail->input != NULL) { +- ibc->value_output_last = '\0'; ++ ibc->value_output_last = '\n'; + ibc->value_output = mail->input; + i_stream_ref(ibc->value_output); + (void)dsync_ibc_stream_send_value_stream(ibc); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Recreate-multiplex-ostream-side-channel-after-u.patch dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Recreate-multiplex-ostream-side-channel-after-u.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Recreate-multiplex-ostream-side-channel-after-u.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Recreate-multiplex-ostream-side-channel-after-u.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,33 @@ +From d4fc8475f3b9cf8debbf9427d7bb8e9bbecb07ba Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 1 May 2026 17:40:17 +0000 +Subject: [PATCH 2/5] imap: Recreate multiplex ostream side channel after + unhibernation + +This will be needed by the following changes to send dict_reset commands via +the side channel. +--- + src/imap/imap-state.c | 7 +++++++ + 1 file changed, 7 insertions(+) + +diff --git a/src/imap/imap-state.c b/src/imap/imap-state.c +index 3736296bea..fbd383d9de 100644 +--- a/src/imap/imap-state.c ++++ b/src/imap/imap-state.c +@@ -723,6 +723,13 @@ import_state_compress(struct client *client, const unsigned char *data, + *error_r = t_strdup_printf("Unknown COMPRESS handler %s", name); + return 0; + } ++ /* Recreate the side channel that cmd-compress would have created in ++ the previous (now-exited) imap process. It is used to forward ++ dict_reset commands back to the imap-login proxy after every tagged ++ reply. */ ++ if (client->multiplex_output != NULL && ++ client->side_channel_output == NULL) ++ client_create_side_channel_output(client); + return p - data; + } + +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Stream-BODYSTRUCTURE-to-client-without-ostream-.patch dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Stream-BODYSTRUCTURE-to-client-without-ostream-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Stream-BODYSTRUCTURE-to-client-without-ostream-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-imap-Stream-BODYSTRUCTURE-to-client-without-ostream-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,79 @@ +From 480e9dfb6140f51992fdf83a9e9418e1dcf742d2 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 16:55:05 +0200 +Subject: [PATCH 02/14] imap: Stream BODYSTRUCTURE to client without ostream + memory duplication + +For huge BODYSTRUCTURE responses (messages with many MIME parts), +o_stream_send_str() caused the ostream to copy the entire string into +its ring buffer when the stream was corked, doubling memory usage. + +Use the same o_stream_set_max_buffer_size(0) + o_stream_send_istream() +pattern as fetch_stream_continue() so the ostream buffer stays at 0 +bytes and the I/O loop handles flow control via WAIT_OUTPUT. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/imap/imap-fetch.c | 37 +++++++++++++++++++++++++++++++++---- + 1 file changed, 33 insertions(+), 4 deletions(-) + +diff --git a/src/imap/imap-fetch.c b/src/imap/imap-fetch.c +index ca4b3216ce..b4a8a465d7 100644 +--- a/src/imap/imap-fetch.c ++++ b/src/imap/imap-fetch.c +@@ -732,6 +732,34 @@ static bool fetch_body_init(struct imap_fetch_init_context *ctx) + return imap_fetch_body_section_init(ctx); + } + ++static int fetch_bodystructure_stream_continue(struct imap_fetch_context *ctx) ++{ ++ struct imap_fetch_state *state = &ctx->state; ++ enum ostream_send_istream_result res; ++ ++ o_stream_set_max_buffer_size(ctx->client->output, 0); ++ res = o_stream_send_istream(ctx->client->output, state->cur_input); ++ o_stream_set_max_buffer_size(ctx->client->output, SIZE_MAX); ++ ++ switch (res) { ++ case OSTREAM_SEND_ISTREAM_RESULT_FINISHED: ++ i_stream_unref(&state->cur_input); ++ state->cont_handler = NULL; ++ if (o_stream_send(ctx->client->output, ")", 1) < 0) ++ return -1; ++ return 1; ++ case OSTREAM_SEND_ISTREAM_RESULT_WAIT_INPUT: ++ i_unreached(); ++ case OSTREAM_SEND_ISTREAM_RESULT_WAIT_OUTPUT: ++ return 0; ++ case OSTREAM_SEND_ISTREAM_RESULT_ERROR_INPUT: ++ i_unreached(); ++ case OSTREAM_SEND_ISTREAM_RESULT_ERROR_OUTPUT: ++ return -1; ++ } ++ i_unreached(); ++} ++ + static int fetch_bodystructure(struct imap_fetch_context *ctx, + struct mail *mail, void *context ATTR_UNUSED) + { +@@ -748,12 +776,13 @@ static int fetch_bodystructure(struct imap_fetch_context *ctx, + return -1; + } + +- if (o_stream_send(ctx->client->output, "BODYSTRUCTURE (", 15) < 0 || +- o_stream_send_str(ctx->client->output, bodystructure) < 0 || +- o_stream_send(ctx->client->output, ")", 1) < 0) ++ if (o_stream_send(ctx->client->output, "BODYSTRUCTURE (", 15) < 0) + return -1; + +- return 1; ++ ctx->state.cur_input = ++ i_stream_create_from_data(bodystructure, strlen(bodystructure)); ++ ctx->state.cont_handler = fetch_bodystructure_stream_continue; ++ return fetch_bodystructure_stream_continue(ctx); + } + + static bool fetch_bodystructure_init(struct imap_fetch_init_context *ctx) +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-imap-hibernate-Use-imap-parser-API-for-parsing-DONE-.patch dovecot-2.4.1+dfsg1/debian/patches/0002-imap-hibernate-Use-imap-parser-API-for-parsing-DONE-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-imap-hibernate-Use-imap-parser-API-for-parsing-DONE-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-imap-hibernate-Use-imap-parser-API-for-parsing-DONE-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,152 @@ +From fc1898a575fd4cf4c2a4b3e00da630825c0acf60 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 22 Apr 2026 12:58:56 +0300 +Subject: [PATCH 2/2] imap-hibernate: Use imap-parser API for parsing DONE + command + +Replace the ad-hoc DONE/IDLE tokenizer with imap-parser.h: use +imap_parser_read_tag() to read the tag and imap_parser_read_word() to +read the DONE and IDLE keywords. This delegates bounds checks and +character validation to the shared parser. +--- + src/imap-hibernate/imap-client.c | 103 ++++++++++++++++++------------- + 1 file changed, 59 insertions(+), 44 deletions(-) + +diff --git a/src/imap-hibernate/imap-client.c b/src/imap-hibernate/imap-client.c +index 2110fe8e8e..564ef44f4f 100644 +--- a/src/imap-hibernate/imap-client.c ++++ b/src/imap-hibernate/imap-client.c +@@ -21,6 +21,7 @@ + #include "master-service-settings.h" + #include "imap-keepalive.h" + #include "imap-master-connection.h" ++#include "imap-parser.h" + #include "imap-client.h" + + #include +@@ -334,64 +335,78 @@ static void imap_client_move_back(struct imap_client *client) + } + + static enum imap_client_input_state +-imap_client_input_parse(const unsigned char *data, size_t size, const char **tag_r) ++imap_client_input_parse_input(struct istream *input, struct imap_parser *parser, ++ const char **tag_r) + { +- const unsigned char *tag_start, *tag_end; +- ++ const unsigned char *rest; ++ size_t rest_size; ++ const char *word, *tag; + enum imap_client_input_state state = IMAP_CLIENT_INPUT_STATE_DONE_LF; ++ int ret; + +- /* skip over DONE[\r]\n */ +- if (i_memcasecmp(data, "DONE", I_MIN(size, 4)) != 0) +- return IMAP_CLIENT_INPUT_STATE_BAD; +- if (size <= 4) ++ /* read DONE */ ++ word = imap_parser_read_word(parser); ++ if (word == NULL) + return IMAP_CLIENT_INPUT_STATE_UNKNOWN; +- data += 4; size -= 4; ++ if (strcasecmp(word, "DONE") != 0) ++ return IMAP_CLIENT_INPUT_STATE_BAD; + +- if (data[0] == '\r') { ++ /* read [\r]\n after DONE */ ++ rest = i_stream_get_data(input, &rest_size); ++ if (rest_size == 0) ++ return IMAP_CLIENT_INPUT_STATE_UNKNOWN; ++ if (rest[0] == '\r') { + state = IMAP_CLIENT_INPUT_STATE_DONE_CRLF; +- data++; size--; ++ i_stream_skip(input, 1); ++ rest = i_stream_get_data(input, &rest_size); ++ if (rest_size == 0) ++ return IMAP_CLIENT_INPUT_STATE_UNKNOWN; + } +- if (size == 0) +- return IMAP_CLIENT_INPUT_STATE_UNKNOWN; +- if (data[0] != '\n') ++ if (rest[0] != '\n') + return IMAP_CLIENT_INPUT_STATE_BAD; +- data++; size--; +- if (size == 0) ++ i_stream_skip(input, 1); ++ ++ /* optionally followed by " IDLE[\r]\n" - checking this assumes ++ that the DONE and IDLE are sent in the same IP packet, otherwise ++ we'll unnecessarily recreate the imap process and immediately resume ++ IDLE there. if this becomes an issue we could add a small delay to ++ the imap process creation and wait for the IDLE command during it. */ ++ rest = i_stream_get_data(input, &rest_size); ++ if (rest_size == 0) + return state; + +- tag_start = data; +- +- /* skip over tag */ +- while(size > 0 && +- data[0] != ' ' && +- data[0] != '\r' && +- data[0] != '\t' && +- data[0] != '\0') { data++; size--; } +- +- tag_end = data; +- +- if (size == 0) ++ ret = imap_parser_read_tag(parser, &tag); ++ if (ret <= 0) + return state; +- if (data[0] != ' ') +- return IMAP_CLIENT_INPUT_STATE_BAD; +- data++; size--; +- +- /* skip over IDLE[\r]\n - checking this assumes that the DONE and IDLE +- are sent in the same IP packet, otherwise we'll unnecessarily +- recreate the imap process and immediately resume IDLE there. if this +- becomes an issue we could add a small delay to the imap process +- creation and wait for the IDLE command during it. */ +- if (size <= 4 || i_memcasecmp(data, "IDLE", 4) != 0) ++ ++ /* read IDLE */ ++ word = imap_parser_read_word(parser); ++ if (word == NULL || strcasecmp(word, "IDLE") != 0) + return state; +- data += 4; size -= 4; + +- if (data[0] == '\r') { +- data++; size--; +- } +- if (size == 1 && data[0] == '\n') { +- *tag_r = t_strdup_until(tag_start, tag_end); +- return IMAP_CLIENT_INPUT_STATE_DONEIDLE; ++ /* require [\r]\n and nothing else left */ ++ rest = i_stream_get_data(input, &rest_size); ++ if (rest_size > 0 && rest[0] == '\r') { ++ i_stream_skip(input, 1); ++ rest = i_stream_get_data(input, &rest_size); + } ++ if (rest_size != 1 || rest[0] != '\n') ++ return state; ++ ++ *tag_r = t_strdup(tag); ++ return IMAP_CLIENT_INPUT_STATE_DONEIDLE; ++} ++ ++static enum imap_client_input_state ++imap_client_input_parse(const unsigned char *data, size_t size, const char **tag_r) ++{ ++ struct istream *input = i_stream_create_from_data(data, size); ++ struct imap_parser *parser = ++ imap_parser_create(input, NULL, size, NULL); ++ enum imap_client_input_state state = ++ imap_client_input_parse_input(input, parser, tag_r); ++ imap_parser_unref(&parser); ++ i_stream_unref(&input); + return state; + } + +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-Replace-str_hash-strcase_hash-with-xxh64.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-Replace-str_hash-strcase_hash-with-xxh64.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-Replace-str_hash-strcase_hash-with-xxh64.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-Replace-str_hash-strcase_hash-with-xxh64.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,80 @@ +From bd11a6525eeb214541fa26450550f8b1bb4baec7 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 13 Apr 2026 12:33:51 +0200 +Subject: [PATCH 02/12] lib: Replace str_hash/strcase_hash with xxh64 + +Replaces the old ASU-derived shift-and-XOR hash with xxh64_to_32(), +which provides better distribution and avalanche properties. +strcase_hash feeds each i_toupper()'d byte through the streaming API. +--- + src/lib/hash.c | 41 +++++++++++------------------------------ + 1 file changed, 11 insertions(+), 30 deletions(-) + +diff --git a/src/lib/hash.c b/src/lib/hash.c +index aceef29b23..8f4cd7d9b4 100644 +--- a/src/lib/hash.c ++++ b/src/lib/hash.c +@@ -5,6 +5,7 @@ + #include "lib.h" + #include "hash.h" + #include "primes.h" ++#include "xxh64.h" + + #include + +@@ -519,42 +520,22 @@ void hash_table_copy(struct hash_table *dest, struct hash_table *src) + hash_table_thaw(dest); + } + +-/* a char* hash function from ASU -- from glib */ +-unsigned int ATTR_NO_SANITIZE_INTEGER +-str_hash(const char *p) ++unsigned int str_hash(const char *p) + { +- const unsigned char *s = (const unsigned char *)p; +- unsigned int g, h = 0; +- +- while (*s != '\0') { +- h = (h << 4) + *s; +- if ((g = h & 0xf0000000UL) != 0) { +- h = h ^ (g >> 24); +- h = h ^ g; +- } +- s++; +- } +- +- return h; ++ return xxh64_to_32(xxh64_data(p, strlen(p), 0)); + } + +-/* a char* hash function from ASU -- from glib */ +-unsigned int ATTR_NO_SANITIZE_INTEGER +-strcase_hash(const char *p) ++unsigned int strcase_hash(const char *p) + { +- const unsigned char *s = (const unsigned char *)p; +- unsigned int g, h = 0; ++ struct xxh64_context ctx; ++ unsigned char c; + +- while (*s != '\0') { +- h = (h << 4) + i_toupper(*s); +- if ((g = h & 0xf0000000UL) != 0) { +- h = h ^ (g >> 24); +- h = h ^ g; +- } +- s++; ++ xxh64_init(&ctx, 0); ++ while (*p != '\0') { ++ c = (unsigned char)i_toupper(*p++); ++ xxh64_loop(&ctx, &c, 1); + } +- +- return h; ++ return xxh64_to_32(xxh64_result(&ctx)); + } + + unsigned int ATTR_NO_SANITIZE_INTEGER +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-compression-Iterate-instead-of-recursing-on-zero.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-compression-Iterate-instead-of-recursing-on-zero.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-compression-Iterate-instead-of-recursing-on-zero.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-compression-Iterate-instead-of-recursing-on-zero.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,187 @@ +From 88ae095f0530417ccc619af0a9fb68b0ef7cf0c0 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Wed, 10 Jun 2026 11:54:00 +0000 +Subject: [PATCH 2/4] lib-compression: Iterate instead of recursing on + zero-output decompress chunks + +istream-lz4, istream-zlib and istream-bzlib each retried a read that +produced no output by tail-calling their own read function. A crafted +compressed stream can contain an unbounded number of chunks/steps that +each decompress to zero bytes - e.g. an lz4 stream of single-byte chunks +that each decode to nothing - so an attacker controlling the compressed +data can drive recursion depth proportional to the chunk count. Tail-call +optimization is not guaranteed, so this can exhaust the stack and crash +the process reading the stream. + +Replace the recursive calls with continue inside the for(;;) loop +introduced in the previous commit, keeping stack usage O(1). Add a +regression test that feeds istream-lz4 a stream of 100000 empty chunks +and reads it in a single call. +--- + src/lib-compression/istream-bzlib.c | 8 +++-- + src/lib-compression/istream-lz4.c | 16 ++++++--- + src/lib-compression/istream-zlib.c | 8 +++-- + src/lib-compression/test-compression.c | 50 ++++++++++++++++++++++++++ + 4 files changed, 74 insertions(+), 8 deletions(-) + +Index: trixie/src/lib-compression/istream-bzlib.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-bzlib.c ++++ trixie/src/lib-compression/istream-bzlib.c +@@ -51,6 +51,10 @@ static ssize_t i_stream_bzlib_read(struc + size_t size, out_size; + int ret; + ++ /* Loop instead of recursing when a decompress step yields no output: ++ a crafted stream can produce an unbounded number of zero-output ++ steps, and tail-call recursion (not guaranteed) would exhaust the ++ stack. */ + for (;;) { + high_offset = stream->istream.v_offset + (stream->pos - stream->skip); + if (zstream->eof_offset == high_offset) { +@@ -130,8 +134,8 @@ static ssize_t i_stream_bzlib_read(struc + i_fatal("BZ2_bzDecompress() failed with %d", ret); + } + if (out_size == 0) { +- /* read more input */ +- return i_stream_bzlib_read(stream); ++ /* no output yet; read more input without recursing */ ++ continue; + } + return out_size; + } +Index: trixie/src/lib-compression/istream-lz4.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-lz4.c ++++ trixie/src/lib-compression/istream-lz4.c +@@ -149,6 +149,10 @@ static ssize_t i_stream_lz4_read(struct + zstream->header_read = TRUE; + } + ++ /* Loop over chunks instead of recursing on zero-output chunks: a ++ crafted stream can contain an unbounded number of chunks that each ++ decompress to zero bytes, and tail-call recursion (which is not ++ guaranteed) would let that exhaust the stack. */ + for (;;) { + if (zstream->chunk_left == 0) { + while ((ret = i_stream_lz4_read_chunk_header(zstream)) == 0) { +@@ -183,8 +187,8 @@ static ssize_t i_stream_lz4_read(struct + if (stream->pos - stream->skip >= i_stream_get_max_buffer_size(&stream->istream)) + return -2; + if (i_stream_get_data_size(zstream->istream.parent) > 0) { +- /* Parent stream was only partially consumed. Set the stream's +- IO as pending to avoid hangs. */ ++ /* Parent stream was only partially consumed. Set the ++ stream's IO as pending to avoid hangs. */ + i_stream_set_input_pending(&zstream->istream.istream, TRUE); + } + /* allocate enough space for the old data and the new +@@ -199,8 +203,12 @@ static ssize_t i_stream_lz4_read(struct + lz4_read_error(zstream, "corrupted lz4 chunk"); + stream->istream.stream_errno = EINVAL; + return -1; +- } else if (ret == 0) +- return i_stream_lz4_read(stream); ++ } else if (ret == 0) { ++ /* chunk decompressed to zero bytes; read the next chunk ++ without recursing so the stack stays bounded. */ ++ buffer_set_used_size(zstream->chunk_buf, 0); ++ continue; ++ } + i_assert(ret > 0); + stream->pos += ret; + i_assert(stream->pos <= stream->buffer_size); +Index: trixie/src/lib-compression/istream-zlib.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-zlib.c ++++ trixie/src/lib-compression/istream-zlib.c +@@ -168,6 +168,10 @@ static ssize_t i_stream_zlib_read(struct + size_t size, out_size; + int ret; + ++ /* Loop instead of recursing when an inflate step yields no output: ++ a crafted stream can produce an unbounded number of zero-output ++ steps, and tail-call recursion (not guaranteed) would exhaust the ++ stack. */ + for (;;) { + high_offset = stream->istream.v_offset + (stream->pos - stream->skip); + if (zstream->eof_offset == high_offset) { +@@ -314,8 +318,8 @@ static ssize_t i_stream_zlib_read(struct + i_fatal("inflate() failed with %d", ret); + } + if (out_size == 0) { +- /* read more input */ +- return i_stream_zlib_read(stream); ++ /* no output yet; read more input without recursing */ ++ continue; + } + return out_size; + } +Index: trixie/src/lib-compression/test-compression.c +=================================================================== +--- trixie.orig/src/lib-compression/test-compression.c ++++ trixie/src/lib-compression/test-compression.c +@@ -1108,6 +1108,55 @@ static void test_lz4_chunk_size(void) + test_end(); + } + ++static void test_lz4_many_empty_chunks(void) ++{ ++ const struct compression_handler *lz4; ++ struct istream *file_input, *input; ++ ++ if (compression_lookup_handler("lz4", &lz4) <= 0) ++ return; /* not compiled in or unknown */ ++ ++ test_begin("lz4 many empty chunks"); ++ ++ /* A crafted lz4 stream can contain an unbounded number of chunks that ++ each decompress to zero bytes. i_stream_lz4_read() must iterate over ++ them rather than recurse once per chunk, or the stack is exhausted ++ (tail-call optimization is not guaranteed). */ ++ buffer_t *buf = buffer_create_dynamic(default_pool, 1024*512); ++ struct iostream_lz4_header hdr; ++ memcpy(hdr.magic, IOSTREAM_LZ4_MAGIC, IOSTREAM_LZ4_MAGIC_LEN); ++ /* a valid (64k) max uncompressed chunk size, big-endian */ ++ hdr.max_uncompressed_chunk_size[0] = 0x00; ++ hdr.max_uncompressed_chunk_size[1] = 0x01; ++ hdr.max_uncompressed_chunk_size[2] = 0x00; ++ hdr.max_uncompressed_chunk_size[3] = 0x00; ++ buffer_append(buf, &hdr, sizeof(hdr)); ++ ++ /* Each chunk: 4-byte big-endian compressed length (1), then a single ++ 0x00 byte, which LZ4_decompress_safe() decodes to zero bytes. */ ++ static const unsigned char empty_chunk[] = { ++ 0x00, 0x00, 0x00, 0x01, 0x00 ++ }; ++ for (unsigned int i = 0; i < 100000; i++) ++ buffer_append(buf, empty_chunk, sizeof(empty_chunk)); ++ ++ file_input = test_istream_create_data(buf->data, buf->used); ++ file_input->blocking = TRUE; ++ input = lz4->create_istream(file_input); ++ i_stream_unref(&file_input); ++ ++ /* All chunks are empty: clean EOF, no content, no error - and, with ++ the iterative read, no stack overflow. */ ++ test_assert(i_stream_read(input) == -1); ++ test_assert(input->eof); ++ test_assert(input->stream_errno == 0); ++ ++ i_stream_unref(&input); ++ buffer_free(&buf); ++ ++ test_end(); ++} ++ + static void test_uncompress_file(const char *path) + { + const struct compression_handler *handler; +@@ -1232,6 +1281,7 @@ int main(int argc, char *argv[]) + test_gz_large_header, + test_lz4_small_header, + test_lz4_chunk_size, ++ test_lz4_many_empty_chunks, + test_compression_ext, + test_compression_deinit, + NULL diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-mail-o_stream_dot_sendv-Fix-boundary-off-by-one.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-mail-o_stream_dot_sendv-Fix-boundary-off-by-one.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-mail-o_stream_dot_sendv-Fix-boundary-off-by-one.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-mail-o_stream_dot_sendv-Fix-boundary-off-by-one.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,62 @@ +From a6d847f3ad7076a3406a84774a90ab58a67f7908 Mon Sep 17 00:00:00 2001 +From: Marco Bettini +Date: Mon, 20 Apr 2026 09:50:54 +0000 +Subject: [PATCH 2/4] lib-mail: o_stream_dot_sendv() - Fix boundary off by one + +--- + src/lib-mail/ostream-dot.c | 2 +- + src/lib-mail/test-ostream-dot.c | 23 +++++++++++++++++++++++ + 2 files changed, 24 insertions(+), 1 deletion(-) + +Index: trixie/src/lib-mail/ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/ostream-dot.c ++++ trixie/src/lib-mail/ostream-dot.c +@@ -105,7 +105,7 @@ o_stream_dot_sendv(struct ostream_privat + + p = data; + pend = CONST_PTR_OFFSET(data, size); +- for (; p < pend && (size_t)(p-data)+2 < max_bytes; p++) { ++ for (; p < pend && ((size_t)(p - data) + 2) <= max_bytes; p++) { + char add = 0; + + size = pend - p; +Index: trixie/src/lib-mail/test-ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/test-ostream-dot.c ++++ trixie/src/lib-mail/test-ostream-dot.c +@@ -92,11 +92,34 @@ static void test_ostream_dot_parent_almo + test_end(); + } + ++static void test_ostream_dot_parent_exact_fit(void) ++{ ++ buffer_t *output_data; ++ struct ostream *test_output, *output; ++ ssize_t ret; ++ ++ test_begin("dot ostream parent exact fit"); ++ output_data = t_buffer_create(1024); ++ test_output = test_ostream_create_nonblocking(output_data, 2); ++ test_ostream_set_max_output_size(test_output, 2); ++ ++ output = o_stream_create_dot(test_output, FALSE); ++ ret = o_stream_send(output, ".", 1); ++ test_assert(ret == 1); ++ test_assert_ucmp(output_data->used, ==, 2); ++ test_assert_memcmp(output_data->data, output_data->used, "..", 2); ++ ++ o_stream_unref(&output); ++ o_stream_unref(&test_output); ++ test_end(); ++} ++ + int main(void) + { + static void (*const test_functions[])(void) = { + test_ostream_dot, + test_ostream_dot_parent_almost_full, ++ test_ostream_dot_parent_exact_fit, + NULL + }; + return test_run(test_functions); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,406 @@ +From 5cc55540eef2096680e6074c27ea0524a2ecc6d9 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 4 May 2026 13:08:16 +0000 +Subject: [PATCH 2/5] lib: path-util - Add t_openat_safe() for jailed openat() + resolution + +Add a helper that opens a path relative to a directory fd while refusing +any resolution that escapes that directory. Each component is opened with +O_NOFOLLOW so symlinks are detected explicitly rather than transparently +followed; symlinks are then resolved manually and only honoured when their +(recursively resolved) target also stays beneath the base fd. Absolute +symlink targets, leading '/', and '..' past the base are rejected with +errno=ELOOP. Symlink chains are bounded by +PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS hops. + +Anchoring resolution at a caller-held base_fd makes the lookup TOCTOU-safe +even when intermediate path components are mutated on disk concurrently: +the kernel resolves all openat() lookups relative to the original +directory inode that base_fd refers to, so an attacker mutating path +components on the filesystem cannot redirect resolution. + +This is the portable counterpart to Linux's openat2() with RESOLVE_BENEATH +and is intended for callers that need to safely look up files under a +user-writable directory (e.g. a Sieve script storage directory). +--- + src/lib/path-util.c | 170 +++++++++++++++++++++++++++++++++++++++ + src/lib/path-util.h | 29 +++++++ + src/lib/test-path-util.c | 124 ++++++++++++++++++++++++++++ + 3 files changed, 323 insertions(+) + +Index: trixie/src/lib/path-util.c +=================================================================== +--- trixie.orig/src/lib/path-util.c ++++ trixie/src/lib/path-util.c +@@ -4,12 +4,17 @@ + #include "str.h" + #include "path-util.h" + ++#include + #include + #include + #include + + #define PATH_UTIL_MAX_PATH 8*1024 + #define PATH_UTIL_MAX_SYMLINKS 80 ++/* Symlink-hop cap for t_openat_safe(). Lower than the realpath cap: ++ t_openat_safe() refuses absolute and escaping targets outright, so a ++ long chain inside the base dir is the only legitimate use. */ ++#define PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS 16 + + static int t_getcwd_noalloc(char **dir_r, size_t *asize_r, + const char **error_r) ATTR_NULL(2) +@@ -358,6 +363,171 @@ int t_readlink(const char *path, const c + return 0; + } + ++/* Iteratively resolve `path` beneath `cur_fd` using openat() with ++ O_NOFOLLOW per component. Symlinks are resolved manually: their targets ++ are validated (non-absolute, no '..' past the base) and the loop ++ restarts with the target spliced into the remaining path. dir_depth ++ tracks how many directory levels we have descended below the original ++ base; '..' is refused when the depth would go negative. ++ */ ++static int ++path_openat_safe_walk(int cur_fd, const char *path, int flags, ++ const char **error_r) ++{ ++ bool cur_owned = FALSE; ++ unsigned int symlink_count = 0; ++ unsigned int dir_depth = 0; ++ ++ /* Absolute paths cannot stay beneath the base directory. */ ++ if (*path == '/') { ++ errno = ELOOP; ++ *error_r = "Path is absolute (escapes base directory)"; ++ return -1; ++ } ++ ++ for (;;) { ++ while (*path == '/') ++ path++; ++ ++ if (*path == '\0') { ++ /* Empty path or trailing slash: open the current ++ directory itself with the requested flags. */ ++ int fd = openat(cur_fd, ".", ++ flags | O_NOFOLLOW | O_CLOEXEC); ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ if (fd < 0) { ++ *error_r = t_strdup_printf( ++ "openat() failed: %m"); ++ } ++ return fd; ++ } ++ ++ const char *slash = strchr(path, '/'); ++ size_t comp_len = (slash != NULL ? ++ (size_t)(slash - path) : strlen(path)); ++ bool is_last = (slash == NULL); ++ ++ if (comp_len == 1 && path[0] == '.') { ++ path += comp_len; ++ continue; ++ } ++ ++ if (comp_len == 2 && path[0] == '.' && path[1] == '.') { ++ if (dir_depth == 0) { ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ errno = ELOOP; ++ *error_r = "Path escapes base directory via '..'"; ++ return -1; ++ } ++ int parent_fd = openat(cur_fd, "..", ++ O_RDONLY | O_DIRECTORY | ++ O_NOFOLLOW | O_CLOEXEC); ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ if (parent_fd < 0) { ++ *error_r = t_strdup_printf( ++ "openat(..) failed: %m"); ++ return -1; ++ } ++ cur_fd = parent_fd; ++ cur_owned = TRUE; ++ dir_depth--; ++ path += comp_len; ++ continue; ++ } ++ ++ const char *comp = t_strdup_until(path, path + comp_len); ++ int next_fd; ++ if (is_last) { ++ next_fd = openat(cur_fd, comp, ++ flags | O_NOFOLLOW | O_CLOEXEC); ++ } else { ++ next_fd = openat(cur_fd, comp, ++ O_RDONLY | O_DIRECTORY | ++ O_NOFOLLOW | O_CLOEXEC); ++ } ++ ++ if (next_fd < 0 && (errno == ELOOP || errno == EMLINK)) { ++ /* Component is a symlink. Resolve it ourselves and ++ restart processing with its target spliced into ++ `path`; that way the target is itself walked ++ component-by-component and the boundary check ++ continues to apply. (O_NOFOLLOW on a symlink reports ++ ELOOP on Linux/macOS but EMLINK on the BSDs, so both ++ must be treated as "this is a symlink".) */ ++ if (++symlink_count > ++ PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS) { ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ errno = ELOOP; ++ *error_r = "Too many symlink dereferences"; ++ return -1; ++ } ++ ++ char buf[PATH_UTIL_MAX_PATH]; ++ ssize_t llen = readlinkat(cur_fd, comp, ++ buf, sizeof(buf) - 1); ++ if (llen < 0) { ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ *error_r = t_strdup_printf( ++ "readlinkat(%s) failed: %m", comp); ++ return -1; ++ } ++ buf[llen] = '\0'; ++ ++ if (buf[0] == '/') { ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ errno = ELOOP; ++ *error_r = t_strdup_printf( ++ "Symlink '%s' has absolute target " ++ "'%s' (escapes base directory)", ++ comp, buf); ++ return -1; ++ } ++ ++ path = is_last ? t_strdup(buf) : ++ t_strconcat(buf, "/", ++ slash + 1, NULL); ++ continue; ++ } ++ ++ if (next_fd < 0) { ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ *error_r = t_strdup_printf( ++ "openat(%s) failed: %m", comp); ++ return -1; ++ } ++ ++ if (is_last) { ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ return next_fd; ++ } ++ ++ if (cur_owned) ++ i_close_fd(&cur_fd); ++ cur_fd = next_fd; ++ cur_owned = TRUE; ++ dir_depth++; ++ path = slash + 1; ++ } ++} ++ ++int t_openat_safe(int base_fd, const char *path, int flags, ++ const char **error_r) ++{ ++ i_assert(base_fd >= 0); ++ i_assert(path != NULL); ++ i_assert(error_r != NULL); ++ ++ return path_openat_safe_walk(base_fd, path, flags, error_r); ++} ++ + bool t_binary_abspath(const char **binpath, const char **error_r) + { + const char *path_env, *const *paths; +Index: trixie/src/lib/path-util.h +=================================================================== +--- trixie.orig/src/lib/path-util.h ++++ trixie/src/lib/path-util.h +@@ -57,6 +57,35 @@ int t_get_working_dir(const char **dir_r + * -1 on failure. error_r is set on failure and cannot be NULL. */ + int t_readlink(const char *path, const char **dest_r, const char **error_r); + ++/* Open `path` relative to `base_fd`, refusing any resolution that escapes ++ the directory referenced by `base_fd`. Each path component is opened with ++ O_NOFOLLOW so symlinks are detected explicitly rather than transparently ++ followed; symlinks are followed only when their (recursively resolved) ++ target also stays beneath `base_fd`. Absolute symlink targets, leading ++ '/', and '..' past the base are rejected. ++ ++ `base_fd` must reference a directory (e.g. obtained via ++ open(dir, O_DIRECTORY|O_RDONLY|O_CLOEXEC)) and remains owned by the ++ caller; this function does not close it. ++ ++ `flags` is OR-ed into the openat() call for the final component (e.g. ++ O_RDONLY). O_NOFOLLOW and O_CLOEXEC are added implicitly. ++ ++ Anchoring resolution at `base_fd` makes the lookup TOCTOU-safe even when ++ intermediate path components are mutated on disk concurrently: the kernel ++ resolves all component lookups relative to the original directory inode ++ `base_fd` references. ++ ++ Returns the new fd (>= 0) on success. On failure returns -1 with errno ++ set and *error_r set to a descriptive message: ++ errno=ELOOP target escapes base, or symlink chain too deep ++ errno=ENOENT file not found ++ errno=EACCES permission denied ++ other errno as set by openat()/readlinkat() ++ */ ++int t_openat_safe(int base_fd, const char *path, int flags, ++ const char **error_r); ++ + /* Update binpath to be absolute: + * a) begins with '/' -> no change + * b) contains '/' -> assume relative to working directory +Index: trixie/src/lib/test-path-util.c +=================================================================== +--- trixie.orig/src/lib/test-path-util.c ++++ trixie/src/lib/test-path-util.c +@@ -233,6 +233,129 @@ static void test_link_alloc2(void) + tmpdir = o_tmpdir; + } + ++static void test_openat_safe(void) ++{ ++ const char *error; ++ int base_fd, fd; ++ ++ base_fd = open(tmpdir, O_RDONLY | O_DIRECTORY | O_CLOEXEC); ++ if (base_fd < 0) ++ i_fatal("open(%s) failed: %m", tmpdir); ++ ++ /* Plain regular file inside base. */ ++ const char *plain = t_strconcat(tmpdir, "/plain", NULL); ++ int wfd = creat(plain, 0600); ++ if (wfd < 0) ++ i_fatal("creat(%s) failed: %m", plain); ++ i_close_fd(&wfd); ++ ++ fd = t_openat_safe(base_fd, "plain", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ ++ /* "./plain" and redundant slashes. */ ++ fd = t_openat_safe(base_fd, "./plain", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ fd = t_openat_safe(base_fd, ".//plain", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ ++ /* Symlink whose target is inside the base: allowed. */ ++ const char *in_link = t_strconcat(tmpdir, "/in-link", NULL); ++ if (symlink("plain", in_link) < 0) ++ i_fatal("symlink(plain, %s) failed: %m", in_link); ++ fd = t_openat_safe(base_fd, "in-link", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ ++ /* Absolute symlink target: refused. */ ++ const char *abs_link = t_strconcat(tmpdir, "/abs-link", NULL); ++ if (symlink("/etc/hostname", abs_link) < 0) ++ i_fatal("symlink failed: %m"); ++ errno = 0; ++ fd = t_openat_safe(base_fd, "abs-link", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ test_assert(error != NULL); ++ ++ /* Relative symlink with '..' that escapes the base: refused. */ ++ const char *escape_link = t_strconcat(tmpdir, "/escape-link", NULL); ++ if (symlink("../../etc/hostname", escape_link) < 0) ++ i_fatal("symlink failed: %m"); ++ errno = 0; ++ fd = t_openat_safe(base_fd, "escape-link", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ test_assert(error != NULL); ++ ++ /* Direct '..' escape attempt: refused. */ ++ errno = 0; ++ fd = t_openat_safe(base_fd, "../plain", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ ++ /* Absolute literal path: refused. */ ++ errno = 0; ++ fd = t_openat_safe(base_fd, "/etc/hostname", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ ++ /* link2's target is an absolute path (tmpdir/link3): refused as an ++ absolute target before the loop is ever followed. */ ++ errno = 0; ++ fd = t_openat_safe(base_fd, "link2", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ ++ /* Relative symlink loop staying inside the base: nothing rejects it ++ except the hop counter, so this exercises ++ PATH_UTIL_OPENAT_SAFE_MAX_SYMLINKS. */ ++ const char *loopa = t_strconcat(tmpdir, "/loopa", NULL); ++ const char *loopb = t_strconcat(tmpdir, "/loopb", NULL); ++ if (symlink("loopb", loopa) < 0) ++ i_fatal("symlink(loopb, %s) failed: %m", loopa); ++ if (symlink("loopa", loopb) < 0) ++ i_fatal("symlink(loopa, %s) failed: %m", loopb); ++ errno = 0; ++ fd = t_openat_safe(base_fd, "loopa", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ test_assert(error != NULL); ++ ++ /* '..' that stays within the base: allowed. Create sub/under and ++ reach back to plain via sub/../plain. */ ++ const char *subdir = t_strconcat(tmpdir, "/sub", NULL); ++ if (mkdir(subdir, 0700) < 0 && errno != EEXIST) ++ i_fatal("mkdir(%s) failed: %m", subdir); ++ fd = t_openat_safe(base_fd, "sub/../plain", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ ++ /* Breaking out of the base via subdirectory. */ ++ errno = 0; ++ fd = t_openat_safe(base_fd, "sub/../../plain", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ ++ /* Nonexistent file inside base: ENOENT, not ELOOP. */ ++ errno = 0; ++ fd = t_openat_safe(base_fd, "no-such-file", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ENOENT); ++ ++ i_close_fd(&base_fd); ++ ++ i_unlink(plain); ++ i_unlink(in_link); ++ i_unlink(abs_link); ++ i_unlink(escape_link); ++ i_unlink(loopa); ++ i_unlink(loopb); ++ if (rmdir(subdir) < 0) ++ i_error("rmdir(%s) failed: %m", subdir); ++} ++ + static void test_cleanup(void) + { + const char *error; +@@ -272,6 +395,7 @@ void test_path_util(void) + test_abspath_vs_normpath(); + test_link_alloc(); + test_link_alloc2(); ++ test_openat_safe(); + test_cleanup(); + alarm(0); + test_end(); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-Drop-binary-header-resource-usage-tracking.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-Drop-binary-header-resource-usage-tracking.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-Drop-binary-header-resource-usage-tracking.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-Drop-binary-header-resource-usage-tracking.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,304 @@ +From 64e1f7af273809f484fb983d4a6edb1af1a85c99 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 5 May 2026 16:19:20 +0000 +Subject: [PATCH 2/7] lib-sieve: Drop binary-header resource usage tracking + +With the per-user sieve-rusage file now authoritative for personal +storages, the .svbin header no longer needs to carry CPU usage. Admin +storages (sieve_before/after, default location) cannot be re-uploaded +by the user, so cross-run tracking is unnecessary there; the runtime +sieve_max_cpu_time check still enforces the per-execution limit. + +Move the in-memory cumulative carry-over from sbin->header.resource_usage +to a dedicated sbin->persisted_rusage substruct. The on-disk header +struct now solely represents the wire format and is zeroed on every +save; the in-memory carry-over no longer overloads it. Drop the now-dead +"resource usage" section from sieve_binary_dumper_run() (the on-disk +fields are always zero, so the section never produced output). +--- + src/lib-sieve/sieve-binary-dumper.c | 12 --- + src/lib-sieve/sieve-binary-file.c | 141 +++++---------------------- + src/lib-sieve/sieve-binary-private.h | 6 ++ + src/lib-sieve/sieve-binary.c | 15 ++- + 4 files changed, 37 insertions(+), 137 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-dumper.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-dumper.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-dumper.c +@@ -6,7 +6,6 @@ + #include "ostream.h" + #include "array.h" + #include "buffer.h" +-#include "time-util.h" + + #include "sieve-common.h" + #include "sieve-extensions.h" +@@ -117,17 +116,6 @@ bool sieve_binary_dumper_run(struct siev + "flags = 0x%08"PRIx32"\n", + header->version_major, header->version_minor, + header->flags); +- if (header->resource_usage.update_time != 0) { +- time_t update_time = +- (time_t)header->resource_usage.update_time; +- sieve_binary_dumpf(denv, +- "resource usage:\n" +- " update time = %s\n" +- " cpu time = %"PRIu32" ms\n", +- t_strflocaltime("%Y-%m-%d %H:%M:%S", +- update_time), +- header->resource_usage.cpu_time_msecs); +- } + + /* Dump list of binary blocks */ + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-file.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +@@ -147,47 +147,15 @@ sieve_binary_file_read_header(struct sie + return 0; + } + +-static int +-sieve_binary_file_write_header(struct sieve_binary *sbin, int fd, +- struct sieve_binary_header *header, +- enum sieve_error *error_code_r) +-{ +- ssize_t wret; +- +- wret = pwrite(fd, header, sizeof(*header), 0); +- if (wret < 0) { +- e_error(sbin->event, "update: " +- "failed to write to binary: %m"); +- *error_code_r = SIEVE_ERROR_TEMP_FAILURE; +- return -1; +- } else if (wret != sizeof(*header)) { +- e_error(sbin->event, "update: " +- "header written partially %zd/%zu", +- wret, sizeof(*header)); +- *error_code_r = SIEVE_ERROR_TEMP_FAILURE; +- return -1; +- } +- return 0; +-} +- +-static void sieve_binary_file_update_header(struct sieve_binary *sbin) ++/* The on-disk binary header carries an unused resource_usage struct for ++ backward compatibility. Tracking lives in the per-user sieve-rusage file. */ ++static void sieve_binary_file_zero_header_rusage(struct sieve_binary *sbin) + { + struct sieve_binary_header *header = &sbin->header; +- struct sieve_resource_usage rusage; +- +- sieve_binary_get_resource_usage(sbin, &rusage); + + i_zero(&header->resource_usage); +- if (HAS_ALL_BITS(header->flags, SIEVE_BINARY_FLAG_RESOURCE_LIMIT) || +- sieve_resource_usage_is_high(sbin->svinst, &rusage)) { +- header->resource_usage.update_time = ioloop_time; +- header->resource_usage.cpu_time_msecs = rusage.cpu_time_msecs; +- } +- + sieve_resource_usage_init(&sbin->rusage); + sbin->rusage_updated = FALSE; +- +- (void)sieve_binary_check_resource_usage(sbin); + } + + /* +@@ -347,7 +315,7 @@ sieve_binary_save_to_stream(struct sieve + header->hdr_size = sizeof(*header); + + header->flags &= ENUM_NEGATE(SIEVE_BINARY_FLAG_RESOURCE_LIMIT); +- sieve_binary_file_update_header(sbin); ++ sieve_binary_file_zero_header_rusage(sbin); + + if (!_save_aligned(sbin, stream, header, sizeof(*header), NULL)) { + e_error(sbin->event, "save: failed to save header"); +@@ -864,6 +832,11 @@ _sieve_binary_open(struct sieve_binary * + return FALSE; + offset = sbin->header.hdr_size; + ++ /* Resource usage tracking is no longer kept in the binary file; ++ discard any data from older versions. */ ++ i_zero(&sbin->header.resource_usage); ++ sbin->header.flags &= ENUM_NEGATE(SIEVE_BINARY_FLAG_RESOURCE_LIMIT); ++ + /* Load block index */ + + for (i = 0; i < sbin->header.blocks && result; i++) { +@@ -979,47 +952,27 @@ int sieve_binary_check_executable(struct + * Resource usage + */ + +-static int +-sieve_binary_file_do_update_resource_usage( +- struct sieve_binary *sbin, int fd, enum sieve_error *error_code_r) +-{ +- struct sieve_binary_header *header = &sbin->header; +- struct file_lock *lock; +- const char *error; +- int ret; +- +- struct file_lock_settings lock_set = { +- .lock_method = FILE_LOCK_METHOD_FCNTL, +- }; +- ret = file_wait_lock(fd, sbin->path, F_WRLCK, &lock_set, +- SIEVE_BINARY_FILE_LOCK_TIMEOUT, &lock, &error); +- if (ret <= 0) { +- e_error(sbin->event, "%s", error); +- *error_code_r = SIEVE_ERROR_TEMP_FAILURE; +- return -1; +- } +- +- ret = sieve_binary_file_read_header(sbin, fd, header, error_code_r); +- if (ret == 0) { +- sieve_binary_file_update_header(sbin); +- ret = sieve_binary_file_write_header(sbin, fd, header, +- error_code_r); +- } +- +- file_lock_free(&lock); +- +- return ret; +-} +- +-static int +-sieve_binary_storage_update_resource_usage(struct sieve_binary *sbin, +- struct sieve_storage *storage, +- enum sieve_error *error_code_r) ++int sieve_binary_file_update_resource_usage(struct sieve_binary *sbin, ++ enum sieve_error *error_code_r) + { ++ struct sieve_storage *storage; + struct sieve_resource_usage delta; + struct sieve_resource_usage total; + uint32_t flags; + ++ sieve_error_args_init(&error_code_r, NULL); ++ ++ sieve_binary_file_close(&sbin->file); ++ ++ if (sbin->script == NULL || sbin->script->storage == NULL || ++ sbin->script->storage->rusage_path == NULL) { ++ /* No per-user file: tracking discarded. */ ++ sieve_resource_usage_init(&sbin->rusage); ++ sbin->rusage_updated = FALSE; ++ return 0; ++ } ++ storage = sbin->script->storage; ++ + delta = sbin->rusage; + sieve_binary_get_resource_usage(sbin, &total); + flags = sbin->header.flags & SIEVE_BINARY_FLAG_RESOURCE_LIMIT; +@@ -1041,47 +994,3 @@ sieve_binary_storage_update_resource_usa + sbin->rusage_updated = FALSE; + return 0; + } +- +-int sieve_binary_file_update_resource_usage(struct sieve_binary *sbin, +- enum sieve_error *error_code_r) +-{ +- struct sieve_storage *storage = NULL; +- int fd, ret = 0; +- +- sieve_error_args_init(&error_code_r, NULL); +- +- sieve_binary_file_close(&sbin->file); +- +- if (sbin->script != NULL && sbin->script->storage != NULL && +- sbin->script->storage->rusage_path != NULL) +- storage = sbin->script->storage; +- +- if (storage != NULL) { +- return sieve_binary_storage_update_resource_usage( +- sbin, storage, error_code_r); +- } +- +- if (sbin->path == NULL) +- return 0; +- if (sbin->header.version_major != SIEVE_BINARY_VERSION_MAJOR) { +- return sieve_binary_save(sbin, sbin->path, TRUE, 0600, +- error_code_r); +- } +- +- fd = sieve_binary_fd_open(sbin, sbin->path, O_RDWR, error_code_r); +- if (fd < 0) { +- i_assert(*error_code_r != SIEVE_ERROR_NONE); +- return -1; +- } +- +- ret = sieve_binary_file_do_update_resource_usage(sbin, fd, +- error_code_r); +- i_assert(ret == 0 || *error_code_r != SIEVE_ERROR_NONE); +- +- if (close(fd) < 0) { +- e_error(sbin->event, "update: " +- "failed to close: close() failed: %m"); +- } +- +- return ret; +-} +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-private.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-private.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-private.h +@@ -98,6 +98,12 @@ struct sieve_binary { + struct sieve_binary_header header; + struct sieve_resource_usage rusage; + ++ /* Cumulative CPU usage loaded from per-user sieve-rusage file */ ++ struct { ++ time_t update_time; ++ uint32_t cpu_time_msecs; ++ } persisted_rusage; ++ + /* When the binary is loaded into memory or when it is being constructed + by the generator, extensions can be associated to the binary. The + extensions array is a sequential list of all linked extensions. The +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary.c +@@ -218,8 +218,8 @@ void sieve_binary_apply_persisted_rusage + if (sieve_rusage_storage_load(storage, &rusage, &flags) <= 0) + return; + +- header->resource_usage.cpu_time_msecs = rusage.cpu_time_msecs; +- header->resource_usage.update_time = ioloop_time; ++ sbin->persisted_rusage.cpu_time_msecs = rusage.cpu_time_msecs; ++ sbin->persisted_rusage.update_time = ioloop_time; + header->flags |= (flags & SIEVE_BINARY_FLAG_RESOURCE_LIMIT); + sieve_resource_usage_init(&sbin->rusage); + sbin->rusage_updated = FALSE; +@@ -228,15 +228,14 @@ void sieve_binary_apply_persisted_rusage + void sieve_binary_get_resource_usage(struct sieve_binary *sbin, + struct sieve_resource_usage *rusage_r) + { +- struct sieve_binary_header *header = &sbin->header; +- time_t update_time = header->resource_usage.update_time; ++ time_t update_time = sbin->persisted_rusage.update_time; + unsigned int timeout = sbin->svinst->set->resource_usage_timeout; + + if (update_time != 0 && (ioloop_time - update_time) > (time_t)timeout) +- i_zero(&header->resource_usage); ++ i_zero(&sbin->persisted_rusage); + + sieve_resource_usage_init(rusage_r); +- rusage_r->cpu_time_msecs = header->resource_usage.cpu_time_msecs; ++ rusage_r->cpu_time_msecs = sbin->persisted_rusage.cpu_time_msecs; + sieve_resource_usage_add(rusage_r, &sbin->rusage); + } + +@@ -278,9 +277,7 @@ bool sieve_binary_record_resource_usage( + void sieve_binary_set_resource_usage(struct sieve_binary *sbin, + const struct sieve_resource_usage *rusage) + { +- struct sieve_binary_header *header = &sbin->header; +- +- i_zero(&header->resource_usage); ++ i_zero(&sbin->persisted_rusage); + sbin->rusage = *rusage; + sbin->rusage_updated = TRUE; + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-actions-Create-fileinto-mailbox-without-th.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-actions-Create-fileinto-mailbox-without-th.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-actions-Create-fileinto-mailbox-without-th.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-actions-Create-fileinto-mailbox-without-th.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,146 @@ +From eec184745fabe1b2da0d5cc343f2a46710b70fc7 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 11 Jun 2026 19:33:23 +0000 +Subject: [PATCH 2/2] lib-sieve: actions - Create fileinto mailbox without the + autocreate flag + +Stop setting MAILBOX_FLAG_AUTO_CREATE/SUBSCRIBE in act_store. Instead, on +mailbox_open() returning MAIL_ERROR_NOTFOUND, create the mailbox explicitly +with sieve_act_store_create_mailbox() when autocreation is enabled (the +:create side effect already creates it explicitly). + +This makes the creation go through the normal ACL CREATE gate, so a fileinto +(with lda_mailbox_autocreate=yes) or fileinto :create can no longer create a +mailbox in a shared or public namespace that the user lacks the CREATE right +for. + +A permission failure to create the mailbox is classified by +sieve_act_store_create_error_status(): for a user's personal script it is a +permanent failure (implicit keep), while for a global (administrator-defined) +script it is deferred (temporary failure), so the administrator notices the +script is behaving in a way they did not intend. +--- + src/lib-sieve/sieve-actions.c | 56 +++++++++++++++++++++++++++-------- + src/lib-sieve/sieve-actions.h | 11 ++++++- + 2 files changed, 53 insertions(+), 14 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-actions.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-actions.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-actions.c +@@ -372,6 +372,31 @@ void sieve_act_store_get_storage_error(c + &trans->error_code)); + } + ++int sieve_act_store_create_error_status( ++ const struct sieve_action_exec_env *aenv, enum mail_error error_code) ++{ ++ const struct sieve_execute_env *eenv = aenv->exec_env; ++ ++ switch (error_code) { ++ case MAIL_ERROR_NONE: ++ return SIEVE_EXEC_OK; ++ case MAIL_ERROR_TEMP: ++ return SIEVE_EXEC_TEMP_FAILURE; ++ case MAIL_ERROR_PERM: ++ /* The mailbox cannot be created because of insufficient ++ permissions. This is a permanent condition. For a user's ++ personal script, fall through to implicit keep rather than ++ deferring the message. For a global (administrator-defined) ++ script, defer instead, so that the administrator notices the ++ script is behaving in a way they did not intend. */ ++ if ((eenv->flags & SIEVE_EXECUTE_FLAG_NOGLOBAL) != 0) ++ return SIEVE_EXEC_FAILURE; ++ return SIEVE_EXEC_TEMP_FAILURE; ++ default: ++ return SIEVE_EXEC_FAILURE; ++ } ++} ++ + int sieve_act_store_create_mailbox(const struct sieve_action_exec_env *aenv, + struct act_store_transaction *trans) + { +@@ -384,13 +409,13 @@ int sieve_act_store_create_mailbox(const + /* Create mailbox */ + if (mailbox_create(box, NULL, FALSE) < 0) { + sieve_act_store_get_storage_error(aenv, trans); +- if (trans->error_code == MAIL_ERROR_EXISTS) { +- trans->error = NULL; +- trans->error_code = MAIL_ERROR_NONE; +- } else { +- return (trans->error_code == MAIL_ERROR_TEMP ? +- SIEVE_EXEC_TEMP_FAILURE : SIEVE_EXEC_FAILURE); ++ if (trans->error_code != MAIL_ERROR_EXISTS) { ++ return sieve_act_store_create_error_status( ++ aenv, trans->error_code); + } ++ /* Lost a race with another session; the mailbox exists now. */ ++ trans->error = NULL; ++ trans->error_code = MAIL_ERROR_NONE; + } + + /* Subscribe to it if necessary */ +@@ -402,10 +427,9 @@ int sieve_act_store_create_mailbox(const + + /* Try opening again */ + if (mailbox_open(box) < 0) { +- /* Failed definitively */ + sieve_act_store_get_storage_error(aenv, trans); +- return (trans->error_code == MAIL_ERROR_TEMP ? +- SIEVE_EXEC_TEMP_FAILURE : SIEVE_EXEC_FAILURE); ++ return sieve_act_store_create_error_status( ++ aenv, trans->error_code); + } + return SIEVE_EXEC_OK; + } +@@ -434,10 +458,6 @@ act_store_mailbox_alloc(const struct sie + return FALSE; + } + +- if (eenv->scriptenv->mailbox_autocreate) +- flags |= MAILBOX_FLAG_AUTO_CREATE; +- if (eenv->scriptenv->mailbox_autosubscribe) +- flags |= MAILBOX_FLAG_AUTO_SUBSCRIBE; + *box_r = box = mailbox_alloc_for_user(eenv->scriptenv->user, mailbox, + flags); + *storage = mailbox_get_storage(box); +@@ -640,6 +660,16 @@ act_store_execute(const struct sieve_act + } + } + ++ /* Create the mailbox if it does not exist yet and autocreation is ++ enabled (e.g. lda_mailbox_autocreate=yes). The :create side effect ++ creates it earlier; this handles a plain fileinto. */ ++ if (trans->error_code == MAIL_ERROR_NOTFOUND && ++ eenv->scriptenv->mailbox_autocreate) { ++ int cstatus = sieve_act_store_create_mailbox(aenv, trans); ++ if (cstatus != SIEVE_EXEC_OK) ++ return cstatus; ++ } ++ + /* Exit early if transaction already failed */ + switch (trans->error_code) { + case MAIL_ERROR_NONE: +Index: trixie/pigeonhole/src/lib-sieve/sieve-actions.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-actions.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-actions.h +@@ -246,8 +246,17 @@ void sieve_act_store_add_flags(const str + void sieve_act_store_get_storage_error(const struct sieve_action_exec_env *aenv, + struct act_store_transaction *trans); + ++/* Map a failure to create the target mailbox to an execution status. A ++ permanent failure (e.g. the user lacks permission to create the mailbox) ++ makes a user's personal script fall through to implicit keep, but is ++ deferred for a global (administrator-defined) script, so that the ++ unintended behaviour is surfaced to the administrator. */ ++int sieve_act_store_create_error_status( ++ const struct sieve_action_exec_env *aenv, enum mail_error error_code); ++ + /* Create the target mailbox of the store transaction (and subscribe to it +- when configured). */ ++ when configured). Returns SIEVE_EXEC_OK on success, or the status from ++ sieve_act_store_create_error_status() on failure. */ + int sieve_act_store_create_mailbox(const struct sieve_action_exec_env *aenv, + struct act_store_transaction *trans); + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-storage-file-Validate-script-stat-path-thr.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-storage-file-Validate-script-stat-path-thr.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-storage-file-Validate-script-stat-path-thr.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-storage-file-Validate-script-stat-path-thr.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,129 @@ +From 915cb2edc90da04d8a7a122fef7932cd55e3fff2 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 4 May 2026 13:10:13 +0000 +Subject: [PATCH 2/2] lib-sieve: storage: file - Validate script stat path + through dir_fd + +Extend the symlink-escape protection added in the previous commit to the +stat performed by sieve_file_script_open(): an "include :personal" lookup +or any other indirect path that triggers sieve_file_script_stat() also +needs to refuse a symlink whose target leaves the personal storage +directory, otherwise the existence check succeeds and the file is opened +later via the safe path with an unhelpful "permission denied". + +Add sieve_file_script_stat_safe(), which uses fstatat(AT_SYMLINK_NOFOLLOW) +to obtain the entry's own stat (lnk_st) and then, only if the entry is a +symlink, opens it through sieve_file_storage_open_safe() to validate the +target stays inside dir_fd and to fetch the resolved target's stat (st) +via fstat(). Non-symlink entries skip the open entirely. + +Use the new helper in sieve_file_script_open() whenever fstorage->dir_fd +is available, falling back to the unsafe lstat+stat variant for +non-personal or single-file storages. +--- + .../storage/file/sieve-file-script.c | 78 ++++++++++++++++++- + 1 file changed, 75 insertions(+), 3 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c ++++ trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c +@@ -285,6 +285,57 @@ sieve_file_script_stat(const char *path, + return 0; + } + ++/* TOCTOU-safe variant of sieve_file_script_stat() for directory storages. ++ Resolves the script entry beneath fstorage->dir_fd, refusing symlinks ++ whose target escapes the storage directory. *st gets the (resolved) ++ target's stat; *lnk_st gets the directory entry's own stat (i.e. ++ AT_SYMLINK_NOFOLLOW), matching the semantics of the unsafe variant. */ ++static int ++sieve_file_script_stat_safe(struct sieve_file_storage *fstorage, ++ const char *filename, struct stat *st, ++ struct stat *lnk_st, const char **error_r) ++{ ++ int fd, saved_errno; ++ ++ i_assert(fstorage->dir_fd >= 0); ++ ++ if (fstatat(fstorage->dir_fd, filename, lnk_st, ++ AT_SYMLINK_NOFOLLOW) < 0) { ++ *error_r = t_strdup_printf( ++ "fstatat(%s/%s) failed: %m", ++ fstorage->path, filename); ++ return -1; ++ } ++ ++ if (!S_ISLNK(lnk_st->st_mode)) { ++ *st = *lnk_st; ++ return 0; ++ } ++ ++ /* Symlink: open it via the safe walker (which refuses any target ++ that leaves fstorage->dir_fd) and read st from the resulting fd. ++ The fd is only used to fetch the resolved stat and is closed ++ immediately. Open with O_NONBLOCK so that a target which resolves ++ to a FIFO or other special file inside the storage directory only ++ yields its stat instead of blocking the delivery process. */ ++ if (sieve_file_storage_open_safe(fstorage, filename, ++ O_RDONLY | O_NONBLOCK, ++ &fd, error_r) < 0) ++ return -1; ++ ++ if (fstat(fd, st) < 0) { ++ saved_errno = errno; ++ *error_r = t_strdup_printf( ++ "fstat() failed for '%s/%s': %m", ++ fstorage->path, filename); ++ i_close_fd(&fd); ++ errno = saved_errno; ++ return -1; ++ } ++ i_close_fd(&fd); ++ return 0; ++} ++ + static const char * + path_split_filename(const char *path, const char **dir_path_r) + { +@@ -353,7 +404,25 @@ static int sieve_file_script_open(struct + dir_path = path; + + path = sieve_file_storage_path_extend(fstorage, filename); +- ret = sieve_file_script_stat(path, &st, &lnk_st); ++ if (fstorage->dir_fd >= 0) { ++ const char *serror; ++ ++ ret = sieve_file_script_stat_safe( ++ fstorage, filename, &st, ++ &lnk_st, &serror); ++ if (ret < 0 && errno == ELOOP) { ++ sieve_script_set_critical( ++ script, ++ "Failed to open sieve script: %s", ++ serror); ++ script->storage->error_code = ++ SIEVE_ERROR_NO_PERMISSION; ++ success = FALSE; ++ } ++ } else { ++ ret = sieve_file_script_stat( ++ path, &st, &lnk_st); ++ } + } + + } else { +@@ -451,8 +520,11 @@ sieve_file_script_get_stream(struct siev + + /* For directory-based storage, open the script via the storage + directory fd so that path resolution refuses to follow symlinks +- whose (recursive) target leaves the storage directory. +- Single-file storages have no dir_fd, so fall back to plain open(). */ ++ whose (recursive) target leaves the storage directory. The fd-based ++ walk also makes this TOCTOU-safe: even if intermediate path ++ components are mutated between the stat in sieve_file_script_open() ++ and this call, the resolution stays within the original directory. ++ Single-file storages have no dir_fd, so fall back to a plain open(). */ + if (fstorage->dir_fd >= 0 && fscript->filename != NULL && + *fscript->filename != '\0') { + if (sieve_file_storage_open_safe(fstorage, fscript->filename, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-util-test-edit-mail-Adjust-to-changes-in-d.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-util-test-edit-mail-Adjust-to-changes-in-d.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-util-test-edit-mail-Adjust-to-changes-in-d.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sieve-util-test-edit-mail-Adjust-to-changes-in-d.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,104 @@ +From fe17ee9e58d2eb7dab38976518380279112c0bca Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Wed, 29 Oct 2025 17:49:10 +0100 +Subject: [PATCH 2/3] lib-sieve: util: test-edit-mail - Adjust to changes in + dovecot core lib-test + +--- + src/lib-sieve/util/test-edit-mail.c | 20 ++++++++++---------- + 1 file changed, 10 insertions(+), 10 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/util/test-edit-mail.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/util/test-edit-mail.c ++++ trixie/pigeonhole/src/lib-sieve/util/test-edit-mail.c +@@ -99,7 +99,7 @@ static void test_deinit_mail_user() + i_error("unlink_directory(%s) failed: %s", mail_home, error); + } + +-static void test_init(void) ++static void test_edit_mail_init(void) + { + test_pool = pool_alloconly_create(MEMPOOL_GROWING"test pool", 128); + +@@ -107,7 +107,7 @@ static void test_init(void) + test_raw_mail_user = mail_raw_user_create(test_mail_user); + } + +-static void test_deinit(void) ++static void test_edit_mail_deinit(void) + { + mail_user_unref(&test_raw_mail_user); + test_deinit_mail_user(); +@@ -186,7 +186,7 @@ static void test_edit_mail_concatenated( + const char *value; + + test_begin("edit-mail - concatenated"); +- test_init(); ++ test_edit_mail_init(); + + /* Compose the message */ + +@@ -434,7 +434,7 @@ static void test_edit_mail_concatenated( + edit_mail_unwrap(&edmail); + mail_raw_close(&rawmail); + i_stream_unref(&input_msg); +- test_deinit(); ++ test_edit_mail_deinit(); + test_end(); + } + +@@ -696,7 +696,7 @@ static void test_edit_mail_big_header(vo + const char *value; + + test_begin("edit-mail - big header"); +- test_init(); ++ test_edit_mail_init(); + + /* compose the message */ + +@@ -737,7 +737,7 @@ static void test_edit_mail_big_header(vo + edit_mail_unwrap(&edmail); + mail_raw_close(&rawmail); + i_stream_unref(&input_msg); +- test_deinit(); ++ test_edit_mail_deinit(); + test_end(); + } + +@@ -757,7 +757,7 @@ static void test_edit_mail_small_buffer( + unsigned int i; + + test_begin("edit-mail - small buffer"); +- test_init(); ++ test_edit_mail_init(); + + /* compose the message */ + +@@ -806,7 +806,7 @@ static void test_edit_mail_small_buffer( + edit_mail_unwrap(&edmail); + mail_raw_close(&rawmail); + i_stream_unref(&input_msg); +- test_deinit(); ++ test_edit_mail_deinit(); + test_end(); + } + +@@ -820,7 +820,7 @@ static void test_edit_mail_empty(void) + const char *value; + + test_begin("edit-mail - empty message"); +- test_init(); ++ test_edit_mail_init(); + + /* Compose the message */ + +@@ -862,7 +862,7 @@ static void test_edit_mail_empty(void) + edit_mail_unwrap(&edmail); + mail_raw_close(&rawmail); + i_stream_unref(&input_msg); +- test_deinit(); ++ test_edit_mail_deinit(); + test_end(); + } + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sql-Extract-sql_query_callback_delayed-helper.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sql-Extract-sql_query_callback_delayed-helper.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sql-Extract-sql_query_callback_delayed-helper.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-sql-Extract-sql_query_callback_delayed-helper.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,53 @@ +From 9582563d2c261cda1cf7c59fb262292d1438cd73 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 29 May 2026 11:21:26 +0000 +Subject: [PATCH 2/6] lib-sql: Extract sql_query_callback_delayed() helper + +--- + src/lib-sql/sql-api.c | 23 ++++++++++++++++------- + 1 file changed, 16 insertions(+), 7 deletions(-) + +diff --git a/src/lib-sql/sql-api.c b/src/lib-sql/sql-api.c +index 4db6913229..5ee13f9b32 100644 +--- a/src/lib-sql/sql-api.c ++++ b/src/lib-sql/sql-api.c +@@ -109,6 +109,20 @@ static struct sql_result *sql_result_new_error(const char *error) + return &result->result; + } + ++static void ++sql_query_callback_delayed(struct sql_db *db, struct sql_result *result, ++ sql_query_callback_t *callback, void *context) ++{ ++ struct sql_query_result_delayed *cb = ++ i_new(struct sql_query_result_delayed, 1); ++ cb->db = db; ++ cb->result = result; ++ cb->callback = callback; ++ cb->context = context; ++ cb->to = timeout_add_short(0, sql_query_delayed_callback, cb); ++ DLLIST_PREPEND(&db->query_delayed_list, cb); ++} ++ + void sql_drivers_init_without_drivers(void) + { + i_array_init(&sql_drivers, 8); +@@ -347,13 +361,8 @@ void sql_query(struct sql_db *db, const char *query, + return; + } + +- struct sql_query_result_delayed *cb = i_new(struct sql_query_result_delayed, 1); +- cb->db = db; +- cb->result = sql_query_s(db, query); +- cb->callback = callback; +- cb->context = context; +- cb->to = timeout_add_short(0, sql_query_delayed_callback, cb); +- DLLIST_PREPEND(&db->query_delayed_list, cb); ++ sql_query_callback_delayed(db, sql_query_s(db, query), ++ callback, context); + } + + struct sql_result *sql_query_s(struct sql_db *db, const char *query) +-- +2.39.5 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Remove-MAILBOX_FLAG_AUTO_CREATE-and-MAIL.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Remove-MAILBOX_FLAG_AUTO_CREATE-and-MAIL.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Remove-MAILBOX_FLAG_AUTO_CREATE-and-MAIL.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Remove-MAILBOX_FLAG_AUTO_CREATE-and-MAIL.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,70 @@ +From e6c952b3ab417f97e9bbc3534b3e40131e808b1e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 11 Jun 2026 17:20:40 +0000 +Subject: [PATCH 2/2] lib-storage: Remove MAILBOX_FLAG_AUTO_CREATE and + MAILBOX_FLAG_AUTO_SUBSCRIBE + +These flags were set only by mail delivery (lib-lda) and Sieve fileinto +(Pigeonhole), both of which now create the target mailbox explicitly with +mailbox_create(). Nothing sets the flags anymore, so remove them and drop +the flag checks from mailbox_is_autocreated() and mailbox_is_autosubscribed(). + +As a result mailbox_is_autocreated() reflects only mailboxes that virtually +already exist (the user's INBOX and admin-configured "auto" mailboxes), so +the ACL plugin again enforces the CREATE right for delivery-autocreated +mailboxes in shared and public namespaces. +--- + src/lib-storage-lua/mail-storage-lua.c | 2 -- + src/lib-storage/mail-storage.c | 4 ---- + src/lib-storage/mail-storage.h | 4 ---- + 3 files changed, 10 deletions(-) + +Index: trixie/src/lib-storage/mail-storage.c +=================================================================== +--- trixie.orig/src/lib-storage/mail-storage.c ++++ trixie/src/lib-storage/mail-storage.c +@@ -1375,16 +1375,12 @@ bool mailbox_is_autocreated(struct mailb + { + if (box->inbox_user) + return TRUE; +- if ((box->flags & MAILBOX_FLAG_AUTO_CREATE) != 0) +- return TRUE; + return box->set != NULL && + strcmp(box->set->autocreate, MAILBOX_SET_AUTO_NO) != 0; + } + + bool mailbox_is_autosubscribed(struct mailbox *box) + { +- if ((box->flags & MAILBOX_FLAG_AUTO_SUBSCRIBE) != 0) +- return TRUE; + return box->set != NULL && + strcmp(box->set->autocreate, MAILBOX_SET_AUTO_SUBSCRIBE) == 0; + } +Index: trixie/src/lib-storage/mail-storage.h +=================================================================== +--- trixie.orig/src/lib-storage/mail-storage.h ++++ trixie/src/lib-storage/mail-storage.h +@@ -74,10 +74,6 @@ enum mailbox_flags { + quota updates (possibly resulting in broken quota). and This is + useful for example when deleting entire user accounts. */ + MAILBOX_FLAG_DELETE_UNSAFE = 0x400, +- /* Mailbox is created implicitly if it does not exist. */ +- MAILBOX_FLAG_AUTO_CREATE = 0x1000, +- /* Mailbox is subscribed to implicitly when it is created automatically */ +- MAILBOX_FLAG_AUTO_SUBSCRIBE = 0x2000, + /* Run fsck for mailbox index before doing anything else. This may be + useful in fixing index corruption errors that aren't otherwise + detected and that are causing the full mailbox opening to fail. */ +Index: trixie/src/lib-storage/mail-storage-lua.c +=================================================================== +--- trixie.orig/src/lib-storage/mail-storage-lua.c ++++ trixie/src/lib-storage/mail-storage-lua.c +@@ -33,8 +33,6 @@ static struct dlua_table_values lua_stor + DLUA_TABLE_ENUM(MAILBOX_FLAG_NO_INDEX_FILES), + DLUA_TABLE_ENUM(MAILBOX_FLAG_KEEP_LOCKED), + DLUA_TABLE_ENUM(MAILBOX_FLAG_IGNORE_ACLS), +- DLUA_TABLE_ENUM(MAILBOX_FLAG_AUTO_CREATE), +- DLUA_TABLE_ENUM(MAILBOX_FLAG_AUTO_SUBSCRIBE), + + DLUA_TABLE_ENUM(MAILBOX_SYNC_FLAG_FULL_READ), + DLUA_TABLE_ENUM(MAILBOX_SYNC_FLAG_FAST), diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Simplify-mailbox_mkdir-using-mailbox_lis.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Simplify-mailbox_mkdir-using-mailbox_lis.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Simplify-mailbox_mkdir-using-mailbox_lis.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-Simplify-mailbox_mkdir-using-mailbox_lis.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,31 @@ +From 01b4ddd1de4b5bfd499891f4b3592880b02c3854 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 23 Jun 2026 15:26:51 +0000 +Subject: [PATCH 2/2] lib-storage: Simplify mailbox_mkdir() using + mailbox_list_mkdir_root(NULL) + +mailbox_mkdir() looked up the root path via mailbox_list_get_root_forced() +only to pass it straight to mailbox_list_mkdir_root(). Let mkdir_root() +resolve the root itself by passing path=NULL, dropping the local variable. +--- + src/lib-storage/mail-storage.c | 4 +--- + 1 file changed, 1 insertion(+), 3 deletions(-) + +Index: trixie/src/lib-storage/mail-storage.c +=================================================================== +--- trixie.orig/src/lib-storage/mail-storage.c ++++ trixie/src/lib-storage/mail-storage.c +@@ -3441,12 +3441,10 @@ int mailbox_mkdir(struct mailbox *box, c + enum mailbox_list_path_type type) + { + const struct mailbox_permissions *perm = mailbox_get_permissions(box); +- const char *root_dir; + + if (!perm->gid_origin_is_mailbox_path) { + /* mailbox root directory doesn't exist, create it */ +- root_dir = mailbox_list_get_root_forced(box->list, type); +- if (mailbox_list_mkdir_root(box->list, root_dir, type) < 0) { ++ if (mailbox_list_mkdir_root(box->list, NULL, type) < 0) { + mail_storage_copy_list_error(box->storage, box->list); + return -1; + } diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-thread-Limit-References-header-msgid-cou.patch dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-thread-Limit-References-header-msgid-cou.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-thread-Limit-References-header-msgid-cou.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-lib-storage-thread-Limit-References-header-msgid-cou.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,47 @@ +From fb14defe1804881a1df50c0b29679f9fdf8ff5b0 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 16:26:47 +0000 +Subject: [PATCH 2/3] lib-storage: thread - Limit References: header msgid + count to prevent O(N^2) CPU usage + +Cap per-message References ingestion at MAIL_THREAD_REFERENCES_MAX (1000) +in mail_thread_map_add_mail(). Without this limit a single crafted email +with N unique Message-IDs triggers N(N-1)/2 ancestor traversals in +thread_node_has_ancestor(), allowing unauthenticated DoS via mail delivery. +--- + src/lib-storage/index/index-thread-private.h | 4 ++++ + src/lib-storage/index/index-thread.c | 3 ++- + 2 files changed, 6 insertions(+), 1 deletion(-) + +diff --git a/src/lib-storage/index/index-thread-private.h b/src/lib-storage/index/index-thread-private.h +index ed2837b7bb..563c3a3d2d 100644 +--- a/src/lib-storage/index/index-thread-private.h ++++ b/src/lib-storage/index/index-thread-private.h +@@ -22,6 +22,10 @@ + #define MAIL_THREAD_NODE_REF_MSGID 0 + #define MAIL_THREAD_NODE_REF_INREPLYTO 1 + #define MAIL_THREAD_NODE_REF_REFERENCES1 2 ++/* Maximum number of References: header message-IDs stored per message. ++ Prevents O(N²) CPU usage in thread_node_has_ancestor() when a single ++ crafted message contains an abnormally long reference chain. */ ++#define MAIL_THREAD_REFERENCES_MAX 1000 + + struct mail_thread_node { + /* UID of the message, or 0 for dummy nodes */ +diff --git a/src/lib-storage/index/index-thread.c b/src/lib-storage/index/index-thread.c +index e20a97072c..a58f9681f3 100644 +--- a/src/lib-storage/index/index-thread.c ++++ b/src/lib-storage/index/index-thread.c +@@ -280,7 +280,8 @@ mail_thread_map_add_mail(struct mail_thread_context *ctx, struct mail *mail) + ref_index, msgid); + } + } T_END; +- } while (msgid != NULL); ++ } while (msgid != NULL && ++ ref_index < MAIL_THREAD_NODE_REF_REFERENCES1 + MAIL_THREAD_REFERENCES_MAX); + } else { + /* no References:, use In-Reply-To: */ + if (thread_get_mail_header(mail, HDR_IN_REPLY_TO, +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-login-common-Avoid-array_front-panic-on-empty-forwar.patch dovecot-2.4.1+dfsg1/debian/patches/0002-login-common-Avoid-array_front-panic-on-empty-forwar.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-login-common-Avoid-array_front-panic-on-empty-forwar.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-login-common-Avoid-array_front-panic-on-empty-forwar.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,50 @@ +From 54211965c41eb15290bae017b8acfd95d08e0a9e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sun, 3 May 2026 16:24:38 +0000 +Subject: [PATCH 2/2] login-common: Avoid array_front() panic on empty + forward_fields array + +sasl_server_auth_request_info_fill() and proxy_redirect_reauth() +NUL-terminate the forward_fields array via array_append_zero() + +array_pop_back() and then call array_front() to hand the C array to +the auth client. array_front() asserts when the array is empty, so a +created-but-empty forward_fields array crashes login. + +The empty-array case is no longer reachable from +client_forward_decode_base64() after the previous commit, but guard +defensively here as well: any future caller that creates the array +without populating it should not be able to panic the process. +--- + src/login-common/client-common-auth.c | 2 +- + src/login-common/sasl-server.c | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/login-common/client-common-auth.c b/src/login-common/client-common-auth.c +index 0b226497c6..36ed7211a7 100644 +--- a/src/login-common/client-common-auth.c ++++ b/src/login-common/client-common-auth.c +@@ -490,7 +490,7 @@ proxy_redirect_reauth(struct client *client, const char *destuser, + t_array_init(&info.extra_fields, N_ELEMENTS(extra_fields)); + array_append(&info.extra_fields, extra_fields, + N_ELEMENTS(extra_fields)); +- if (array_is_created(&client->forward_fields)) { ++ if (array_not_empty(&client->forward_fields)) { + array_append_zero(&client->forward_fields); + array_pop_back(&client->forward_fields); + info.forward_fields = array_front(&client->forward_fields); +diff --git a/src/login-common/sasl-server.c b/src/login-common/sasl-server.c +index f02262c00f..88d46e7c68 100644 +--- a/src/login-common/sasl-server.c ++++ b/src/login-common/sasl-server.c +@@ -534,7 +534,7 @@ int sasl_server_auth_request_info_fill(struct client *client, + info_r->real_remote_port = client->real_remote_port; + if (client->client_id != NULL) + info_r->client_id = str_c(client->client_id); +- if (array_is_created(&client->forward_fields)) { ++ if (array_not_empty(&client->forward_fields)) { + array_append_zero(&client->forward_fields); + array_pop_back(&client->forward_fields); + info_r->forward_fields = array_front(&client->forward_fields); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-managesieve-login-client_skip_line-Discard-data-when.patch dovecot-2.4.1+dfsg1/debian/patches/0002-managesieve-login-client_skip_line-Discard-data-when.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-managesieve-login-client_skip_line-Discard-data-when.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-managesieve-login-client_skip_line-Discard-data-when.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,29 @@ +From eee1ecfc512d348f8fb60633b6bd26b1536319da Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 25 May 2026 10:38:13 +0000 +Subject: [PATCH 2/2] managesieve-login: client_skip_line() - Discard data when + newline is not found + +Without this, data without a newline (e.g., from a lone CR protocol violation +detected by the parser) stays in the buffer indefinitely. This prevents the +server from ever detecting client disconnect, causing a deadlock where both +sides wait for the other. + +The post-login server-side client_skip_line() in managesieve-client.c already +handles this correctly by always calling i_stream_skip(). +--- + src/managesieve-login/client.c | 1 + + 1 file changed, 1 insertion(+) + +Index: trixie/pigeonhole/src/managesieve-login/client.c +=================================================================== +--- trixie.orig/pigeonhole/src/managesieve-login/client.c ++++ trixie/pigeonhole/src/managesieve-login/client.c +@@ -51,6 +51,7 @@ bool client_skip_line(struct managesieve + } + } + ++ i_stream_skip(client->input, data_size); + return FALSE; + } + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0002-submission-login-client-Fix-panic-occurring-at-mail_.patch dovecot-2.4.1+dfsg1/debian/patches/0002-submission-login-client-Fix-panic-occurring-at-mail_.patch --- dovecot-2.4.1+dfsg1/debian/patches/0002-submission-login-client-Fix-panic-occurring-at-mail_.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0002-submission-login-client-Fix-panic-occurring-at-mail_.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,78 @@ +From f0bb034096518187b2b97ab25cd62a6d5517a150 Mon Sep 17 00:00:00 2001 +From: Stephan Bosch +Date: Fri, 28 Nov 2025 04:12:10 +0100 +Subject: [PATCH 2/5] submission-login: client - Fix panic occurring at + mail_max_userip_connections limit transgression + +Panic was: +Panic: epoll_ctl(del, 8) failed: Bad file descriptor + +Fixed by making sure the underlying smtp-server connection is always closed before the connection FD is closed. +--- + src/lib-smtp/smtp-server-connection.c | 5 +++++ + src/lib-smtp/smtp-server.h | 1 + + src/submission-login/client.c | 16 ++++++++++++++++ + 3 files changed, 22 insertions(+) + +Index: trixie/src/lib-smtp/smtp-server-connection.c +=================================================================== +--- trixie.orig/src/lib-smtp/smtp-server-connection.c ++++ trixie/src/lib-smtp/smtp-server-connection.c +@@ -1401,6 +1401,11 @@ void smtp_server_connection_close(struct + smtp_server_connection_unref(&conn); + } + ++bool smtp_server_connection_is_closed(struct smtp_server_connection *conn) ++{ ++ return conn->closed; ++} ++ + void smtp_server_connection_terminate(struct smtp_server_connection **_conn, + const char *enh_code, const char *reason) + { +Index: trixie/src/lib-smtp/smtp-server.h +=================================================================== +--- trixie.orig/src/lib-smtp/smtp-server.h ++++ trixie/src/lib-smtp/smtp-server.h +@@ -476,6 +476,7 @@ void smtp_server_connection_set_ssl_stre + + void smtp_server_connection_close(struct smtp_server_connection **_conn, + const char *reason) ATTR_NULL(2); ++bool smtp_server_connection_is_closed(struct smtp_server_connection *conn); + void smtp_server_connection_terminate(struct smtp_server_connection **_conn, + const char *enh_code, const char *reason) + ATTR_NULL(3); +Index: trixie/src/submission-login/client.c +=================================================================== +--- trixie.orig/src/submission-login/client.c ++++ trixie/src/submission-login/client.c +@@ -146,6 +146,21 @@ static int submission_client_create(stru + return 0; + } + ++static void ++submission_client_disconnect(struct client *client, const char *reason) ++{ ++ struct submission_client *subm_client = ++ container_of(client, struct submission_client, common); ++ ++ /* If the smtp-server connection is already in its close cascade (i.e. ++ we're being called via its conn_disconnect callback), skip closing ++ it again. Doing so would null subm_client->conn and prevent the ++ conn_free callback from invoking client_destroy(). */ ++ if (subm_client->conn != NULL && ++ !smtp_server_connection_is_closed(subm_client->conn)) ++ smtp_server_connection_close(&subm_client->conn, reason); ++} ++ + static void submission_client_destroy(struct client *client) + { + struct submission_client *subm_client = +@@ -320,6 +335,7 @@ static const struct smtp_server_callback + static struct client_vfuncs submission_client_vfuncs = { + .alloc = submission_client_alloc, + .create = submission_client_create, ++ .disconnect = submission_client_disconnect, + .destroy = submission_client_destroy, + .reload_config = submission_client_reload_config, + .notify_auth_ready = submission_client_notify_auth_ready, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-auth-db-oauth2-Add-oauth2_audience-setting-deprecate.patch dovecot-2.4.1+dfsg1/debian/patches/0003-auth-db-oauth2-Add-oauth2_audience-setting-deprecate.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-auth-db-oauth2-Add-oauth2_audience-setting-deprecate.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-auth-db-oauth2-Add-oauth2_audience-setting-deprecate.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,131 @@ +From fd7d4fddf1798b118b0c5481a0dabe03250829ca Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 29 May 2026 07:31:50 +0000 +Subject: [PATCH 3/4] auth: db-oauth2: Add oauth2_audience setting, deprecate + aud-as-scope fallback + +Add a dedicated oauth2_audience setting checked against the token's aud +claim (RFC 7519 section 4.1.3) using the same tab-split AND-semantics as +oauth2_scope. Emit a deprecation warning when the existing aud fallback +in db_oauth2_token_in_scope() is triggered so operators know to migrate. +--- + src/auth/db-oauth2.c | 53 ++++++++++++++++++++++++++++++++++++++++++-- + src/auth/db-oauth2.h | 2 ++ + 2 files changed, 53 insertions(+), 2 deletions(-) + +Index: trixie/src/auth/db-oauth2.c +=================================================================== +--- trixie.orig/src/auth/db-oauth2.c ++++ trixie/src/auth/db-oauth2.c +@@ -17,16 +17,21 @@ + #include "db-oauth2.h" + #include "dcrypt.h" + #include "dict.h" ++#include + + #undef DEF + #define DEF(type, name) \ + SETTING_DEFINE_STRUCT_##type("oauth2_"#name, name, struct auth_oauth2_settings) + ++#define WARN_AUD_FALLBACK_PERIOD 600 ++static time_t last_warned_aud_fallback = 0; ++ + static const struct setting_define auth_oauth2_setting_defines[] = { + DEF(STR, tokeninfo_url), + DEF(STR, grant_url), + DEF(STR, introspection_url), + DEF(BOOLLIST, scope), ++ DEF(BOOLLIST, audience), + DEF(ENUM, introspection_mode), + DEF(STR_NOVARS, username_validation_format), + DEF(STR, username_attribute), +@@ -50,6 +55,7 @@ static const struct auth_oauth2_settings + .grant_url = "", + .introspection_url = "", + .scope = ARRAY_INIT, ++ .audience = ARRAY_INIT, + .force_introspection = FALSE, + .introspection_mode = ":auth:get:post:local", + .username_validation_format = "%{user}", +@@ -558,8 +564,19 @@ db_oauth2_token_in_scope(struct db_oauth + + const char *value = auth_fields_find(req->fields, "scope"); + bool has_scope = value != NULL; +- if (!has_scope) ++ if (!has_scope && array_is_empty(&req->db->set->audience)) { + value = auth_fields_find(req->fields, "aud"); ++ if (value != NULL) { ++ time_t t0 = time(NULL); ++ if (t0 - last_warned_aud_fallback > WARN_AUD_FALLBACK_PERIOD) { ++ e_warning(authdb_event(req->auth_request), ++ "Token has no 'scope' claim; falling back to " ++ "'aud' for scope check is deprecated - " ++ "use oauth2_audience instead"); ++ last_warned_aud_fallback = t0; ++ } ++ } ++ } + e_debug(authdb_event(req->auth_request), + "Token scope(s): %s", value); + +@@ -585,6 +602,37 @@ db_oauth2_token_in_scope(struct db_oauth + return found; + } + ++static bool ++db_oauth2_token_in_audience(struct db_oauth2_request *req, ++ enum passdb_result *result_r, const char **error_r) ++{ ++ if (array_is_empty(&req->db->set->audience)) ++ return TRUE; ++ ++ const char *value = auth_fields_find(req->fields, "aud"); ++ e_debug(authdb_event(req->auth_request), ++ "Token audience(s): %s", value != NULL ? value : "(none)"); ++ ++ bool found = FALSE; ++ if (value != NULL && *value != '\0') { ++ const char *const *entries = t_strsplit_tabescaped(value); ++ const char *wanted; ++ found = TRUE; ++ array_foreach_elem(&req->db->set->audience, wanted) { ++ if (!str_array_find(entries, wanted)) { ++ found = FALSE; ++ break; ++ } ++ } ++ } ++ if (!found) { ++ *error_r = t_strdup_printf("Token audience does not include '%s'", ++ t_array_const_string_join(&req->db->set->audience, " ")); ++ *result_r = PASSDB_RESULT_USER_DISABLED; ++ } ++ return found; ++} ++ + static void db_oauth2_process_fields(struct db_oauth2_request *req, + enum passdb_result *result_r, + const char **error_r) +@@ -593,7 +641,8 @@ static void db_oauth2_process_fields(str + + if (db_oauth2_user_is_enabled(req, result_r, error_r) && + db_oauth2_validate_username(req, result_r, error_r) && +- db_oauth2_token_in_scope(req, result_r, error_r)) { ++ db_oauth2_token_in_scope(req, result_r, error_r) && ++ db_oauth2_token_in_audience(req, result_r, error_r)) { + /* The user has now been successfully authenticated, + mark the request as such. This allows having no + passdb in config. */ +Index: trixie/src/auth/db-oauth2.h +=================================================================== +--- trixie.orig/src/auth/db-oauth2.h ++++ trixie/src/auth/db-oauth2.h +@@ -15,6 +15,8 @@ struct auth_oauth2_settings { + const char *introspection_url; + /* expected scope(s), optional */ + ARRAY_TYPE(const_string) scope; ++ /* expected audience(s) (aud claim), optional */ ++ ARRAY_TYPE(const_string) audience; + /* mode of introspection, one of auth, get, post, local + - auth: send token with header Authorization: Bearer token + - get: append token to url diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-doveadm-compress-Delay-failing-if-used-COMPRESS-algo.patch dovecot-2.4.1+dfsg1/debian/patches/0003-doveadm-compress-Delay-failing-if-used-COMPRESS-algo.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-doveadm-compress-Delay-failing-if-used-COMPRESS-algo.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-doveadm-compress-Delay-failing-if-used-COMPRESS-algo.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,41 @@ +From 161256a377c194aa8e63c83053c35cdd1ab75eda Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 7 Jul 2026 14:33:40 +0300 +Subject: [PATCH 3/3] doveadm compress: Delay failing if used COMPRESS + algorithm is not supported locally + +This command is used in CI tests to verify the server rejects non-DEFLATE +algorithm. +--- + src/doveadm/doveadm-compress.c | 9 ++++++++- + 1 file changed, 8 insertions(+), 1 deletion(-) + +Index: trixie/src/doveadm/doveadm-compress.c +=================================================================== +--- trixie.orig/src/doveadm/doveadm-compress.c ++++ trixie/src/doveadm/doveadm-compress.c +@@ -123,9 +123,14 @@ client_input_get_compress_algorithm(stru + if (!str_begins_icase(line, " COMPRESS ", &algorithm)) + return FALSE; + ++ /* Look up the local handler needed to (de)compress the stream once the ++ server accepts. Don't fail here if the mechanism is unknown or not ++ compiled in: the server may reject it, and this client must still send ++ the command to let the server do so. If the server unexpectedly ++ accepts a mechanism we can't handle, we fail then (see server_input()). */ + if (compression_lookup_handler(t_str_lcase(algorithm), + &client->handler) <= 0) +- i_fatal("Unsupported compression mechanism: %s", algorithm); ++ client->handler = NULL; + /* Remember the tag so we can tell whether the server accepted or + rejected this COMPRESS command. */ + i_free(client->compress_tag); +@@ -281,6 +286,8 @@ static void server_input(struct client * + struct istream *input; + struct ostream *output; + ++ if (client->handler == NULL) ++ i_fatal("Server accepted unsupported compression mechanism"); + e_info(client->event, ""); + input = client->handler->create_istream(client->input); + output = client->handler-> diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-imap-Stream-ENVELOPE-to-client-without-ostream-memor.patch dovecot-2.4.1+dfsg1/debian/patches/0003-imap-Stream-ENVELOPE-to-client-without-ostream-memor.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-imap-Stream-ENVELOPE-to-client-without-ostream-memor.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-imap-Stream-ENVELOPE-to-client-without-ostream-memor.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,75 @@ +From e14d6ae2825adf24f58e397e3cf88d8c9f41e8a2 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 16:55:34 +0200 +Subject: [PATCH 03/14] imap: Stream ENVELOPE to client without ostream memory + duplication + +Same fix as for BODYSTRUCTURE: avoid copying the envelope string into +the ostream ring buffer by using o_stream_set_max_buffer_size(0) + +o_stream_send_istream() with a continuation handler. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/imap/imap-fetch.c | 38 ++++++++++++++++++++++++++++++++++---- + 1 file changed, 34 insertions(+), 4 deletions(-) + +diff --git a/src/imap/imap-fetch.c b/src/imap/imap-fetch.c +index b4a8a465d7..5a6629916b 100644 +--- a/src/imap/imap-fetch.c ++++ b/src/imap/imap-fetch.c +@@ -793,6 +793,34 @@ static bool fetch_bodystructure_init(struct imap_fetch_init_context *ctx) + return TRUE; + } + ++static int fetch_envelope_stream_continue(struct imap_fetch_context *ctx) ++{ ++ struct imap_fetch_state *state = &ctx->state; ++ enum ostream_send_istream_result res; ++ ++ o_stream_set_max_buffer_size(ctx->client->output, 0); ++ res = o_stream_send_istream(ctx->client->output, state->cur_input); ++ o_stream_set_max_buffer_size(ctx->client->output, SIZE_MAX); ++ ++ switch (res) { ++ case OSTREAM_SEND_ISTREAM_RESULT_FINISHED: ++ i_stream_unref(&state->cur_input); ++ state->cont_handler = NULL; ++ if (o_stream_send(ctx->client->output, ")", 1) < 0) ++ return -1; ++ return 1; ++ case OSTREAM_SEND_ISTREAM_RESULT_WAIT_INPUT: ++ i_unreached(); ++ case OSTREAM_SEND_ISTREAM_RESULT_WAIT_OUTPUT: ++ return 0; ++ case OSTREAM_SEND_ISTREAM_RESULT_ERROR_INPUT: ++ i_unreached(); ++ case OSTREAM_SEND_ISTREAM_RESULT_ERROR_OUTPUT: ++ return -1; ++ } ++ i_unreached(); ++} ++ + static int fetch_envelope(struct imap_fetch_context *ctx, struct mail *mail, + void *context ATTR_UNUSED) + { +@@ -808,11 +836,13 @@ static int fetch_envelope(struct imap_fetch_context *ctx, struct mail *mail, + return -1; + } + +- if (o_stream_send(ctx->client->output, "ENVELOPE (", 10) < 0 || +- o_stream_send_str(ctx->client->output, envelope) < 0 || +- o_stream_send(ctx->client->output, ")", 1) < 0) ++ if (o_stream_send(ctx->client->output, "ENVELOPE (", 10) < 0) + return -1; +- return 1; ++ ++ ctx->state.cur_input = ++ i_stream_create_from_data(envelope, strlen(envelope)); ++ ctx->state.cont_handler = fetch_envelope_stream_continue; ++ return fetch_envelope_stream_continue(ctx); + } + + static bool fetch_envelope_init(struct imap_fetch_init_context *ctx) +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-str_stable_hash.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-str_stable_hash.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-str_stable_hash.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-str_stable_hash.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,43 @@ +From 4778fe2357a1fcaa45d566f5095e80d8be8f4e4b Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 13 Apr 2026 17:20:46 +0200 +Subject: [PATCH 03/12] lib: Add str_stable_hash() + +For now this is the same as str_hash(), but this is changed in a following +commit. +--- + src/lib/hash.c | 5 +++++ + src/lib/hash.h | 1 + + 2 files changed, 6 insertions(+) + +diff --git a/src/lib/hash.c b/src/lib/hash.c +index 8f4cd7d9b4..61fe86ed8e 100644 +--- a/src/lib/hash.c ++++ b/src/lib/hash.c +@@ -525,6 +525,11 @@ unsigned int str_hash(const char *p) + return xxh64_to_32(xxh64_data(p, strlen(p), 0)); + } + ++unsigned int str_stable_hash(const char *p) ++{ ++ return xxh64_to_32(xxh64_data(p, strlen(p), 0)); ++} ++ + unsigned int strcase_hash(const char *p) + { + struct xxh64_context ctx; +diff --git a/src/lib/hash.h b/src/lib/hash.h +index 5716a77a7d..84d9c52aef 100644 +--- a/src/lib/hash.h ++++ b/src/lib/hash.h +@@ -171,6 +171,7 @@ void hash_table_copy(struct hash_table *dest, struct hash_table *src); + /* hash function for strings */ + unsigned int str_hash(const char *p) ATTR_PURE; + unsigned int strcase_hash(const char *p) ATTR_PURE; ++unsigned int str_stable_hash(const char *p) ATTR_PURE; + + /* fast hash function which uppercases a-z. Does not work well + with input that consists from non number/letter input, as +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-t_openat_safe_dir.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-t_openat_safe_dir.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-t_openat_safe_dir.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-Add-t_openat_safe_dir.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,129 @@ +From ba9ae23ee80dece8ebc3bb6b28c42b35002afb96 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 18:10:36 +0300 +Subject: [PATCH 3/5] lib: Add t_openat_safe_dir() + +--- + src/lib/path-util.c | 19 +++++++++++++++ + src/lib/path-util.h | 7 ++++++ + src/lib/test-path-util.c | 52 ++++++++++++++++++++++++++++++++++++++++ + 3 files changed, 78 insertions(+) + +Index: trixie/src/lib/path-util.c +=================================================================== +--- trixie.orig/src/lib/path-util.c ++++ trixie/src/lib/path-util.c +@@ -528,6 +528,25 @@ int t_openat_safe(int base_fd, const cha + return path_openat_safe_walk(base_fd, path, flags, error_r); + } + ++int t_openat_safe_dir(const char *base_dir, const char *path, int flags, ++ const char **error_r) ++{ ++ i_assert(base_dir != NULL); ++ i_assert(path != NULL); ++ i_assert(error_r != NULL); ++ ++ int dir_fd = open(base_dir, ++ O_DIRECTORY | O_RDONLY | O_CLOEXEC | ++ (flags & O_NOFOLLOW)); ++ if (dir_fd < 0) { ++ *error_r = t_strdup_printf("open(%s) failed: %m", base_dir); ++ return -1; ++ } ++ int fd = t_openat_safe(dir_fd, path, flags, error_r); ++ i_close_fd(&dir_fd); ++ return fd; ++} ++ + bool t_binary_abspath(const char **binpath, const char **error_r) + { + const char *path_env, *const *paths; +Index: trixie/src/lib/path-util.h +=================================================================== +--- trixie.orig/src/lib/path-util.h ++++ trixie/src/lib/path-util.h +@@ -86,6 +86,13 @@ int t_readlink(const char *path, const c + int t_openat_safe(int base_fd, const char *path, int flags, + const char **error_r); + ++/* Convenience wrapper: opens base_dir as a directory fd, then delegates to ++ t_openat_safe(). If O_NOFOLLOW is present in flags it is also applied to ++ the open() of base_dir itself, refusing a symlink as its final component. ++ Returns the new fd on success, -1 on failure. */ ++int t_openat_safe_dir(const char *base_dir, const char *path, int flags, ++ const char **error_r); ++ + /* Update binpath to be absolute: + * a) begins with '/' -> no change + * b) contains '/' -> assume relative to working directory +Index: trixie/src/lib/test-path-util.c +=================================================================== +--- trixie.orig/src/lib/test-path-util.c ++++ trixie/src/lib/test-path-util.c +@@ -356,6 +356,57 @@ static void test_openat_safe(void) + i_error("rmdir(%s) failed: %m", subdir); + } + ++static void test_openat_safe_dir(void) ++{ ++ const char *error; ++ int fd; ++ ++ /* Plain file: success. */ ++ const char *plain = t_strconcat(tmpdir, "/plain2", NULL); ++ int wfd = creat(plain, 0600); ++ if (wfd < 0) ++ i_fatal("creat(%s) failed: %m", plain); ++ i_close_fd(&wfd); ++ ++ fd = t_openat_safe_dir(tmpdir, "plain2", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ ++ /* Nonexistent base_dir. */ ++ errno = 0; ++ fd = t_openat_safe_dir(t_strconcat(tmpdir, "/no-such-dir", NULL), ++ "plain2", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ENOENT); ++ test_assert(error != NULL); ++ ++ /* Absolute symlink in relative part: refused. */ ++ const char *abs_link2 = t_strconcat(tmpdir, "/abs-link2", NULL); ++ if (symlink("/etc/hostname", abs_link2) < 0) ++ i_fatal("symlink failed: %m"); ++ errno = 0; ++ fd = t_openat_safe_dir(tmpdir, "abs-link2", O_RDONLY, &error); ++ test_assert(fd == -1); ++ test_assert(errno == ELOOP); ++ ++ /* O_NOFOLLOW: symlink as base_dir final component refused. */ ++ const char *dir_link = t_strconcat(tmpdir, "/dir-link", NULL); ++ if (symlink(tmpdir, dir_link) < 0) ++ i_fatal("symlink failed: %m"); ++ errno = 0; ++ fd = t_openat_safe_dir(dir_link, "plain2", ++ O_RDONLY | O_NOFOLLOW, &error); ++ test_assert(fd == -1); ++ /* Without O_NOFOLLOW the same symlinked base_dir succeeds. */ ++ fd = t_openat_safe_dir(dir_link, "plain2", O_RDONLY, &error); ++ test_assert(fd >= 0); ++ i_close_fd(&fd); ++ ++ i_unlink(plain); ++ i_unlink(abs_link2); ++ i_unlink(dir_link); ++} ++ + static void test_cleanup(void) + { + const char *error; +@@ -396,6 +447,7 @@ void test_path_util(void) + test_link_alloc(); + test_link_alloc2(); + test_openat_safe(); ++ test_openat_safe_dir(); + test_cleanup(); + alarm(0); + test_end(); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-Add-o_stream_deflate_reset_dict.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-Add-o_stream_deflate_reset_dict.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-Add-o_stream_deflate_reset_dict.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-Add-o_stream_deflate_reset_dict.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,104 @@ +From c7629782bfbb0f32076c220230db67bd01d5e115 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 17:38:41 +0200 +Subject: [PATCH 3/5] lib-compression: Add o_stream_deflate_reset_dict() + +New public function that locates the deflate ostream in the parent chain +(transparently handles rawlog wrappers) and schedules a Z_FULL_FLUSH on +the next uncork/flush. Z_FULL_FLUSH emits all buffered data and then +resets the deflate dictionary, so subsequent compressed output cannot +reference data from before the call. + +Adds a pending_dict_reset flag to struct zlib_ostream. The flag persists +through partial flush retries and is cleared only once the full flush +loop completes. +--- + src/lib-compression/ostream-zlib.c | 37 ++++++++++++++++++++++++++++-- + src/lib-compression/ostream-zlib.h | 7 ++++++ + 2 files changed, 42 insertions(+), 2 deletions(-) + +diff --git a/src/lib-compression/ostream-zlib.c b/src/lib-compression/ostream-zlib.c +index 6aeccbf35d..5bc41d0b2c 100644 +--- a/src/lib-compression/ostream-zlib.c ++++ b/src/lib-compression/ostream-zlib.c +@@ -23,6 +23,7 @@ struct zlib_ostream { + + bool gz:1; + bool flushed:1; ++ bool pending_dict_reset:1; + }; + + struct zlib_settings { +@@ -252,8 +253,14 @@ o_stream_zlib_send_flush(struct zlib_ostream *zstream, bool final) + if ((ret = o_stream_zlib_send_outbuf(zstream)) <= 0) + return ret; + +- flush = final ? Z_FINISH : +- (!zstream->gz ? Z_SYNC_FLUSH : Z_NO_FLUSH); ++ if (final) ++ flush = Z_FINISH; ++ else if (zstream->gz) ++ flush = Z_NO_FLUSH; ++ else if (zstream->pending_dict_reset) ++ flush = Z_FULL_FLUSH; ++ else ++ flush = Z_SYNC_FLUSH; + + i_assert(zstream->outbuf_used == 0); + do { +@@ -283,6 +290,10 @@ o_stream_zlib_send_flush(struct zlib_ostream *zstream, bool final) + } + } while (zs->avail_out != sizeof(zstream->outbuf)); + ++ /* Z_FULL_FLUSH completes here; clear the request so subsequent flushes ++ use the normal Z_SYNC_FLUSH again. */ ++ zstream->pending_dict_reset = FALSE; ++ + if (final) { + if (o_stream_zlib_send_gz_trailer(zstream) < 0) + return -1; +@@ -443,3 +454,25 @@ struct ostream *o_stream_create_deflate_auto(struct ostream *output, struct even + { + return o_stream_create_zlib_auto(output, event, FALSE); + } ++void o_stream_deflate_reset_dict(struct ostream *_output) ++{ ++ struct ostream_private *stream = _output->real_stream; ++ ++ /* Traverse the ostream parent chain to find the deflate ostream. ++ The caller may pass a rawlog-wrapped stream or the deflate stream ++ itself; either way we find it by its sendv function pointer. */ ++ while (stream != NULL) { ++ if (stream->sendv == o_stream_zlib_sendv) { ++ struct zlib_ostream *zstream = ++ container_of(stream, struct zlib_ostream, ostream); ++ i_assert(!zstream->gz); ++ zstream->pending_dict_reset = TRUE; ++ o_stream_set_flush_pending( ++ &zstream->ostream.ostream, TRUE); ++ return; ++ } ++ stream = (stream->parent != NULL) ? ++ stream->parent->real_stream : NULL; ++ } ++ /* No deflate ostream found in the chain – nothing to reset. */ ++} +diff --git a/src/lib-compression/ostream-zlib.h b/src/lib-compression/ostream-zlib.h +index 9738e36cc0..4db62fda81 100644 +--- a/src/lib-compression/ostream-zlib.h ++++ b/src/lib-compression/ostream-zlib.h +@@ -7,4 +7,11 @@ struct ostream *o_stream_create_bz2_auto(struct ostream *output, struct event *e + struct ostream *o_stream_create_lz4_auto(struct ostream *output, struct event *event); + struct ostream *o_stream_create_zstd_auto(struct ostream *output, struct event *event); + ++/* Reset the deflate compression dictionary so that subsequent compressed ++ output cannot reference data from before this call. Pass any ostream in ++ the chain (e.g. a rawlog wrapper); the function locates the deflate stream ++ internally. Safe to call when compression is not active – it is a no-op ++ in that case. */ ++void o_stream_deflate_reset_dict(struct ostream *output); ++ + #endif +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-istream-zstd-Guard-against-no-progre.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-istream-zstd-Guard-against-no-progre.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-istream-zstd-Guard-against-no-progre.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-compression-istream-zstd-Guard-against-no-progre.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,39 @@ +From cc00fbd91027e9f7b3595ced4c505deec33e7f6c Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 12 Jun 2026 11:19:42 +0000 +Subject: [PATCH 3/4] lib-compression: istream-zstd - Guard against no-progress + loop in read + +If ZSTD_decompressStream() returns with neither input consumed nor +output produced, the read loop would spin indefinitely. Add a check +after each call: if input.pos is unchanged and output.pos is zero, +treat it as a corrupt stream (EIO). +--- + src/lib-compression/istream-zstd.c | 8 ++++++++ + 1 file changed, 8 insertions(+) + +Index: trixie/src/lib-compression/istream-zstd.c +=================================================================== +--- trixie.orig/src/lib-compression/istream-zstd.c ++++ trixie/src/lib-compression/istream-zstd.c +@@ -175,12 +175,20 @@ static ssize_t i_stream_zstd_read(struct + zstream->output.pos = 0; + zstream->output.size = ZSTD_DStreamOutSize(); + ++ size_t old_input_pos = zstream->input.pos; + size_t zret = ZSTD_decompressStream(zstream->dstream, &zstream->output, + &zstream->input); + if (ZSTD_isError(zret) != 0) { + i_stream_zstd_read_error(zstream, zret); + return -1; + } ++ if (zstream->input.pos == old_input_pos && zstream->output.pos == 0) { ++ io_stream_set_error(&zstream->istream.iostream, ++ "zstd.read(%s): decompressor made no progress", ++ i_stream_get_name(&zstream->istream.istream)); ++ zstream->istream.istream.stream_errno = EIO; ++ return -1; ++ } + /* ZSTD magic number is 4 bytes, but it's only defined after v0.8 */ + if (!zstream->hdr_read && zstream->input.size > 4) + zstream->hdr_read = TRUE; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-mail-o_stream_dot_sendv-Use-enumeration-to-defin.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-mail-o_stream_dot_sendv-Use-enumeration-to-defin.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-mail-o_stream_dot_sendv-Use-enumeration-to-defin.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-mail-o_stream_dot_sendv-Use-enumeration-to-defin.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,91 @@ +From a26d37e321c46eb974703ac85bab66cce544d35f Mon Sep 17 00:00:00 2001 +From: Marco Bettini +Date: Fri, 10 Apr 2026 08:49:49 +0000 +Subject: [PATCH 3/4] lib-mail: o_stream_dot_sendv() - Use enumeration to + define the items to inject + +--- + src/lib-mail/ostream-dot.c | 39 ++++++++++++++++++++++++++------------ + 1 file changed, 27 insertions(+), 12 deletions(-) + +Index: trixie/src/lib-mail/ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/ostream-dot.c ++++ trixie/src/lib-mail/ostream-dot.c +@@ -71,6 +71,12 @@ o_stream_dot_close(struct iostream_priva + o_stream_close(dstream->ostream.parent); + } + ++enum o_stream_dot_sendv_add { ++ ADD_NONE, ++ ADD_CR, ++ ADD_DOT, ++}; ++ + static ssize_t + o_stream_dot_sendv(struct ostream_private *stream, + const struct const_iovec *iov, unsigned int iov_count) +@@ -106,7 +112,7 @@ o_stream_dot_sendv(struct ostream_privat + p = data; + pend = CONST_PTR_OFFSET(data, size); + for (; p < pend && ((size_t)(p - data) + 2) <= max_bytes; p++) { +- char add = 0; ++ enum o_stream_dot_sendv_add add = ADD_NONE; + + size = pend - p; + switch (dstream->state) { +@@ -122,8 +128,7 @@ o_stream_dot_sendv(struct ostream_privat + switch (*p) { + case '\n': + dstream->state = STREAM_STATE_CRLF; +- /* add missing CR */ +- add = '\r'; ++ add = ADD_CR; + break; + case '\r': + dstream->state = STREAM_STATE_CR; +@@ -155,12 +160,10 @@ o_stream_dot_sendv(struct ostream_privat + break; + case '\n': + dstream->state = STREAM_STATE_CRLF; +- /* add missing CR */ +- add = '\r'; ++ add = ADD_CR; + break; + case '.': +- /* add dot */ +- add = '.'; ++ add = ADD_DOT; + /* fall through */ + default: + dstream->state = STREAM_STATE_NONE; +@@ -185,12 +188,24 @@ o_stream_dot_sendv(struct ostream_privat + } + /* insert byte (substitute one with pair) */ + data++; +- iovn.iov_base = (add == '\r' ? "\r\n" : ".."); +- iovn.iov_len = 2; ++ ++ switch(add) { ++ case ADD_DOT: ++ iovn.iov_base = ".."; ++ iovn.iov_len = 2; ++ break; ++ case ADD_CR: ++ iovn.iov_base = "\r\n"; ++ iovn.iov_len = 2; ++ break; ++ default: ++ i_unreached(); ++ } ++ + array_push_back(&iov_arr, &iovn); +- i_assert(max_bytes >= 2); +- max_bytes -= 2; +- added++; ++ i_assert(max_bytes >= iovn.iov_len); ++ max_bytes -= iovn.iov_len; ++ added += iovn.iov_len - 1; + sent++; + } + } diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-Rename-on-disk-header-rusage-fields-to-unu.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-Rename-on-disk-header-rusage-fields-to-unu.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-Rename-on-disk-header-rusage-fields-to-unu.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-Rename-on-disk-header-rusage-fields-to-unu.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,71 @@ +From e0c8bcd5d0d8bbd39edb2bc78cd15f469834660a Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 12:10:46 +0000 +Subject: [PATCH 3/7] lib-sieve: Rename on-disk header rusage fields to + unused_* + +The .svbin header's resource_usage fields no longer carry data: they +are zeroed on save and discarded on load. Cumulative CPU tracking +lives in the per-user sieve-rusage file, and the in-memory carry-over +lives in sbin->persisted_rusage. + +Flatten the nested resource_usage struct and rename the fields to +unused_update_time and unused_cpu_time_msecs so it is obvious the +fields are kept only for on-disk format compatibility. The memory +layout of struct sieve_binary_header is unchanged. +--- + src/lib-sieve/sieve-binary-file.c | 8 +++++--- + src/lib-sieve/sieve-binary-private.h | 9 +++++---- + 2 files changed, 10 insertions(+), 7 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-file.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +@@ -147,13 +147,14 @@ sieve_binary_file_read_header(struct sie + return 0; + } + +-/* The on-disk binary header carries an unused resource_usage struct for ++/* The on-disk binary header carries unused resource_usage fields for + backward compatibility. Tracking lives in the per-user sieve-rusage file. */ + static void sieve_binary_file_zero_header_rusage(struct sieve_binary *sbin) + { + struct sieve_binary_header *header = &sbin->header; + +- i_zero(&header->resource_usage); ++ header->unused_update_time = 0; ++ header->unused_cpu_time_msecs = 0; + sieve_resource_usage_init(&sbin->rusage); + sbin->rusage_updated = FALSE; + } +@@ -834,7 +835,8 @@ _sieve_binary_open(struct sieve_binary * + + /* Resource usage tracking is no longer kept in the binary file; + discard any data from older versions. */ +- i_zero(&sbin->header.resource_usage); ++ sbin->header.unused_update_time = 0; ++ sbin->header.unused_cpu_time_msecs = 0; + sbin->header.flags &= ENUM_NEGATE(SIEVE_BINARY_FLAG_RESOURCE_LIMIT); + + /* Load block index */ +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-private.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-private.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-private.h +@@ -26,10 +26,11 @@ struct sieve_binary_header { + uint32_t hdr_size; + uint32_t flags; + +- struct { +- uint64_t update_time; +- uint32_t cpu_time_msecs; +- } resource_usage; ++ /* Resource usage tracking moved to per-user sieve-rusage file. These ++ fields are kept zeroed and present only for on-disk format ++ compatibility with older .svbin files. */ ++ uint64_t unused_update_time; ++ uint32_t unused_cpu_time_msecs; + }; + + struct sieve_binary_file { diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-edit_mail_headers_parse-Fix-info-leak-via-.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-edit_mail_headers_parse-Fix-info-leak-via-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-edit_mail_headers_parse-Fix-info-leak-via-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sieve-edit_mail_headers_parse-Fix-info-leak-via-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,271 @@ +From 76957df3b7ec3ca682e779fa91e22f6d8bb22740 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 1 May 2026 06:28:33 +0000 +Subject: [PATCH 3/3] lib-sieve: edit_mail_headers_parse() - Fix info leak via + NUL-truncated header copy + +i_strndup() stops at the first NUL byte, so embedded NULs caused field->data +to be shorter than field->size. This left stale heap data readable past the +copied content when callers treat data+body_offset as a string. + +Adds a test-edit-mail unit test and a sieve testsuite case that both +exercise edit_mail_headers_parse() with a NUL byte embedded in a header +value. The unit test memcmp-verifies that the bytes after the NUL are +preserved; the sieve test triggers an invalid heap read detectable by +valgrind under the old code. +--- + Makefile.am | 1 + + src/lib-sieve/util/edit-mail.c | 8 +- + src/lib-sieve/util/test-edit-mail.c | 140 +++++++++++++++++++ + tests/extensions/editheader/nul-value.svtest | 46 ++++++ + 4 files changed, 192 insertions(+), 3 deletions(-) + create mode 100644 tests/extensions/editheader/nul-value.svtest + +Index: trixie/pigeonhole/Makefile.am +=================================================================== +--- trixie.orig/pigeonhole/Makefile.am ++++ trixie/pigeonhole/Makefile.am +@@ -169,6 +169,7 @@ test_cases = \ + tests/extensions/editheader/utf8.svtest \ + tests/extensions/editheader/protected.svtest \ + tests/extensions/editheader/errors.svtest \ ++ tests/extensions/editheader/nul-value.svtest \ + tests/extensions/editheader/execute.svtest \ + tests/extensions/duplicate/errors.svtest \ + tests/extensions/duplicate/execute.svtest \ +Index: trixie/pigeonhole/src/lib-sieve/util/edit-mail.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/util/edit-mail.c ++++ trixie/pigeonhole/src/lib-sieve/util/edit-mail.c +@@ -553,8 +553,9 @@ edit_mail_header_field_create(struct edi + &field->body_offset); + + /* Copy to new field */ +- field->data = i_strndup(str_data(data), str_len(data)); + field->size = str_len(data); ++ field->data = i_malloc(field->size + 1); ++ memcpy(field->data, str_data(data), field->size); + field->virtual_size = (edmail->crlf ? + field->size : field->size + lines); + field->lines = lines; +@@ -831,8 +832,9 @@ static int edit_mail_headers_parse(struc + + field->size = str_len(hdr_data); + field->virtual_size = field->size + vsize_diff; +- field->data = i_strndup(str_data(hdr_data), +- field->size); ++ field->data = i_malloc(field->size + 1); ++ memcpy(field->data, str_data(hdr_data), ++ field->size); + field->offset = offset; + field->lines = lines; + +Index: trixie/pigeonhole/src/lib-sieve/util/test-edit-mail.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/util/test-edit-mail.c ++++ trixie/pigeonhole/src/lib-sieve/util/test-edit-mail.c +@@ -866,6 +866,144 @@ static void test_edit_mail_empty(void) + test_end(); + } + ++static void test_edit_mail_nul_in_header(void) ++{ ++ /* Message with a NUL byte embedded in the X-Has-NUL header value. ++ * sizeof() - 1 gives the true byte count, skipping the C string's ++ * trailing NUL terminator, while preserving the embedded \x00. */ ++ static const unsigned char message[] = ++ "From: sender@example.com\n" ++ "X-Has-NUL: value\x00" "afterNUL\n" ++ "Subject: Test\n" ++ "\n" ++ "Body\n"; ++ /* Expected output after deleting the Subject header. */ ++ static const unsigned char expected[] = ++ "From: sender@example.com\n" ++ "X-Has-NUL: value\x00" "afterNUL\n" ++ "\n" ++ "Body\n"; ++ struct istream *input_msg, *input_mail; ++ buffer_t *buffer; ++ struct mail_raw *rawmail; ++ struct edit_mail *edmail; ++ struct mail *mail; ++ ++ test_begin("edit-mail - NUL byte in header value"); ++ test_edit_mail_init(); ++ ++ /* sizeof - 1 excludes the trailing C-string NUL terminator */ ++ input_msg = i_stream_create_from_data(message, sizeof(message) - 1); ++ ++ rawmail = mail_raw_open_stream(test_raw_mail_user, input_msg); ++ edmail = edit_mail_wrap(rawmail->mail); ++ ++ /* Deleting any header triggers edit_mail_headers_parse(), which with ++ * the old i_strndup() allocated only strlen("X-Has-NUL: value")+1=17 ++ * bytes for X-Has-NUL's field->data even though field->size=26. ++ * Streaming then called memcpy(dst, field->data, 26), reading 9 bytes ++ * past the end of the allocation and producing garbage in place of ++ * "afterNUL\n". */ ++ edit_mail_header_delete(edmail, "Subject", 0); ++ ++ mail = edit_mail_get_mail(edmail); ++ ++ if (mail_get_stream(mail, NULL, NULL, &input_mail) < 0) { ++ i_fatal("Failed to open mail stream: %s", ++ mailbox_get_last_internal_error(mail->box, NULL)); ++ } ++ ++ buffer = buffer_create_dynamic(default_pool, 128); ++ ++ /* normal */ ++ ++ i_stream_seek(input_mail, 0); ++ test_stream_data(input_mail, buffer); ++ ++ test_out("nul in header", ++ buffer->used == sizeof(expected) - 1 && ++ memcmp(buffer->data, expected, sizeof(expected) - 1) == 0); ++ ++ /* slow (byte-by-byte) */ ++ ++ i_stream_seek(input_mail, 0); ++ buffer_set_used_size(buffer, 0); ++ test_stream_data_slow(input_mail, buffer); ++ ++ test_out("nul in header, slow", ++ buffer->used == sizeof(expected) - 1 && ++ memcmp(buffer->data, expected, sizeof(expected) - 1) == 0); ++ ++ /* clean up */ ++ ++ buffer_free(&buffer); ++ edit_mail_unwrap(&edmail); ++ mail_raw_close(&rawmail); ++ i_stream_unref(&input_msg); ++ test_edit_mail_deinit(); ++ test_end(); ++} ++ ++static void test_edit_mail_empty2(void) ++{ ++ struct istream *input_msg, *input_mail; ++ buffer_t *buffer; ++ struct mail_raw *rawmail; ++ struct edit_mail *edmail; ++ struct mail *mail; ++ const char *value; ++ ++ test_begin("edit-mail - empty message (delete, add)"); ++ test_edit_mail_init(); ++ ++ /* Compose the message */ ++ ++ input_msg = i_stream_create_from_data("", 0); ++ ++ rawmail = mail_raw_open_stream(test_raw_mail_user, input_msg); ++ ++ edmail = edit_mail_wrap(rawmail->mail); ++ ++ /* Delete header */ ++ ++ edit_mail_header_delete(edmail, "X-B", 0); ++ ++ /* Add header */ ++ ++ edit_mail_header_add(edmail, "X-B", "Frop", TRUE); ++ mail = edit_mail_get_mail(edmail); ++ ++ /* Prepare tests */ ++ ++ if (mail_get_stream(mail, NULL, NULL, &input_mail) < 0) { ++ i_fatal("Failed to open mail stream: %s", ++ mailbox_get_last_error(mail->box, NULL)); ++ } ++ ++ buffer = buffer_create_dynamic(default_pool, 1024); ++ ++ /* Evaluate modified header */ ++ ++ test_assert(mail_get_first_header_utf8(mail, "X-B", &value) > 0 && ++ strcmp(value, "Frop") == 0); ++ ++ /* Added */ ++ ++ i_stream_seek(input_mail, 0); ++ buffer_set_used_size(buffer, 0); ++ ++ test_stream_data(input_mail, buffer); ++ ++ /* Clean up */ ++ ++ buffer_free(&buffer); ++ edit_mail_unwrap(&edmail); ++ mail_raw_close(&rawmail); ++ i_stream_unref(&input_msg); ++ test_edit_mail_deinit(); ++ test_end(); ++} ++ + int main(int argc, char *argv[]) + { + static void (*test_functions[])(void) = { +@@ -873,6 +1011,8 @@ int main(int argc, char *argv[]) + test_edit_mail_big_header, + test_edit_mail_small_buffer, + test_edit_mail_empty, ++ test_edit_mail_empty2, ++ test_edit_mail_nul_in_header, + NULL + }; + const enum master_service_flags service_flags = +Index: trixie/pigeonhole/tests/extensions/editheader/nul-value.svtest +=================================================================== +--- /dev/null ++++ trixie/pigeonhole/tests/extensions/editheader/nul-value.svtest +@@ -0,0 +1,46 @@ ++require "vnd.dovecot.testsuite"; ++require "encoded-character"; ++require "variables"; ++require "fileinto"; ++require "mailbox"; ++ ++require "editheader"; ++ ++/* ++ * Regression test: edit_mail_headers_parse() used i_strndup() to copy the ++ * raw header data, which stopped at the first NUL byte and allocated a buffer ++ * shorter than field->size. When the modified message was subsequently ++ * streamed (e.g. fileinto), memcpy() read field->size bytes out of that ++ * under-sized buffer, causing an invalid heap read detectable by valgrind. ++ */ ++ ++/* Construct a raw message that has a NUL byte inside a header value. ++ * ${hex:00} = NUL. field->size covers the full header line including ++ * bytes after the NUL; the old i_strndup() only allocated up to the NUL. */ ++test_set "message" text: ++From: sender@example.com ++X-Has-NUL: value${hex:00}afterNUL ++Subject: Test ++ ++Body ++. ++; ++ ++test "editheader - NUL byte in header value" { ++ /* deleteheader calls edit_mail_headers_parse(), which allocates ++ * field->data for every header including X-Has-NUL. With the old ++ * i_strndup() code, X-Has-NUL's allocation is only strlen("value")+1 ++ * bytes, but field->size covers the full extent including bytes after ++ * the NUL. deleteheader also sets modified=TRUE. */ ++ deleteheader "Subject"; ++ ++ fileinto :create "nul-test"; ++ ++ /* Streaming iterates over all remaining parsed headers including ++ * X-Has-NUL, reading field->size bytes from field->data. With the ++ * old under-sized allocation this is an invalid heap read that ++ * valgrind detects. */ ++ if not test_result_execute { ++ test_fail "failed to execute result"; ++ } ++} diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-smtp-smtp-server-Expose-smtp_server_connection_r.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-smtp-smtp-server-Expose-smtp_server_connection_r.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-smtp-smtp-server-Expose-smtp_server_connection_r.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-smtp-smtp-server-Expose-smtp_server_connection_r.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,51 @@ +From 12fcbfe65431ece3f49d39d553f22001b3b007b6 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 7 May 2026 10:57:59 +0000 +Subject: [PATCH 3/5] lib-smtp: smtp-server - Expose + smtp_server_connection_reply_immediate + +The function already existed internally as a way to write a reply directly +to the output stream, bypassing the per-command reply queue. Make it part +of the public API so callers that need a reply on the wire before tearing +the connection down (where queued replies would otherwise be aborted) can +use it. +--- + src/lib-smtp/smtp-server-private.h | 3 --- + src/lib-smtp/smtp-server.h | 10 ++++++++++ + 2 files changed, 10 insertions(+), 3 deletions(-) + +Index: trixie/src/lib-smtp/smtp-server-private.h +=================================================================== +--- trixie.orig/src/lib-smtp/smtp-server-private.h ++++ trixie/src/lib-smtp/smtp-server-private.h +@@ -323,9 +323,6 @@ void smtp_server_connection_reply_lines( + unsigned int status, + const char *enh_code, + const char *const *text_lines); +-void smtp_server_connection_reply_immediate( +- struct smtp_server_connection *conn, unsigned int status, +- const char *fmt, ...) ATTR_FORMAT(3, 4); + + void smtp_server_connection_reset_state(struct smtp_server_connection *conn); + void smtp_server_connection_set_state(struct smtp_server_connection *conn, +Index: trixie/src/lib-smtp/smtp-server.h +=================================================================== +--- trixie.orig/src/lib-smtp/smtp-server.h ++++ trixie/src/lib-smtp/smtp-server.h +@@ -477,6 +477,16 @@ void smtp_server_connection_set_ssl_stre + void smtp_server_connection_close(struct smtp_server_connection **_conn, + const char *reason) ATTR_NULL(2); + bool smtp_server_connection_is_closed(struct smtp_server_connection *conn); ++/* Send a reply line directly to the output stream, bypassing the per-command ++ reply queue. Useful when the caller is about to close the connection and ++ needs the reply on the wire before close/destroy aborts queued replies. ++ The formatted text must be a single line (no CR/LF); unlike ++ smtp_server_reply_add_text(), this function does not split on \n nor add ++ the SMTP continuation prefix to subsequent lines. */ ++void smtp_server_connection_reply_immediate(struct smtp_server_connection *conn, ++ unsigned int status, ++ const char *fmt, ...) ++ ATTR_FORMAT(3, 4); + void smtp_server_connection_terminate(struct smtp_server_connection **_conn, + const char *enh_code, const char *reason) + ATTR_NULL(3); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sql-Add-sql_commit_schedule_delayed-helper.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sql-Add-sql_commit_schedule_delayed-helper.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sql-Add-sql_commit_schedule_delayed-helper.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-sql-Add-sql_commit_schedule_delayed-helper.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,50 @@ +From 3c388ec11db1d61a2795241321d80a7dc245105c Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Tue, 2 Jun 2026 09:54:41 +0000 +Subject: [PATCH 3/6] lib-sql: Add sql_commit_schedule_delayed() helper + +--- + src/lib-sql/sql-api.c | 21 ++++++++++++++------- + 1 file changed, 14 insertions(+), 7 deletions(-) + +Index: trixie/src/lib-sql/sql-api.c +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.c ++++ trixie/src/lib-sql/sql-api.c +@@ -885,6 +885,19 @@ static void sql_commit_delayed_callback( + i_free(cb); + } + ++static void ++sql_commit_schedule_delayed(struct sql_db *db, const char *error, ++ sql_commit_callback_t *callback, void *context) ++{ ++ struct sql_commit_result_delayed *cb = i_new(struct sql_commit_result_delayed, 1); ++ cb->db = db; ++ cb->error = i_strdup(error); ++ cb->callback = callback; ++ cb->context = context; ++ cb->to = timeout_add_short(0, sql_commit_delayed_callback, cb); ++ DLLIST_PREPEND(&db->commit_delayed_list, cb); ++} ++ + #undef sql_transaction_commit + void sql_transaction_commit(struct sql_transaction_context **_ctx, + sql_commit_callback_t *callback, void *context) +@@ -898,15 +911,9 @@ void sql_transaction_commit(struct sql_t + return; + } + +- struct sql_commit_result_delayed *cb = i_new(struct sql_commit_result_delayed, 1); + const char *error = NULL; + ctx->db->v.transaction_commit_s(ctx, &error); +- cb->db = db; +- cb->error = i_strdup(error); +- cb->callback = callback; +- cb->context = context; +- cb->to = timeout_add_short(0, sql_commit_delayed_callback, cb); +- DLLIST_PREPEND(&db->commit_delayed_list, cb); ++ sql_commit_schedule_delayed(db, error, callback, context); + } + + int sql_transaction_commit_s(struct sql_transaction_context **_ctx, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0003-lib-storage-thread-Limit-ancestor-chain-traversal-de.patch dovecot-2.4.1+dfsg1/debian/patches/0003-lib-storage-thread-Limit-ancestor-chain-traversal-de.patch --- dovecot-2.4.1+dfsg1/debian/patches/0003-lib-storage-thread-Limit-ancestor-chain-traversal-de.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0003-lib-storage-thread-Limit-ancestor-chain-traversal-de.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,92 @@ +From e6934c6d1afdcecd63591d7f514ccb39e11d6ada Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 13:15:54 +0000 +Subject: [PATCH 3/3] lib-storage: thread - Limit ancestor chain traversal + depth to prevent O(N^2) CPU usage + +The per-message References limit (MAIL_THREAD_REFERENCES_MAX) prevents a +single crafted message from causing O(N^2) traversals in +thread_node_has_ancestor(). However, multiple crafted messages each +containing 1000 References entries can build an arbitrarily deep ancestor +chain across the mailbox, causing the same quadratic blowup spread over +many messages: processing email k costs O(k * MAIL_THREAD_REFERENCES_MAX) +steps, giving O(M^2 * MAIL_THREAD_REFERENCES_MAX) total for M emails. + +Fix this by limiting the traversal depth in thread_node_has_ancestor() to +MAIL_THREAD_REFERENCES_MAX steps. When the limit is reached the link is +dropped, bounding per-email work to O(MAIL_THREAD_REFERENCES_MAX^2) +regardless of how deep the chain was built by prior messages. +--- + src/lib-storage/index/index-thread-links.c | 31 +++++++++++++++++++--- + 1 file changed, 28 insertions(+), 3 deletions(-) + +diff --git a/src/lib-storage/index/index-thread-links.c b/src/lib-storage/index/index-thread-links.c +index 9affb8338d..0ce33a0772 100644 +--- a/src/lib-storage/index/index-thread-links.c ++++ b/src/lib-storage/index/index-thread-links.c +@@ -31,12 +31,24 @@ static uint32_t thread_msg_add(struct mail_thread_cache *cache, + + static bool thread_node_has_ancestor(struct mail_thread_cache *cache, + const struct mail_thread_node *node, +- const struct mail_thread_node *ancestor) ++ const struct mail_thread_node *ancestor, ++ bool *depth_exceeded_r) + { ++ unsigned int n = 0; ++ ++ *depth_exceeded_r = FALSE; + while (node != ancestor) { + if (node->parent_idx == 0) + return FALSE; +- ++ if (++n > MAIL_THREAD_REFERENCES_MAX) { ++ /* Ancestor chain is deeper than the per-message ++ References limit. Multiple crafted messages can build ++ an arbitrarily deep chain, causing O(N^2) traversals ++ per email even with the per-message limit. Treat the ++ link as unsafe to prevent the DoS. */ ++ *depth_exceeded_r = TRUE; ++ return FALSE; ++ } + node = array_idx(&cache->thread_nodes, node->parent_idx); + } + return TRUE; +@@ -47,6 +59,7 @@ static void thread_link_reference(struct mail_thread_cache *cache, + { + struct mail_thread_node *node, *parent, *child; + uint32_t idx; ++ bool depth_exceeded; + + i_assert(parent_idx < cache->first_invalid_msgid_str_idx); + +@@ -62,7 +75,7 @@ static void thread_link_reference(struct mail_thread_cache *cache, + } + + child->parent_link_refcount++; +- if (thread_node_has_ancestor(cache, parent, child)) { ++ if (thread_node_has_ancestor(cache, parent, child, &depth_exceeded)) { + if (parent == child) { + /* loops to itself - ignore */ + return; +@@ -88,6 +101,18 @@ static void thread_link_reference(struct mail_thread_cache *cache, + node->child_unref_rebuilds = TRUE; + } while (node != child); + return; ++ } else if (depth_exceeded) { ++ /* Ancestor chain exceeded the depth limit; drop this link. ++ parent_link_refcount was already incremented above and is ++ intentionally kept that way: mail_thread_unref_link() ++ walks References blindly at remove time and would hit ++ i_assert(child->parent_link_refcount > 0) on the matching ++ edge if we hadn't bumped it here. The trade-off is that ++ the bumped refcount can leave child->parent_idx attached ++ to a stale parent if another message later sets a real ++ parent edge on this child and is then expunged - threading ++ stays wrong until some other event triggers a rebuild. */ ++ return; + } else if (child->parent_idx == parent_idx) { + /* The same link already exists */ + return; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-acl-dovecot-acl-list-is-never-supposed-to-be-a-symli.patch dovecot-2.4.1+dfsg1/debian/patches/0004-acl-dovecot-acl-list-is-never-supposed-to-be-a-symli.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-acl-dovecot-acl-list-is-never-supposed-to-be-a-symli.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-acl-dovecot-acl-list-is-never-supposed-to-be-a-symli.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,26 @@ +From a5d06e714ed25a6cb18fd3af99314e94fa080046 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 18:16:38 +0300 +Subject: [PATCH 4/5] acl: dovecot-acl-list is never supposed to be a symlink - + use O_NOFOLLOW flag + +--- + src/plugins/acl/acl-backend-vfile-acllist.c | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/src/plugins/acl/acl-backend-vfile-acllist.c b/src/plugins/acl/acl-backend-vfile-acllist.c +index 7dfcfdd393..d291b55594 100644 +--- a/src/plugins/acl/acl-backend-vfile-acllist.c ++++ b/src/plugins/acl/acl-backend-vfile-acllist.c +@@ -115,7 +115,7 @@ static int acl_backend_vfile_acllist_read(struct acl_backend_vfile *backend) + return 0; + } + +- fd = open(path, O_RDONLY); ++ fd = open(path, O_RDONLY | O_NOFOLLOW); + if (fd == -1) { + if (errno == ENOENT) { + backend->acllist_mtime = 0; +-- +2.39.5 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-imap-login-Add-dict_reset-side-channel-command-to-im.patch dovecot-2.4.1+dfsg1/debian/patches/0004-imap-login-Add-dict_reset-side-channel-command-to-im.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-imap-login-Add-dict_reset-side-channel-command-to-im.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-imap-login-Add-dict_reset-side-channel-command-to-im.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,55 @@ +From da4c52ac021b32bfc9f926f3c229c91ecb729cca Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 17:38:45 +0200 +Subject: [PATCH 4/5] imap-login: Add dict_reset side-channel command to + imap-proxy + +When imap_compress_on_proxy is enabled, DEFLATE compression runs inside +the imap-login process. The imap backend process cannot call +o_stream_deflate_reset_dict() directly on a remote stream, so this adds +a new side-channel command "dict_reset" that the backend can send to +trigger the dictionary reset on the proxy side. +--- + src/imap-login/imap-proxy.c | 14 ++++++++++++++ + 1 file changed, 14 insertions(+) + +diff --git a/src/imap-login/imap-proxy.c b/src/imap-login/imap-proxy.c +index 03dad690e1..2084cb290d 100644 +--- a/src/imap-login/imap-proxy.c ++++ b/src/imap-login/imap-proxy.c +@@ -10,6 +10,7 @@ + #include "str-sanitize.h" + #include "safe-memset.h" + #include "compression.h" ++#include "ostream-zlib.h" + #include "dsasl-client.h" + #include "imap-login-client.h" + #include "client-authenticate.h" +@@ -641,11 +642,24 @@ proxy_side_cmd_compress(struct client *client, const char *const *args, + return 0; + } + ++static int ++proxy_side_cmd_dict_reset(struct client *client, ++ const char *const *args ATTR_UNUSED, ++ const char **error_r ATTR_UNUSED) ++{ ++ struct ostream *client_output = ++ login_proxy_get_client_ostream(client->login_proxy); ++ o_stream_deflate_reset_dict(client_output); ++ return 0; ++} ++ + int imap_proxy_side_channel_input(struct client *client, + const char *const *args, const char **error_r) + { + if (strcmp(args[0], "compress") == 0) + return proxy_side_cmd_compress(client, args + 1, error_r); ++ else if (strcmp(args[0], "dict_reset") == 0) ++ return proxy_side_cmd_dict_reset(client, args + 1, error_r); + else { + *error_r = "Unsupported command"; + return -1; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-lib-Key-str_hash-strcase_hash-with-a-random-seed-to-.patch dovecot-2.4.1+dfsg1/debian/patches/0004-lib-Key-str_hash-strcase_hash-with-a-random-seed-to-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-lib-Key-str_hash-strcase_hash-with-a-random-seed-to-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-lib-Key-str_hash-strcase_hash-with-a-random-seed-to-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,120 @@ +From b9afacaf42e7b2efff769dbc3eb10ac48934462a Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 13 Apr 2026 12:38:50 +0200 +Subject: [PATCH 04/12] lib: Key str_hash/strcase_hash with a random seed to + prevent HashDoS + +hash_init() fills a process-wide uint64_t hash_iv via random_fill() +at lib_init() time (after random_init()). str_hash() and strcase_hash() +pass hash_iv as the xxh64 seed so an attacker cannot predict bucket +placement and manufacture collision chains. +--- + src/lib/hash.c | 12 ++++++++++-- + src/lib/hash.h | 10 +++++++++- + src/lib/lib.c | 2 ++ + 3 files changed, 21 insertions(+), 3 deletions(-) + +diff --git a/src/lib/hash.c b/src/lib/hash.c +index 61fe86ed8e..83eeed42f6 100644 +--- a/src/lib/hash.c ++++ b/src/lib/hash.c +@@ -5,6 +5,7 @@ + #include "lib.h" + #include "hash.h" + #include "primes.h" ++#include "randgen.h" + #include "xxh64.h" + + #include +@@ -59,6 +60,8 @@ enum hash_table_operation{ + HASH_TABLE_OP_RESIZE + }; + ++uint64_t hash_iv; ++ + static bool hash_table_resize(struct hash_table *table, bool grow); + + void hash_table_create(struct hash_table **table_r, pool_t node_pool, +@@ -520,9 +523,14 @@ void hash_table_copy(struct hash_table *dest, struct hash_table *src) + hash_table_thaw(dest); + } + ++void hash_init(void) ++{ ++ random_fill(&hash_iv, sizeof(hash_iv)); ++} ++ + unsigned int str_hash(const char *p) + { +- return xxh64_to_32(xxh64_data(p, strlen(p), 0)); ++ return xxh64_to_32(xxh64_data(p, strlen(p), hash_iv)); + } + + unsigned int str_stable_hash(const char *p) +@@ -535,7 +543,7 @@ unsigned int strcase_hash(const char *p) + struct xxh64_context ctx; + unsigned char c; + +- xxh64_init(&ctx, 0); ++ xxh64_init(&ctx, hash_iv); + while (*p != '\0') { + c = (unsigned char)i_toupper(*p++); + xxh64_loop(&ctx, &c, 1); +diff --git a/src/lib/hash.h b/src/lib/hash.h +index 84d9c52aef..534c25f6ee 100644 +--- a/src/lib/hash.h ++++ b/src/lib/hash.h +@@ -14,6 +14,9 @@ typedef unsigned int hash_callback_t(const void *p); + /* Returns 0 if the pointers are equal. */ + typedef int hash_cmp_callback_t(const void *p1, const void *p2); + ++/* Random per-process IV to use for hash functions */ ++extern uint64_t hash_iv; ++ + /* Create a new hash table. If initial_size is 0, the default value is used. + table_pool is used to allocate/free large hash tables, node_pool is used + for smaller allocations and can also be alloconly pool. The pools must not +@@ -168,9 +171,12 @@ void hash_table_copy(struct hash_table *dest, struct hash_table *src); + #define hash_table_copy(table1, table2) \ + hash_table_copy((table1)._table, (table2)._table) + +-/* hash function for strings */ ++/* Hash function for strings. These are safe against collision attacks. They ++ are initialized with a per-process random key. */ + unsigned int str_hash(const char *p) ATTR_PURE; + unsigned int strcase_hash(const char *p) ATTR_PURE; ++/* Like str_hash(), but there is no per-process random key. This isn't as safe ++ against collision attacks. */ + unsigned int str_stable_hash(const char *p) ATTR_PURE; + + /* fast hash function which uppercases a-z. Does not work well +@@ -181,4 +187,6 @@ unsigned int strfastcase_hash(const char *p) ATTR_PURE; + /* a generic hash for a given memory block */ + unsigned int mem_hash(const void *p, unsigned int size) ATTR_PURE; + ++void hash_init(void); ++ + #endif +diff --git a/src/lib/lib.c b/src/lib/lib.c +index 4df939b7a1..4a4372f516 100644 +--- a/src/lib/lib.c ++++ b/src/lib/lib.c +@@ -5,6 +5,7 @@ + #include "array.h" + #include "event-filter.h" + #include "env-util.h" ++#include "hash.h" + #include "hostpid.h" + #include "ipwd.h" + #include "process-title.h" +@@ -184,6 +185,7 @@ void lib_init(void) + { + i_assert(!lib_initialized); + random_init(); ++ hash_init(); + data_stack_init(); + hostpid_init(); + lib_open_non_stdio_dev_null(); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-lib-compression-test-Add-zero-len-frame-test-for-all.patch dovecot-2.4.1+dfsg1/debian/patches/0004-lib-compression-test-Add-zero-len-frame-test-for-all.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-lib-compression-test-Add-zero-len-frame-test-for-all.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-lib-compression-test-Add-zero-len-frame-test-for-all.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,72 @@ +From 32ef85eff6329395b74e47ec69bf7926c629d7c6 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 12 Jun 2026 11:19:42 +0000 +Subject: [PATCH 4/4] lib-compression: test - Add zero-len frame test for all + handlers + +Verify that successive empty compressed frames produce a clean EOF +across all compression algorithms. lz4 is limited to one frame as it +uses a custom single-stream format without concatenation support. +--- + src/lib-compression/test-compression.c | 41 ++++++++++++++++++++++++++ + 1 file changed, 41 insertions(+) + +Index: trixie/src/lib-compression/test-compression.c +=================================================================== +--- trixie.orig/src/lib-compression/test-compression.c ++++ trixie/src/lib-compression/test-compression.c +@@ -155,6 +155,46 @@ test_compression_handler_empty(const str + } + + static void ++test_compression_handler_zero_frame(const struct compression_handler *handler, ++ bool autodetect) ++{ ++ test_begin(t_strdup_printf("compression handler %s (zero-len frame, autodetect=%s)", ++ handler->name, autodetect ? "yes" : "no")); ++ ++ /* Produce successive empty frames. Each frame needs its own ostream ++ because o_stream_finish() propagates to the parent; compress each ++ into a separate buffer then concatenate. ++ lz4 uses a custom single-stream format and does not support ++ concatenated frames, so limit it to one. */ ++ unsigned int n_frames = strcmp(handler->name, "lz4") == 0 ? 1 : 3; ++ buffer_t *compressed = buffer_create_dynamic(pool_datastack_create(), 256); ++ for (unsigned int i = 0; i < n_frames; i++) { ++ buffer_t *frame_buf = buffer_create_dynamic(pool_datastack_create(), 64); ++ struct ostream *os = test_ostream_create(frame_buf); ++ struct ostream *output = handler->create_ostream_auto(os, set.event); ++ o_stream_unref(&os); ++ test_assert_idx(o_stream_finish(output) == 1, i); ++ o_stream_unref(&output); ++ test_assert_idx(frame_buf->used > 0, i); ++ buffer_append(compressed, frame_buf->data, frame_buf->used); ++ } ++ ++ /* Decompress: must yield clean EOF with no error and no loop. */ ++ struct istream *is = test_istream_create_data(compressed->data, compressed->used); ++ is->blocking = TRUE; ++ struct istream *input = !autodetect ? handler->create_istream(is) : ++ i_stream_create_decompress(is, 0); ++ i_stream_unref(&is); ++ ++ test_assert(i_stream_read(input) == -1); ++ test_assert(input->eof); ++ test_assert(input->stream_errno == 0); ++ i_stream_unref(&input); ++ ++ test_end(); ++} ++ ++static void + test_compression_handler_seek(const struct compression_handler *handler, + bool autodetect) + { +@@ -743,6 +783,7 @@ static void test_compression_int(bool au + test_compression_handler_detect(&compression_handlers[i]); + test_compression_handler_short(&compression_handlers[i], autodetect); + test_compression_handler_empty(&compression_handlers[i], autodetect); ++ test_compression_handler_zero_frame(&compression_handlers[i], autodetect); + test_compression_handler(&compression_handlers[i], autodetect); + test_compression_handler_seek(&compression_handlers[i], autodetect); + test_compression_handler_reset(&compression_handlers[i], autodetect); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-message-parser-from-parts-Enforce-50-MB-all.patch dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-message-parser-from-parts-Enforce-50-MB-all.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-message-parser-from-parts-Enforce-50-MB-all.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-message-parser-from-parts-Enforce-50-MB-all.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,52 @@ +From afb5fd924bf39dc790a573151e2d2626a63d0aec Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 22:38:02 +0200 +Subject: [PATCH 04/14] lib-mail: message-parser-from-parts: Enforce 50 MB + all-headers-max-size limit + +The preparsed (from-parts) header parser was not tracking +all_headers_total_size and never called message_parse_header_lower_limit(), +so the cumulative 50 MB header size limit was never applied when re-parsing +a message using cached part structure. + +Fix by mirroring the same tracking that message-parser.c does in its +parse_next_header_block(): update all_headers_total_size for each parsed +header line, and call message_parse_header_lower_limit() with the remaining +budget when initialising the per-part header parser. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/lib-mail/message-parser-from-parts.c | 10 ++++++++++ + 1 file changed, 10 insertions(+) + +diff --git a/src/lib-mail/message-parser-from-parts.c b/src/lib-mail/message-parser-from-parts.c +index 867a916c1c..fb75dc7148 100644 +--- a/src/lib-mail/message-parser-from-parts.c ++++ b/src/lib-mail/message-parser-from-parts.c +@@ -303,6 +303,11 @@ static int preparsed_parse_next_header(struct message_parser_ctx *ctx, + } + + if (hdr != NULL) { ++ if (!hdr->continues) { ++ ctx->all_headers_total_size += hdr->name_len; ++ ctx->all_headers_total_size += hdr->middle_len; ++ } ++ ctx->all_headers_total_size += hdr->value_len; + block_r->hdr = hdr; + block_r->size = 0; + return 1; +@@ -344,6 +349,11 @@ static int preparsed_parse_next_header_init(struct message_parser_ctx *ctx, + message_parse_header_init(hdr_input, NULL, ctx->hdr_flags); + i_stream_unref(&hdr_input); + ++ size_t headers_available = ++ ctx->all_headers_max_size > ctx->all_headers_total_size ? ++ ctx->all_headers_max_size - ctx->all_headers_total_size : 0; ++ message_parse_header_lower_limit(ctx->hdr_parser_ctx, headers_available); ++ + ctx->parse_next_block = preparsed_parse_next_header; + return preparsed_parse_next_header(ctx, block_r); + } +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-o_stream_dot_sendv-Do-not-send-unguarded-.-.patch dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-o_stream_dot_sendv-Do-not-send-unguarded-.-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-o_stream_dot_sendv-Do-not-send-unguarded-.-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-lib-mail-o_stream_dot_sendv-Do-not-send-unguarded-.-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,216 @@ +From dc1f33030101d90fcb77612eb3a111854525506a Mon Sep 17 00:00:00 2001 +From: Marco Bettini +Date: Wed, 8 Apr 2026 10:14:08 +0000 +Subject: [PATCH 4/4] lib-mail: o_stream_dot_sendv() - Do not send unguarded + '.' after bare '\r' + +--- + src/lib-mail/ostream-dot.c | 40 ++++++++++++++++-- + src/lib-mail/test-iostream-dot.c | 9 ++++ + src/lib-mail/test-ostream-dot.c | 71 ++++++++++++++++++++++++++++++-- + 3 files changed, 112 insertions(+), 8 deletions(-) + +Index: trixie/src/lib-mail/ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/ostream-dot.c ++++ trixie/src/lib-mail/ostream-dot.c +@@ -71,10 +71,12 @@ o_stream_dot_close(struct iostream_priva + o_stream_close(dstream->ostream.parent); + } + ++#define ADD_MAX 3 + enum o_stream_dot_sendv_add { + ADD_NONE, + ADD_CR, + ADD_DOT, ++ ADD_LF_DOT, + }; + + static ssize_t +@@ -111,14 +113,14 @@ o_stream_dot_sendv(struct ostream_privat + + p = data; + pend = CONST_PTR_OFFSET(data, size); +- for (; p < pend && ((size_t)(p - data) + 2) <= max_bytes; p++) { ++ for (; p < pend && ((size_t)(p - data) + ADD_MAX) <= max_bytes; p++) { + enum o_stream_dot_sendv_add add = ADD_NONE; + + size = pend - p; + switch (dstream->state) { + /* none */ + case STREAM_STATE_NONE: { +- size_t maxlen = I_MIN(size, max_bytes - ((size_t)(p - data) + 2)); ++ size_t maxlen = I_MIN(size, max_bytes - ((size_t)(p - data) + ADD_MAX)); + p = CONST_PTR_OFFSET(p, i_memcspn(p, maxlen, "\r\n", 2)); + i_assert(p <= pend); + if (p == pend) { +@@ -145,6 +147,9 @@ o_stream_dot_sendv(struct ostream_privat + case '\n': + dstream->state = STREAM_STATE_CRLF; + break; ++ case '.': ++ add = ADD_LF_DOT; ++ /* fall through */ + default: + dstream->state = STREAM_STATE_NONE; + break; +@@ -186,9 +191,31 @@ o_stream_dot_sendv(struct ostream_privat + max_bytes -= chunk; + sent += chunk; + } +- /* insert byte (substitute one with pair) */ + data++; + ++ /* Apply the modifications required according to RFC 5321 to the ++ stream : ++ ++ ADD_DOT - Apply standard SMTP dot-stuffing ++ ++ ADD_CR - Convert invalid lone LFs to CRLF to comply with ++ DATA line termination requirements, and avoid ++ potentially emitting bare "\n." sequences (same ++ threat as below for "\r." sequences). ++ ++ ADD_LF_DOT - Guard against invalid "\r." sequences. ++ A downstream MTA that incorrectly handles bare CRs ++ as line terminators could interpret a '.' at the ++ start of the next line as end-of-DATA. This would ++ allow any following bytes to be processed as SMTP ++ commands. ++ ++ ADD_CR and ADD_LF_DOT make the istream-dot round-trip lossy, ++ as these introduce bytes not present in the original input. ++ We accept this tradeoff, since preserving exact byte fidelity ++ for malformed input is less important than eliminating an ++ injection vector. */ ++ + switch(add) { + case ADD_DOT: + iovn.iov_base = ".."; +@@ -198,12 +225,17 @@ o_stream_dot_sendv(struct ostream_privat + iovn.iov_base = "\r\n"; + iovn.iov_len = 2; + break; ++ case ADD_LF_DOT: ++ iovn.iov_base = "\n.."; ++ iovn.iov_len = 3; ++ break; + default: + i_unreached(); + } + + array_push_back(&iov_arr, &iovn); +- i_assert(max_bytes >= iovn.iov_len); ++ i_assert(iovn.iov_len <= ADD_MAX); ++ i_assert(iovn.iov_len <= max_bytes); + max_bytes -= iovn.iov_len; + added += iovn.iov_len - 1; + sent++; +Index: trixie/src/lib-mail/test-ostream-dot.c +=================================================================== +--- trixie.orig/src/lib-mail/test-ostream-dot.c ++++ trixie/src/lib-mail/test-ostream-dot.c +@@ -57,6 +57,9 @@ static void test_ostream_dot(void) + { "foo\n.\n", "foo\r\n..\r\n.\r\n" }, + { ".foo\r\n.\r\nfoo\r\n", "..foo\r\n..\r\nfoo\r\n.\r\n" }, + { ".foo\n.\nfoo\n", "..foo\r\n..\r\nfoo\r\n.\r\n" }, ++ { ".", "..\r\n.\r\n" }, ++ { "\r.", "\r\n..\r\n.\r\n" }, ++ { "\r\r.", "\r\r\n..\r\n.\r\n" }, + { "\r\n", "\r\n.\r\n" }, + { "\n", "\r\n.\r\n" }, + { "", "\r\n.\r\n" }, +@@ -92,7 +95,7 @@ static void test_ostream_dot_parent_almo + test_end(); + } + +-static void test_ostream_dot_parent_exact_fit(void) ++static void test_ostream_dot_parent_max_bytes_boundary(void) + { + buffer_t *output_data; + struct ostream *test_output, *output; +@@ -100,8 +103,8 @@ static void test_ostream_dot_parent_exac + + test_begin("dot ostream parent exact fit"); + output_data = t_buffer_create(1024); +- test_output = test_ostream_create_nonblocking(output_data, 2); +- test_ostream_set_max_output_size(test_output, 2); ++ test_output = test_ostream_create_nonblocking(output_data, 3); ++ test_ostream_set_max_output_size(test_output, 3); + + output = o_stream_create_dot(test_output, FALSE); + ret = o_stream_send(output, ".", 1); +@@ -114,12 +117,72 @@ static void test_ostream_dot_parent_exac + test_end(); + } + ++/* STATE_CR must persist across sendv calls so that a bare CR ending one send ++ followed by '.' starting the next still triggers ADD_LF_DOT. ++ A stateless per-call implementation would emit the '.' unguarded and let a ++ downstream MTA that treats bare CR as EOL interpret it as end-of-DATA. */ ++static void test_ostream_dot_cr_across_sendv(void) ++{ ++ buffer_t *output_data; ++ struct ostream *test_output, *output; ++ ssize_t ret; ++ const char *expected = "\r\n..\r\n.\r\n"; ++ ++ test_begin("dot ostream CR across sendv calls"); ++ output_data = t_buffer_create(1024); ++ test_output = o_stream_create_buffer(output_data); ++ ++ output = o_stream_create_dot(test_output, FALSE); ++ ret = o_stream_send(output, "\r", 1); ++ test_assert(ret == 1); ++ ret = o_stream_send(output, ".", 1); ++ test_assert(ret == 1); ++ test_assert(o_stream_finish(output) > 0); ++ ++ o_stream_unref(&output); ++ o_stream_unref(&test_output); ++ ++ test_assert_ucmp(str_len(output_data), ==, strlen(expected)); ++ test_assert_memcmp(str_c(output_data), str_len(output_data), ++ expected, strlen(expected)); ++ test_end(); ++} ++ ++/* Off-by-one regression guard for the bare-CR + '.' injection. ++ Input "\r." places '.' at offset 1; the ADD_LF_DOT pair injects 3 bytes ++ ("\n.."). The loop guard check becomes (1 + 3) <= max_bytes, so max_bytes==4 ++ is the exact-fit case. */ ++static void test_ostream_dot_cr_dot_exact_fit(void) ++{ ++ buffer_t *output_data; ++ struct ostream *test_output, *output; ++ ssize_t ret; ++ ++ test_begin("dot ostream CR+dot exact fit"); ++ output_data = t_buffer_create(1024); ++ test_output = test_ostream_create_nonblocking(output_data, 4); ++ test_ostream_set_max_output_size(test_output, 4); ++ ++ output = o_stream_create_dot(test_output, FALSE); ++ ret = o_stream_send(output, "\r.", 2); ++ test_assert(ret == 2); ++ test_assert_ucmp(output_data->used, ==, 4); ++ test_assert_memcmp(output_data->data, output_data->used, ++ "\r\n..", 4); ++ ++ o_stream_unref(&output); ++ o_stream_unref(&test_output); ++ test_end(); ++} ++ + int main(void) + { + static void (*const test_functions[])(void) = { + test_ostream_dot, + test_ostream_dot_parent_almost_full, +- test_ostream_dot_parent_exact_fit, ++ test_ostream_dot_parent_max_bytes_boundary, ++ test_ostream_dot_cr_across_sendv, ++ test_ostream_dot_cr_dot_exact_fit, + NULL + }; + return test_run(test_functions); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-lib-sieve-Switch-sieve_max_cpu_time-enforcement-to-t.patch dovecot-2.4.1+dfsg1/debian/patches/0004-lib-sieve-Switch-sieve_max_cpu_time-enforcement-to-t.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-lib-sieve-Switch-sieve_max_cpu_time-enforcement-to-t.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-lib-sieve-Switch-sieve_max_cpu_time-enforcement-to-t.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,294 @@ +From 7a55953924c54dc116150ae809b976456977db75 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 5 May 2026 16:30:23 +0000 +Subject: [PATCH 4/7] lib-sieve: Switch sieve_max_cpu_time enforcement to + time-decayed re-enable + +When a user's cumulative CPU time exceeded sieve_max_cpu_time, the +script was permanently disabled. SIEVE_BINARY_FLAG_RESOURCE_LIMIT was +set in the binary header on first overrun and was never cleared by +the resource_usage_timeout decay (which only zeroed the cpu_time +field), so sieve_binary_check_executable() refused execution forever +unless the binary was recompiled or an admin manually wiped the file. + +Replace the sticky-flag model with one driven purely by the persisted +cumulative cpu_time, decayed by resource_usage_timeout: + + - Drop SIEVE_BINARY_FLAG_RESOURCE_LIMIT and the enum that defined it; + nothing reads sbin->header.flags anymore. + - sieve_binary_check_executable() and sieve_binary_check_resource_usage() + call sieve_resource_usage_is_excessive() on the current cumulative + cpu_time. Raising sieve_max_cpu_time now correctly re-enables a + previously over-limit user without further intervention. + - Gate per-user file persistence on the per-run delta, not the + cumulative total. A refused-without-running attempt has a zero + delta, skips the file write, and leaves update_time at the moment + work was actually consumed. After resource_usage_timeout seconds + of inactivity (or only-trivial deliveries) the next load decays + the cpu_time to zero and the user is allowed to run again. + +The on-disk per-user sieve-rusage file format becomes: + + V1 +--- + src/lib-sieve/sieve-binary-file.c | 28 ++++++++++++++-------------- + src/lib-sieve/sieve-binary-private.h | 4 ---- + src/lib-sieve/sieve-binary.c | 13 ++----------- + src/lib-sieve/sieve-rusage.c | 25 ++++++++----------------- + src/lib-sieve/sieve-rusage.h | 14 +++++--------- + 5 files changed, 29 insertions(+), 55 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-file.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +@@ -315,7 +315,7 @@ sieve_binary_save_to_stream(struct sieve + header->blocks = blk_count; + header->hdr_size = sizeof(*header); + +- header->flags &= ENUM_NEGATE(SIEVE_BINARY_FLAG_RESOURCE_LIMIT); ++ header->flags = 0; + sieve_binary_file_zero_header_rusage(sbin); + + if (!_save_aligned(sbin, stream, header, sizeof(*header), NULL)) { +@@ -837,7 +837,7 @@ _sieve_binary_open(struct sieve_binary * + discard any data from older versions. */ + sbin->header.unused_update_time = 0; + sbin->header.unused_cpu_time_msecs = 0; +- sbin->header.flags &= ENUM_NEGATE(SIEVE_BINARY_FLAG_RESOURCE_LIMIT); ++ sbin->header.flags = 0; + + /* Load block index */ + +@@ -934,15 +934,16 @@ int sieve_binary_check_executable(struct + enum sieve_error *error_code_r, + const char **client_error_r) + { ++ struct sieve_resource_usage rusage; ++ + *client_error_r = NULL; + sieve_error_args_init(&error_code_r, NULL); + +- if (HAS_ALL_BITS(sbin->header.flags, +- SIEVE_BINARY_FLAG_RESOURCE_LIMIT)) { ++ sieve_binary_get_resource_usage(sbin, &rusage); ++ if (sieve_resource_usage_is_excessive(sbin->svinst, &rusage)) { + e_debug(sbin->event, + "Binary execution is blocked: " +- "Cumulative resource usage limit exceeded " +- "(resource limit flag is set)"); ++ "Cumulative resource usage limit exceeded"); + *error_code_r = SIEVE_ERROR_RESOURCE_LIMIT; + *client_error_r = "cumulative resource usage limit exceeded"; + return 0; +@@ -959,8 +960,6 @@ int sieve_binary_file_update_resource_us + { + struct sieve_storage *storage; + struct sieve_resource_usage delta; +- struct sieve_resource_usage total; +- uint32_t flags; + + sieve_error_args_init(&error_code_r, NULL); + +@@ -976,18 +975,19 @@ int sieve_binary_file_update_resource_us + storage = sbin->script->storage; + + delta = sbin->rusage; +- sieve_binary_get_resource_usage(sbin, &total); +- flags = sbin->header.flags & SIEVE_BINARY_FLAG_RESOURCE_LIMIT; + +- if (!HAS_ALL_BITS(flags, SIEVE_BINARY_FLAG_RESOURCE_LIMIT) && +- !sieve_resource_usage_is_high(sbin->svinst, &total)) { +- /* Nothing meaningful to persist */ ++ /* Persist only when this run consumed a meaningful amount of CPU. ++ Critically, refused-without-running attempts (delta == 0) must not ++ touch the file - otherwise the file's update_time would be refreshed ++ on every blocked attempt, preventing the resource_usage_timeout from ++ ever decaying the persisted CPU time and re-enabling the user. */ ++ if (!sieve_resource_usage_is_high(sbin->svinst, &delta)) { + sieve_resource_usage_init(&sbin->rusage); + sbin->rusage_updated = FALSE; + return 0; + } + +- if (sieve_rusage_storage_add(storage, &delta, flags) < 0) { ++ if (sieve_rusage_storage_add(storage, &delta) < 0) { + *error_code_r = SIEVE_ERROR_TEMP_FAILURE; + return -1; + } +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-private.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-private.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-private.h +@@ -13,10 +13,6 @@ + * Binary file + */ + +-enum SIEVE_BINARY_FLAGS { +- SIEVE_BINARY_FLAG_RESOURCE_LIMIT = BIT(0), +-}; +- + struct sieve_binary_header { + uint32_t magic; + uint16_t version_major; +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary.c +@@ -210,17 +210,14 @@ void sieve_binary_apply_persisted_rusage + { + struct sieve_storage *storage = sieve_binary_get_user_storage(sbin); + struct sieve_resource_usage rusage; +- struct sieve_binary_header *header = &sbin->header; +- uint32_t flags = 0; + + if (storage == NULL) + return; +- if (sieve_rusage_storage_load(storage, &rusage, &flags) <= 0) ++ if (sieve_rusage_storage_load(storage, &rusage) <= 0) + return; + + sbin->persisted_rusage.cpu_time_msecs = rusage.cpu_time_msecs; + sbin->persisted_rusage.update_time = ioloop_time; +- header->flags |= (flags & SIEVE_BINARY_FLAG_RESOURCE_LIMIT); + sieve_resource_usage_init(&sbin->rusage); + sbin->rusage_updated = FALSE; + } +@@ -241,16 +238,10 @@ void sieve_binary_get_resource_usage(str + + bool sieve_binary_check_resource_usage(struct sieve_binary *sbin) + { +- struct sieve_binary_header *header = &sbin->header; + struct sieve_resource_usage rusage; + + sieve_binary_get_resource_usage(sbin, &rusage); +- +- if (sieve_resource_usage_is_excessive(sbin->svinst, &rusage)) { +- header->flags |= SIEVE_BINARY_FLAG_RESOURCE_LIMIT; +- return FALSE; +- } +- return TRUE; ++ return !sieve_resource_usage_is_excessive(sbin->svinst, &rusage); + } + + bool sieve_binary_record_resource_usage( +Index: trixie/pigeonhole/src/lib-sieve/sieve-rusage.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-rusage.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-rusage.c +@@ -26,12 +26,11 @@ + #define SIEVE_RUSAGE_LOCK_TIMEOUT 10 + #define SIEVE_RUSAGE_LOCK_STALE_TIMEOUT 60 + +-/* Maximum size of the on-disk file. Single-line ASCII format with four ++/* Maximum size of the on-disk file. Single-line ASCII format with three + space-separated tokens; anything larger is treated as corrupt. */ + #define SIEVE_RUSAGE_MAX_FILE_SIZE 128 + + struct sieve_rusage_record { +- uint32_t flags; + uint32_t cpu_time_msecs; + time_t update_time; + }; +@@ -72,13 +71,11 @@ sieve_rusage_parse(const char *line, str + strcmp(tokens[0], SIEVE_RUSAGE_VERSION_TAG) != 0) + break; + if (tokens[1] == NULL || tokens[2] == NULL || +- tokens[3] == NULL || tokens[4] != NULL) ++ tokens[3] != NULL) + break; +- if (str_to_uint32(tokens[1], &rec->flags) < 0) ++ if (str_to_uint32(tokens[1], &cpu_secs) < 0) + break; +- if (str_to_uint32(tokens[2], &cpu_secs) < 0) +- break; +- if (str_to_time(tokens[3], &rec->update_time) < 0) ++ if (str_to_time(tokens[2], &rec->update_time) < 0) + break; + if (cpu_secs > UINT32_MAX / 1000) + rec->cpu_time_msecs = UINT32_MAX; +@@ -148,14 +145,12 @@ sieve_rusage_dotlock_settings(struct dot + } + + int sieve_rusage_storage_load(struct sieve_storage *storage, +- struct sieve_resource_usage *rusage_r, +- uint32_t *flags_r) ++ struct sieve_resource_usage *rusage_r) + { + struct sieve_rusage_record rec; + unsigned int timeout; + + sieve_resource_usage_init(rusage_r); +- *flags_r = 0; + + if (storage->rusage_path == NULL) + return 0; +@@ -171,13 +166,11 @@ int sieve_rusage_storage_load(struct sie + } + + rusage_r->cpu_time_msecs = rec.cpu_time_msecs; +- *flags_r = rec.flags; + return 1; + } + + int sieve_rusage_storage_add(struct sieve_storage *storage, +- const struct sieve_resource_usage *delta_rusage, +- uint32_t flags_to_set) ++ const struct sieve_resource_usage *delta_rusage) + { + struct sieve_rusage_record rec; + struct dotlock_settings dlset; +@@ -226,7 +219,6 @@ int sieve_rusage_storage_add(struct siev + (ioloop_time - rec.update_time) > (time_t)timeout) { + /* decayed: discard previous values */ + rec.cpu_time_msecs = 0; +- rec.flags = 0; + } + + old_cpu = rec.cpu_time_msecs; +@@ -234,7 +226,6 @@ int sieve_rusage_storage_add(struct siev + rec.cpu_time_msecs = UINT32_MAX; + else + rec.cpu_time_msecs = old_cpu + delta_rusage->cpu_time_msecs; +- rec.flags |= flags_to_set; + rec.update_time = ioloop_time; + + /* Round CPU time up to whole seconds for persistence. */ +@@ -244,8 +235,8 @@ int sieve_rusage_storage_add(struct siev + cpu_secs = (rec.cpu_time_msecs + 999) / 1000; + + out = t_str_new(64); +- str_printfa(out, "%s %u %u %lld\n", SIEVE_RUSAGE_VERSION_TAG, +- rec.flags, cpu_secs, (long long)rec.update_time); ++ str_printfa(out, "%s %u %lld\n", SIEVE_RUSAGE_VERSION_TAG, ++ cpu_secs, (long long)rec.update_time); + + wret = write(fd, str_data(out), str_len(out)); + if (wret != (ssize_t)str_len(out)) { +Index: trixie/pigeonhole/src/lib-sieve/sieve-rusage.h +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-rusage.h ++++ trixie/pigeonhole/src/lib-sieve/sieve-rusage.h +@@ -16,17 +16,13 @@ void sieve_rusage_storage_init(struct si + + /* Load persisted resource usage from the per-user file. Returns 1 on success, + 0 if the storage has no rusage file (no INBOX namespace path), -1 on error. +- Applies the resource_usage_timeout decay. flags_r receives sieve binary +- header flags (RESOURCE_LIMIT) that were persisted. */ ++ Applies the resource_usage_timeout decay. */ + int sieve_rusage_storage_load(struct sieve_storage *storage, +- struct sieve_resource_usage *rusage_r, +- uint32_t *flags_r); ++ struct sieve_resource_usage *rusage_r); + +-/* Atomically add delta_rusage to and OR flags_to_set into the persisted +- per-user rusage file. Read-modify-write under fcntl WRLCK. Returns 1 on +- success, 0 if no rusage file is configured, -1 on error. */ ++/* Atomically add delta_rusage to the persisted per-user rusage file. ++ Returns 1 on success, 0 if no rusage file is configured, -1 on error. */ + int sieve_rusage_storage_add(struct sieve_storage *storage, +- const struct sieve_resource_usage *delta_rusage, +- uint32_t flags_to_set); ++ const struct sieve_resource_usage *delta_rusage); + + #endif diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-lib-var-expand-Change-escape-func-signature-to-retur.patch dovecot-2.4.1+dfsg1/debian/patches/0004-lib-var-expand-Change-escape-func-signature-to-retur.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-lib-var-expand-Change-escape-func-signature-to-retur.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-lib-var-expand-Change-escape-func-signature-to-retur.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,305 @@ +From d7a040f5b0230368dd97112daf7a710446fdfd16 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Thu, 21 May 2026 11:00:56 +0300 +Subject: [PATCH 4/6] lib-var-expand: Change escape func signature to return + int with error_r + +Wire error propagation in var_expand_program_execute_one_real() so a +failing escape function causes the expansion to return -1. + +Update all implementations (passdb-sql, userdb-sql, db-ldap, dict-ldap, +auth-request-var-expand) and replace the unsafe auth_request_escape_func_t +cast in auth-request-var-expand.c with a proper bridge wrapper. +--- + src/auth/auth-request-var-expand.c | 13 +++++++++++-- + src/auth/db-ldap.c | 12 ++++++++---- + src/auth/db-ldap.h | 4 ++-- + src/auth/passdb-sql.c | 6 ++++-- + src/auth/test-auth-request-var-expand.c | 16 ++++++++++++---- + src/auth/test-ldap.c | 5 +++-- + src/auth/userdb-sql.c | 6 ++++-- + src/lib-dict-backend/dict-ldap.c | 6 ++++-- + src/lib-var-expand/expansion-filter.c | 7 +++++-- + src/lib-var-expand/expansion-program.c | 11 +++++++---- + src/lib-var-expand/test-var-expand.c | 6 ++++-- + src/lib-var-expand/var-expand.h | 7 ++++--- + 12 files changed, 68 insertions(+), 31 deletions(-) + +Index: trixie/src/auth/auth-request-var-expand.c +=================================================================== +--- trixie.orig/src/auth/auth-request-var-expand.c ++++ trixie/src/auth/auth-request-var-expand.c +@@ -55,6 +55,15 @@ escape_none(const char *string, + return string; + } + ++static int ++auth_request_escape_wrapper(const char *input, const char **output_r, ++ void *context, const char **error_r ATTR_UNUSED) ++{ ++ const struct auth_request_var_expand_ctx *ctx = context; ++ *output_r = ctx->escape_func(input, ctx->auth_request); ++ return 0; ++} ++ + const char * + auth_request_str_escape(const char *string, + const struct auth_request *request ATTR_UNUSED) +@@ -242,9 +251,9 @@ int auth_request_var_expand_with_table(s + const struct var_expand_params params = { + .table = table, + .providers = auth_request_var_expand_providers, +- .escape_func = (var_expand_escape_func_t *)ctx.escape_func, ++ .escape_func = auth_request_escape_wrapper, + .context = &ctx, +- .escape_context = (void *)auth_request, ++ .escape_context = &ctx, + .event = auth_request->event, + }; + +Index: trixie/src/auth/db-ldap.c +=================================================================== +--- trixie.orig/src/auth/db-ldap.c ++++ trixie/src/auth/db-ldap.c +@@ -1138,7 +1138,8 @@ void db_ldap_get_attribute_names(pool_t + *sensitive_r = array_front(&sensitive_attr_names); + } + +-const char *ldap_escape(const char *input, void *context ATTR_UNUSED) ++int ldap_escape(const char *input, const char **output_r, ++ void *context ATTR_UNUSED, const char **error_r ATTR_UNUSED) + { + /* This function escapes both LDAP filters and LDAP DNs. This works, + because both allow using the method of escaping any characters. +@@ -1195,8 +1196,10 @@ const char *ldap_escape(const char *inpu + (required by DN) */ + if (pos > 0 && input[pos - 1] == ' ') + pos--; +- else +- return input; ++ else { ++ *output_r = input; ++ return 0; ++ } + } + } + +@@ -1211,7 +1214,8 @@ const char *ldap_escape(const char *inpu + pos--; + } while (pos < input_len); + str_append_data(str, input, pos); +- return str_c(str); ++ *output_r = str_c(str); ++ return 0; + } + + static bool +Index: trixie/src/auth/db-ldap.h +=================================================================== +--- trixie.orig/src/auth/db-ldap.h ++++ trixie/src/auth/db-ldap.h +@@ -169,8 +169,8 @@ void db_ldap_connect_delayed(struct ldap + + void db_ldap_enable_input(struct ldap_connection *conn, bool enable); + +-const char *ldap_dn_escape(const char *str, void *context); +-const char *ldap_escape(const char *str, void *context); ++int ldap_dn_escape(const char *str, const char **output_r, void *context, const char **error_r); ++int ldap_escape(const char *str, const char **output_r, void *context, const char **error_r); + const char *ldap_get_error(struct ldap_connection *conn); + + struct db_ldap_result_iterate_context * +Index: trixie/src/auth/passdb-sql.c +=================================================================== +--- trixie.orig/src/auth/passdb-sql.c ++++ trixie/src/auth/passdb-sql.c +@@ -167,10 +167,12 @@ static void sql_query_callback(struct sq + auth_request_unref(&auth_request); + } + +-static const char *passdb_sql_escape(const char *str, void *context) ++static int passdb_sql_escape(const char *str, const char **output_r, ++ void *context, const char **error_r ATTR_UNUSED) + { + struct sql_db *db = context; +- return sql_escape_string(db, str); ++ *output_r = sql_escape_string(db, str); ++ return 0; + } + + static void sql_lookup_pass(struct passdb_sql_request *sql_request) +Index: trixie/src/auth/test-auth-request-var-expand.c +=================================================================== +--- trixie.orig/src/auth/test-auth-request-var-expand.c ++++ trixie/src/auth/test-auth-request-var-expand.c +@@ -70,6 +70,14 @@ test_escape(const char *string, const st + return dest; + } + ++static int ++test_escape_varexpand(const char *string, const char **output_r, ++ void *context, const char **error_r ATTR_UNUSED) ++{ ++ *output_r = test_escape(string, context); ++ return 0; ++} ++ + static bool test_empty_request(string_t *str, const char *input) + { + const struct var_expand_params params = { +@@ -102,7 +110,7 @@ static void test_auth_request_var_expand + + const struct var_expand_params params = { + .table = auth_request_get_var_expand_table(&test_request), +- .escape_func = (var_expand_escape_func_t *)test_escape, ++ .escape_func = test_escape_varexpand, + .escape_context = &test_request, + }; + +@@ -129,7 +137,7 @@ static void test_auth_request_var_expand + + struct var_expand_params params = { + .table = auth_request_get_var_expand_table(&test_request), +- .escape_func = (var_expand_escape_func_t *)test_escape, ++ .escape_func = test_escape_varexpand, + .escape_context = &test_request + }; + test_assert(var_expand(str, test_input, ¶ms, &error) == 0); +@@ -169,7 +177,7 @@ static void test_auth_request_var_expand + + const struct var_expand_params params = { + .table = auth_request_get_var_expand_table(&test_request), +- .escape_func = (var_expand_escape_func_t *)test_escape, ++ .escape_func = test_escape_varexpand, + .escape_context = &test_request, + }; + +@@ -200,7 +208,7 @@ static void test_auth_request_var_expand + test_request.fields.user = t_strdup_noconst(tests[i].username); + const struct var_expand_params params = { + .table = auth_request_get_var_expand_table(&test_request), +- .escape_func = (var_expand_escape_func_t *)test_escape, ++ .escape_func = test_escape_varexpand, + .escape_context = &test_request, + }; + str_truncate(str, 0); +Index: trixie/src/auth/test-ldap.c +=================================================================== +--- trixie.orig/src/auth/test-ldap.c ++++ trixie/src/auth/test-ldap.c +@@ -25,8 +25,9 @@ static void test_ldap_escape(void) + }; + test_begin("ldap_escape()"); + for (unsigned int i = 0; i < N_ELEMENTS(tests); i++) { +- test_assert_strcmp_idx(ldap_escape(tests[i].input, NULL), +- tests[i].output, i); ++ const char *output, *error; ++ test_assert(ldap_escape(tests[i].input, &output, NULL, &error) == 0); ++ test_assert_strcmp_idx(output, tests[i].output, i); + } + test_end(); + } +Index: trixie/src/auth/userdb-sql.c +=================================================================== +--- trixie.orig/src/auth/userdb-sql.c ++++ trixie/src/auth/userdb-sql.c +@@ -112,10 +112,12 @@ static void sql_query_callback(struct sq + i_free(sql_request); + } + +-static const char *userdb_sql_escape(const char *str, void *context) ++static int userdb_sql_escape(const char *str, const char **output_r, ++ void *context, const char **error_r ATTR_UNUSED) + { + struct sql_db *db = context; +- return sql_escape_string(db, str); ++ *output_r = sql_escape_string(db, str); ++ return 0; + } + + static void userdb_sql_lookup(struct auth_request *auth_request, +Index: trixie/src/lib-dict-backend/dict-ldap.c +=================================================================== +--- trixie.orig/src/lib-dict-backend/dict-ldap.c ++++ trixie/src/lib-dict-backend/dict-ldap.c +@@ -139,7 +139,8 @@ int dict_ldap_connect(struct ldap_dict * + #define IS_LDAP_ESCAPED_CHAR(c) \ + ((((unsigned char)(c)) & 0x80) != 0 || strchr(LDAP_ESCAPE_CHARS, (c)) != NULL) + +-static const char *ldap_escape(const char *str, void *context ATTR_UNUSED) ++static int ldap_escape(const char *str, const char **output_r, ++ void *context ATTR_UNUSED, const char **error_r ATTR_UNUSED) + { + string_t *ret = NULL; + +@@ -154,7 +155,8 @@ static const char *ldap_escape(const cha + str_append_c(ret, *p); + } + +- return ret == NULL ? str : str_c(ret); ++ *output_r = ret == NULL ? str : str_c(ret); ++ return 0; + } + + static +Index: trixie/src/lib-var-expand/expansion-program.c +=================================================================== +--- trixie.orig/src/lib-var-expand/expansion-program.c ++++ trixie/src/lib-var-expand/expansion-program.c +@@ -142,9 +142,11 @@ int var_expand_program_execute(string_t + if (state.transfer_set) { + if (!program->only_literal && !state.transfer_safe && + params->escape_func != NULL) { +- str_append(state.result, +- params->escape_func(str_c(state.transfer), +- params->escape_context)); ++ const char *escaped; ++ if (params->escape_func(str_c(state.transfer), &escaped, ++ params->escape_context, error_r) < 0) ++ return -1; ++ str_append(state.result, escaped); + } else + str_append_str(state.result, state.transfer); + } else { +Index: trixie/src/lib-var-expand/test-var-expand.c +=================================================================== +--- trixie.orig/src/lib-var-expand/test-var-expand.c ++++ trixie/src/lib-var-expand/test-var-expand.c +@@ -586,7 +586,8 @@ static void test_var_expand_tables_arr(v + test_end(); + } + +-static const char *test_escape(const char *str, void *context) ++static int test_escape(const char *str, const char **output_r, ++ void *context, const char **error_r ATTR_UNUSED) + { + const char *escape_chars = context; + string_t *dest = t_str_new(strlen(str) + 2); +@@ -601,7 +602,8 @@ static const char *test_escape(const cha + } + } + str_append_c(dest, '\''); +- return str_c(dest); ++ *output_r = str_c(dest); ++ return 0; + } + + static void test_var_expand_escape(void) +Index: trixie/src/lib-var-expand/var-expand.h +=================================================================== +--- trixie.orig/src/lib-var-expand/var-expand.h ++++ trixie/src/lib-var-expand/var-expand.h +@@ -11,9 +11,10 @@ + */ + typedef int value_provider_func_t(const char *key, const char **value_r, + void *context, const char **error_r); +-/* Used for escaping values, gets given string to escape and context, +- must return escaped string. */ +-typedef const char *var_expand_escape_func_t(const char *str, void *context); ++/* Used for escaping values. On success sets output_r and returns 0. ++ On failure sets error_r and returns -1. */ ++typedef int var_expand_escape_func_t(const char *input, const char **output_r, ++ void *context, const char **error_r); + + struct var_expand_parser_state; + struct var_expand_program; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0004-submission-login-client-authenticate-Reply-421-4.7.0.patch dovecot-2.4.1+dfsg1/debian/patches/0004-submission-login-client-authenticate-Reply-421-4.7.0.patch --- dovecot-2.4.1+dfsg1/debian/patches/0004-submission-login-client-authenticate-Reply-421-4.7.0.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0004-submission-login-client-authenticate-Reply-421-4.7.0.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,51 @@ +From 4e968e6c8614a935a83024ba4ac9a5c9b6c6b7ff Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 7 May 2026 10:58:12 +0000 +Subject: [PATCH 4/5] submission-login: client-authenticate - Reply 421 4.7.0 + on mail_max_userip_connections + +Until now the connection limit was reported via the same 454 4.7.0 reply +that is used for generic temporary authentication failures. That makes +proxies (including Dovecot's own submission proxy) treat the rejection as +a transient auth error and retry, which is futile when the limit is hit +and only obscures the actual cause in the proxy log. + +Reply with 421 4.7.0 instead. RFC 5321 Section 4.2.1 specifies 421 as +"service shutting down, closing transmission channel", which is the +right signal for "do not retry on this connection". The 421 + 4.7.0 +combination is unique among the 421 replies emitted by submission and is +used by the submission proxy to recognize this specifically as a +connection-limit reply rather than a generic 421 internal/shutdown. +--- + src/submission-login/client-authenticate.c | 19 ++++++++++++++++++- + 1 file changed, 18 insertions(+), 1 deletion(-) + +Index: trixie/src/submission-login/client-authenticate.c +=================================================================== +--- trixie.orig/src/submission-login/client-authenticate.c ++++ trixie/src/submission-login/client-authenticate.c +@@ -151,6 +151,24 @@ void submission_client_auth_result(struc + */ + smtp_server_reply(cmd, 454, "4.7.0", "%s", text); + break; ++ case CLIENT_AUTH_RESULT_LIMIT_REACHED: ++ /* The user has too many concurrent connections. Reply with ++ 421 4.7.0: 421 means "service shutting down, closing ++ transmission channel" (RFC 5321 Section 4.2.1) and ++ signals the client that retrying on this same connection ++ is pointless. The proxy uses the 421 + 4.7.0 combination ++ to recognize this specifically as a connection-limit ++ response (rather than a generic 421 internal/shutdown ++ reply) and avoid reconnecting. ++ ++ Use reply_immediate() rather than the queued ++ smtp_server_reply() because the caller (sasl-server) ++ immediately tears the connection down after this returns, ++ which would abort a queued reply before it reaches the ++ wire. */ ++ smtp_server_connection_reply_immediate(subm_client->conn, ++ 421, "4.7.0 %s", text); ++ break; + case CLIENT_AUTH_RESULT_ABORTED: + /* RFC4954, Section 4: + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-imap-Add-imap_compress_on_proxy-hidden-setting.patch dovecot-2.4.1+dfsg1/debian/patches/0005-imap-Add-imap_compress_on_proxy-hidden-setting.patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-imap-Add-imap_compress_on_proxy-hidden-setting.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-imap-Add-imap_compress_on_proxy-hidden-setting.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,62 @@ +From c27391fb8528e95e2b2b30c9694aab1ab4341b14 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 4 Jun 2025 17:05:36 +0300 +Subject: [PATCH 5/6] imap: Add imap_compress_on_proxy hidden setting + +Keep it disabled by default for now. Once we're sure COMPRESS on proxy +works properly we'll enable it again. +--- + src/imap/cmd-compress.c | 3 ++- + src/imap/imap-settings.c | 2 ++ + src/imap/imap-settings.h | 1 + + 3 files changed, 5 insertions(+), 1 deletion(-) + +diff --git a/src/imap/cmd-compress.c b/src/imap/cmd-compress.c +index 18aa988892..0367cc6f64 100644 +--- a/src/imap/cmd-compress.c ++++ b/src/imap/cmd-compress.c +@@ -87,7 +87,8 @@ bool cmd_compress(struct client_command_context *cmd) + return TRUE; + } + +- if (client->multiplex_output != NULL) { ++ if (client->multiplex_output != NULL && ++ client->set->imap_compress_on_proxy) { + /* Let imap-login process handle the COMPRESS. It's the one + that will send the tagged reply to the client. */ + client->compress_handler = handler; +diff --git a/src/imap/imap-settings.c b/src/imap/imap-settings.c +index aa0cd747cb..3e2561ccd2 100644 +--- a/src/imap/imap-settings.c ++++ b/src/imap/imap-settings.c +@@ -81,6 +81,7 @@ static const struct setting_define imap_setting_defines[] = { + DEF(ENUM, imap_fetch_failure), + DEF(BOOL, imap_metadata), + DEF(BOOL, imap_literal_minus), ++ DEF(BOOL_HIDDEN, imap_compress_on_proxy), + DEF(BOOL, mail_utf8_extensions), + #ifdef BUILD_IMAP_HIBERNATE + DEF(TIME, imap_hibernate_timeout), +@@ -115,6 +116,7 @@ static const struct imap_settings imap_default_settings = { + .imap_fetch_failure = "disconnect-immediately:disconnect-after:no-after", + .imap_metadata = FALSE, + .imap_literal_minus = FALSE, ++ .imap_compress_on_proxy = FALSE, + .mail_utf8_extensions = FALSE, + #ifdef DOVECOT_PRO_EDITION + .imap_hibernate_timeout = 30, +diff --git a/src/imap/imap-settings.h b/src/imap/imap-settings.h +index 202ea45a30..347d85cec7 100644 +--- a/src/imap/imap-settings.h ++++ b/src/imap/imap-settings.h +@@ -34,6 +34,7 @@ struct imap_settings { + const char *imap_fetch_failure; + bool imap_metadata; + bool imap_literal_minus; ++ bool imap_compress_on_proxy; + bool mail_utf8_extensions; + unsigned int imap_hibernate_timeout; + ARRAY_TYPE(const_string) imap_id_send; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-lib-Add-comments-about-memory-allocations-and-string.patch dovecot-2.4.1+dfsg1/debian/patches/0005-lib-Add-comments-about-memory-allocations-and-string.patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-lib-Add-comments-about-memory-allocations-and-string.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-lib-Add-comments-about-memory-allocations-and-string.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,81 @@ +From 626a19e307e915b2c545cad226aa57aec8620906 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 21:14:51 +0000 +Subject: [PATCH 5/5] lib: Add comments about memory allocations and string + functions preserving errno + +--- + src/lib/data-stack.h | 5 +++++ + src/lib/imem.h | 3 +++ + src/lib/mempool.h | 6 ++++++ + src/lib/strfuncs.h | 7 +++++++ + 4 files changed, 21 insertions(+) + +diff --git a/src/lib/data-stack.h b/src/lib/data-stack.h +index 7ff66c4ef4..e0febe494f 100644 +--- a/src/lib/data-stack.h ++++ b/src/lib/data-stack.h +@@ -31,6 +31,11 @@ + overflows. + */ + ++/* All data stack allocations - t_malloc(), t_malloc0(), t_buffer_get(), ++ t_try_realloc() etc. - preserve errno. It is therefore safe to allocate ++ temporary memory between a failing syscall and reading errno (or ++ formatting "%m"). */ ++ + #ifndef STATIC_CHECKER + typedef unsigned int data_stack_frame_t; + #else +diff --git a/src/lib/imem.h b/src/lib/imem.h +index ed8c2eb581..6635bc018a 100644 +--- a/src/lib/imem.h ++++ b/src/lib/imem.h +@@ -3,6 +3,9 @@ + + /* For easy allocation of memory from default memory pool. */ + ++/* Like all pool allocations, i_malloc()/i_realloc()/i_free() and the ++ i_strdup*() helpers preserve errno (see mempool.h). */ ++ + extern pool_t default_pool; + + #define i_new(type, count) p_new(default_pool, type, count) +diff --git a/src/lib/mempool.h b/src/lib/mempool.h +index 3b9872cb20..37ecff4c7c 100644 +--- a/src/lib/mempool.h ++++ b/src/lib/mempool.h +@@ -19,6 +19,12 @@ + zeroed, it will cost only a few CPU cycles and may well save some debug + time. */ + ++/* All pool memory operations - p_malloc(), p_realloc(), p_free() and the ++ p_new()/p_strdup*() helpers built on them - preserve errno. This means an ++ allocation between a failing syscall and reading errno (e.g. when building ++ an error string with "%m") will not clobber errno. The same guarantee holds ++ for the t_* (data stack) and i_* (default pool) allocators. */ ++ + typedef struct pool *pool_t; + + struct pool_vfuncs { +diff --git a/src/lib/strfuncs.h b/src/lib/strfuncs.h +index 715af58d8e..e6daa14847 100644 +--- a/src/lib/strfuncs.h ++++ b/src/lib/strfuncs.h +@@ -13,6 +13,13 @@ extern const char *const empty_str_array[]; + int i_snprintf(char *dest, size_t max_chars, const char *format, ...) + ATTR_FORMAT(3, 4); + ++/* The p_/t_/i_ strdup, strconcat and *printf helpers below preserve errno. ++ So this pattern is safe - errno still refers to the failed syscall when ++ "%m" is expanded: ++ ++ if (syscall(...) < 0) ++ error = t_strdup_printf("syscall() failed: %m"); */ ++ + char *p_strdup(pool_t pool, const char *str) ATTR_MALLOC; + void *p_memdup(pool_t pool, const void *data, size_t size) ATTR_MALLOC; + /* return NULL if str = "" */ +-- +2.39.5 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-lib-index-mail-index-strmap-Fix-OOB-read-from-trunca.patch dovecot-2.4.1+dfsg1/debian/patches/0005-lib-index-mail-index-strmap-Fix-OOB-read-from-trunca.patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-lib-index-mail-index-strmap-Fix-OOB-read-from-trunca.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-lib-index-mail-index-strmap-Fix-OOB-read-from-trunca.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,27 @@ +From b0f98d919ca586171c8149e52326e6f0c0ec5252 Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 17 Aug 2026 11:13:18 +0300 +Subject: [PATCH 05/12] lib-index: mail-index-strmap - Fix OOB read from + truncated record size + +--- + src/lib-index/mail-index-strmap.c | 3 +++ + 1 file changed, 3 insertions(+) + +diff --git a/src/lib-index/mail-index-strmap.c b/src/lib-index/mail-index-strmap.c +index fb92517068..9ce2de9103 100644 +--- a/src/lib-index/mail-index-strmap.c ++++ b/src/lib-index/mail-index-strmap.c +@@ -425,6 +425,9 @@ mail_index_strmap_read_rec_first(struct mail_index_strmap_read_context *ctx, + if (mail_index_strmap_read_packed(ctx, &n) <= 0) + return -1; + count = n < 2 ? n + 1 : n; ++ /* check that rec_size fits */ ++ if (UINT_MAX / (sizeof(ctx->rec.str_idx) + sizeof(*crc32_r)) < count) ++ return -1; + ctx->view->total_ref_count += count; + + ctx->rec_size = count * (sizeof(ctx->rec.str_idx) + sizeof(*crc32_r)); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-lib-mail-Add-count-field-to-struct-message_address_l.patch dovecot-2.4.1+dfsg1/debian/patches/0005-lib-mail-Add-count-field-to-struct-message_address_l.patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-lib-mail-Add-count-field-to-struct-message_address_l.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-lib-mail-Add-count-field-to-struct-message_address_l.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,56 @@ +From ef2cddece8101876c0e756536e6ef358c780dc07 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 15:46:10 +0200 +Subject: [PATCH 05/14] lib-mail: Add count field to struct + message_address_list + +Add a count field and increment it in add_address() so callers can read +the number of parsed addresses directly from the list struct without +walking the linked list. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/lib-mail/message-address.c | 1 + + src/lib-mail/message-address.h | 1 + + src/lib-mail/message-part-data.c | 1 + + 3 files changed, 3 insertions(+) + +diff --git a/src/lib-mail/message-address.c b/src/lib-mail/message-address.c +index 675e652e04..967cc518bf 100644 +--- a/src/lib-mail/message-address.c ++++ b/src/lib-mail/message-address.c +@@ -30,6 +30,7 @@ static void add_address(struct message_address_parser_context *ctx) + i_zero(&ctx->addr); + + DLLIST2_APPEND(&ctx->addr_list.head, &ctx->addr_list.tail, addr); ++ ctx->addr_list.count++; + } + + /* quote with "" and escape all '\', '"' and "'" characters if need */ +diff --git a/src/lib-mail/message-address.h b/src/lib-mail/message-address.h +index 224f7a7560..8b99ce1401 100644 +--- a/src/lib-mail/message-address.h ++++ b/src/lib-mail/message-address.h +@@ -33,6 +33,7 @@ struct message_address { + + struct message_address_list { + struct message_address *head, *tail; ++ unsigned int count; + }; + + /* Parse message addresses from given data. Note that giving an empty string +diff --git a/src/lib-mail/message-part-data.c b/src/lib-mail/message-part-data.c +index 895bf88e85..acd50e5210 100644 +--- a/src/lib-mail/message-part-data.c ++++ b/src/lib-mail/message-part-data.c +@@ -247,6 +247,7 @@ void message_part_envelope_parse_from_header(pool_t pool, + validated while MUA only shows the second From header. */ + DLLIST2_JOIN(&addr_p->head, &addr_p->tail, + &new_addr.head, &new_addr.tail); ++ addr_p->count += new_addr.count; + } else if (str_p != NULL) { + *str_p = message_header_strdup(pool, hdr->full_value, + hdr->full_value_len); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sieve-file-storage-Remove-orphan-compiled-binari.patch dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sieve-file-storage-Remove-orphan-compiled-binari.patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sieve-file-storage-Remove-orphan-compiled-binari.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sieve-file-storage-Remove-orphan-compiled-binari.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,55 @@ +From 88470b80fb4079cd2207bc8c4e986b816218421e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 5 May 2026 16:51:27 +0000 +Subject: [PATCH 5/7] lib-sieve: file storage: Remove orphan compiled binaries + on delete and rename + +DELETESCRIPT and RENAMESCRIPT only operated on the .sieve file and +left the matching compiled binary (.svbin) on disk. The orphan was +never referenced again under that name, so it accumulated as wasted +disk space until something else (manual cleanup, account removal) +removed it. + +Unlink fscript->bin_path alongside the .sieve operation. The unlink is +best-effort: failures other than ENOENT are logged but do not fail the +DELETE/RENAME. +--- + src/lib-sieve/storage/file/sieve-file-script.c | 18 ++++++++++++++++++ + 1 file changed, 18 insertions(+) + +Index: trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c ++++ trixie/pigeonhole/src/lib-sieve/storage/file/sieve-file-script.c +@@ -659,6 +659,14 @@ static int sieve_file_storage_script_del + fscript->path); + } + } ++ ++ /* Best-effort: drop the compiled binary as well so it does not ++ linger as a stale orphan. */ ++ if (fscript->bin_path != NULL && ++ unlink(fscript->bin_path) < 0 && errno != ENOENT) { ++ e_error(script->event, ++ "unlink(%s) failed: %m", fscript->bin_path); ++ } + return ret; + } + +@@ -791,6 +799,16 @@ sieve_file_storage_script_rename(struct + fscript->path); + } + ++ /* Drop the old compiled binary; the renamed ++ script will recompile under its new name. */ ++ if (fscript->bin_path != NULL && ++ unlink(fscript->bin_path) < 0 && ++ errno != ENOENT) { ++ e_error(script->event, ++ "unlink(%s) failed: %m", ++ fscript->bin_path); ++ } ++ + if (script->name != NULL && *script->name != '\0') + script->name = p_strdup(script->pool, newname); + fscript->path = p_strdup(script->pool, newpath); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sql-Make-escape_string-return-int-with-error_r-f.patch dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sql-Make-escape_string-return-int-with-error_r-f.patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sql-Make-escape_string-return-int-with-error_r-f.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-lib-sql-Make-escape_string-return-int-with-error_r-f.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,629 @@ +From 4e83f5367d2b897430c91c8be9f0830b16e3d90f Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 29 May 2026 11:22:57 +0000 +Subject: [PATCH 5/6] lib-sql: Make escape_string return int with error_r, fail + instead of unsafe fallback + +Change escape_string driver vfunc and sql_escape_string() to return int +with separate error_r output parameter. On failure (e.g. not connected), +return -1 instead of falling back to unsafe escaping. + +Move escaping from sql_statement_bind_str() to sql_statement_get_query() +so errors can be propagated to callers. Add failed_error field to +sql_transaction_context for deferred error reporting at commit time. +--- + src/auth/passdb-sql.c | 5 +- + src/auth/userdb-sql.c | 5 +- + src/lib-sql/driver-cassandra.c | 52 ++++++++++++----- + src/lib-sql/driver-mysql.c | 20 +++---- + src/lib-sql/driver-pgsql.c | 27 ++++++--- + src/lib-sql/driver-sqlite.c | 8 ++- + src/lib-sql/driver-sqlpool.c | 10 ++-- + src/lib-sql/driver-test.c | 27 +++++---- + src/lib-sql/sql-api-private.h | 8 ++- + src/lib-sql/sql-api.c | 103 +++++++++++++++++++++++++++------ + src/lib-sql/sql-api.h | 3 +- + 11 files changed, 188 insertions(+), 80 deletions(-) + +Index: trixie/src/auth/passdb-sql.c +=================================================================== +--- trixie.orig/src/auth/passdb-sql.c ++++ trixie/src/auth/passdb-sql.c +@@ -168,11 +168,10 @@ static void sql_query_callback(struct sq + } + + static int passdb_sql_escape(const char *str, const char **output_r, +- void *context, const char **error_r ATTR_UNUSED) ++ void *context, const char **error_r) + { + struct sql_db *db = context; +- *output_r = sql_escape_string(db, str); +- return 0; ++ return sql_escape_string(db, str, output_r, error_r); + } + + static void sql_lookup_pass(struct passdb_sql_request *sql_request) +Index: trixie/src/auth/userdb-sql.c +=================================================================== +--- trixie.orig/src/auth/userdb-sql.c ++++ trixie/src/auth/userdb-sql.c +@@ -113,11 +113,10 @@ static void sql_query_callback(struct sq + } + + static int userdb_sql_escape(const char *str, const char **output_r, +- void *context, const char **error_r ATTR_UNUSED) ++ void *context, const char **error_r) + { + struct sql_db *db = context; +- *output_r = sql_escape_string(db, str); +- return 0; ++ return sql_escape_string(db, str, output_r, error_r); + } + + static void userdb_sql_lookup(struct auth_request *auth_request, +Index: trixie/src/lib-sql/driver-cassandra.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-cassandra.c ++++ trixie/src/lib-sql/driver-cassandra.c +@@ -830,22 +830,26 @@ static void driver_cassandra_disconnect( + driver_cassandra_close(db, "Disconnected"); + } + +-static const char * ++static int + driver_cassandra_escape_string(struct sql_db *db ATTR_UNUSED, +- const char *string) ++ const char *string, const char **output_r, ++ const char **error_r ATTR_UNUSED) + { + string_t *escaped; + unsigned int i; + +- if (strchr(string, '\'') == NULL) +- return string; ++ if (strchr(string, '\'') == NULL) { ++ *output_r = string; ++ return 0; ++ } + escaped = t_str_new(strlen(string)+10); + for (i = 0; string[i] != '\0'; i++) { + if (string[i] == '\'') + str_append_c(escaped, '\''); + str_append_c(escaped, string[i]); + } +- return str_c(escaped); ++ *output_r = str_c(escaped); ++ return 0; + } + + static void +@@ -2192,8 +2196,13 @@ static void cassandra_transaction_finish + if (stmt->prep == NULL) + have_nonprepared = TRUE; + if (stmt->cass_stmt == NULL) { +- stmt->cass_stmt = cass_statement_new( +- sql_statement_get_query(&stmt->stmt), 0); ++ const char *query, *error; ++ if (sql_statement_get_query(&stmt->stmt, ++ &query, &error) < 0) { ++ cassandra_transaction_finish(ctx, error); ++ return; ++ } ++ stmt->cass_stmt = cass_statement_new(query, 0); + if (stmt->timestamp != 0) { + cass_statement_set_timestamp(stmt->cass_stmt, + stmt->timestamp); +@@ -2289,11 +2298,15 @@ driver_cassandra_try_commit_s(struct cas + i_panic("cassandra: sql_transaction_commit_s() not supported for prepared statements"); + } + ++ const char *query, *error; ++ if (sql_statement_get_query(&stmt->stmt, &query, &error) < 0) { ++ transaction_set_failed(ctx, error); ++ return; ++ } ++ + /* just a single query, send it */ + driver_cassandra_sync_init(db); +- result = driver_cassandra_sync_query(db, +- sql_statement_get_query(&stmt->stmt), +- ctx->query_type); ++ result = driver_cassandra_sync_query(db, query, ctx->query_type); + driver_cassandra_sync_deinit(db); + + if (sql_result_next_row(result) < 0) +@@ -2796,8 +2809,14 @@ driver_cassandra_statement_query(struct + } else { + /* Not a prepared statement. Generate a statement from + the query string. */ +- stmt->result->statement = +- cass_statement_new(sql_statement_get_query(_stmt), 0); ++ const char *query, *error; ++ if (sql_statement_get_query(_stmt, &query, &error) < 0) { ++ stmt->result->error = i_strdup(error); ++ result_finish(stmt->result); ++ cassandra_sql_statement_free(stmt); ++ return; ++ } ++ stmt->result->statement = cass_statement_new(query, 0); + stmt->result->timestamp = stmt->timestamp; + if (stmt->timestamp != 0) { + cass_statement_set_timestamp(stmt->result->statement, +@@ -2826,8 +2845,13 @@ driver_cassandra_update_stmt(struct sql_ + + i_assert(affected_rows == NULL); + +- if (!driver_cassandra_update_query_type(ctx, +- sql_statement_get_query(_stmt))) { ++ const char *query, *error; ++ if (sql_statement_get_query(_stmt, &query, &error) < 0) { ++ transaction_set_failed(ctx, error); ++ cassandra_sql_statement_free(stmt); ++ return; ++ } ++ if (!driver_cassandra_update_query_type(ctx, query)) { + cassandra_sql_statement_free(stmt); + return; + } +Index: trixie/src/lib-sql/driver-mysql.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-mysql.c ++++ trixie/src/lib-sql/driver-mysql.c +@@ -462,8 +462,9 @@ static int driver_mysql_do_query(struct + return -1; + } + +-static const char * +-driver_mysql_escape_string(struct sql_db *_db, const char *string) ++static int ++driver_mysql_escape_string(struct sql_db *_db, const char *string, ++ const char **output_r, const char **error_r) + { + struct mysql_db *db = container_of(_db, struct mysql_db, api); + size_t len = strlen(string); +@@ -475,22 +476,15 @@ driver_mysql_escape_string(struct sql_db + } + + if (_db->state == SQL_DB_STATE_DISCONNECTED) { +- /* FIXME: we don't have a valid connection, so fallback +- to using default escaping. the next query will most +- likely fail anyway so it shouldn't matter that much +- what we return here.. Anyway, this API needs +- changing so that the escaping function could already +- fail the query reliably. */ +- to = t_buffer_get(len * 2 + 1); +- len = mysql_escape_string(to, string, len); +- t_buffer_alloc(len + 1); +- return to; ++ *error_r = SQL_ERRSTR_NOT_CONNECTED; ++ return -1; + } + + to = t_buffer_get(len * 2 + 1); + len = mysql_real_escape_string(db->mysql, to, string, len); + t_buffer_alloc(len + 1); +- return to; ++ *output_r = to; ++ return 0; + } + + static void driver_mysql_exec(struct sql_db *_db, const char *query) +Index: trixie/src/lib-sql/driver-pgsql.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-pgsql.c ++++ trixie/src/lib-sql/driver-pgsql.c +@@ -718,8 +718,9 @@ static void do_query(struct pgsql_result + } + } + +-static const char * +-driver_pgsql_escape_string(struct sql_db *_db, const char *string) ++static int ++driver_pgsql_escape_string(struct sql_db *_db, const char *string, ++ const char **output_r, const char **error_r) + { + struct pgsql_db *db = (struct pgsql_db *)_db; + size_t len = strlen(string); +@@ -735,14 +736,24 @@ driver_pgsql_escape_string(struct sql_db + + to = t_buffer_get(len * 2 + 1); + len = PQescapeStringConn(db->pg, to, string, len, &error); +- } else +-#endif +- { +- to = t_buffer_get(len * 2 + 1); +- len = PQescapeString(to, string, len); ++ if (error != 0) { ++ *error_r = last_error(db); ++ return -1; ++ } ++ t_buffer_alloc(len + 1); ++ *output_r = to; ++ return 0; ++ } else { ++ *error_r = SQL_ERRSTR_NOT_CONNECTED; ++ return -1; + } ++#else ++ to = t_buffer_get(len * 2 + 1); ++ len = PQescapeString(to, string, len); + t_buffer_alloc(len + 1); +- return to; ++ *output_r = to; ++ return 0; ++#endif + } + + static void exec_callback(struct sql_result *_result, +Index: trixie/src/lib-sql/driver-sqlite.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-sqlite.c ++++ trixie/src/lib-sql/driver-sqlite.c +@@ -221,15 +221,17 @@ static void driver_sqlite_deinit_v(struc + i_free(db); + } + +-static const char * ++static int + driver_sqlite_escape_string(struct sql_db *_db ATTR_UNUSED, +- const char *string) ++ const char *string, const char **output_r, ++ const char **error_r ATTR_UNUSED) + { + const size_t len = strlen(string) * 2 + 1; + char *escaped = t_malloc_no0(len); + if (sqlite3_snprintf(len, escaped, "%q", string) == NULL) + i_unreached(); +- return escaped; ++ *output_r = escaped; ++ return 0; + } + + static const char * +Index: trixie/src/lib-sql/driver-sqlpool.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-sqlpool.c ++++ trixie/src/lib-sql/driver-sqlpool.c +@@ -573,8 +573,9 @@ static void driver_sqlpool_disconnect(st + driver_sqlpool_abort_requests(db); + } + +-static const char * +-driver_sqlpool_escape_string(struct sql_db *_db, const char *string) ++static int ++driver_sqlpool_escape_string(struct sql_db *_db, const char *string, ++ const char **output_r, const char **error_r) + { + struct sqlpool_db *db = (struct sqlpool_db *)_db; + const struct sqlpool_connection *conns; +@@ -584,11 +585,12 @@ driver_sqlpool_escape_string(struct sql_ + conns = array_get(&db->all_connections, &count); + for (i = 0; i < count; i++) { + if (SQL_DB_IS_READY(conns[i].db)) +- return sql_escape_string(conns[i].db, string); ++ return sql_escape_string(conns[i].db, string, ++ output_r, error_r); + } + /* no ready connections. just use the first one (we're guaranteed + to always have one) */ +- return sql_escape_string(conns[0].db, string); ++ return sql_escape_string(conns[0].db, string, output_r, error_r); + } + + static void driver_sqlpool_timeout(struct sqlpool_db *db) +Index: trixie/src/lib-sql/driver-test.c +=================================================================== +--- trixie.orig/src/lib-sql/driver-test.c ++++ trixie/src/lib-sql/driver-test.c +@@ -33,10 +33,12 @@ static int driver_test_sqlite_init(struc + static void driver_test_deinit(struct sql_db *_db); + static int driver_test_connect(struct sql_db *_db); + static void driver_test_disconnect(struct sql_db *_db); +-static const char * +-driver_test_mysql_escape_string(struct sql_db *_db, const char *string); +-static const char * +-driver_test_escape_string(struct sql_db *_db, const char *string); ++static int ++driver_test_mysql_escape_string(struct sql_db *_db, const char *string, ++ const char **output_r, const char **error_r); ++static int ++driver_test_escape_string(struct sql_db *_db, const char *string, ++ const char **output_r, const char **error_r); + static void driver_test_exec(struct sql_db *_db, const char *query); + static void driver_test_query(struct sql_db *_db, const char *query, + sql_query_callback_t *callback, void *context); +@@ -237,9 +239,10 @@ static int driver_test_connect(struct sq + static void driver_test_disconnect(struct sql_db *_db ATTR_UNUSED) + { } + +-static const char * ++static int + driver_test_mysql_escape_string(struct sql_db *_db ATTR_UNUSED, +- const char *string) ++ const char *string, const char **output_r, ++ const char **error_r ATTR_UNUSED) + { + string_t *esc = t_str_new(strlen(string)); + for(const char *ptr = string; *ptr != '\0'; ptr++) { +@@ -248,13 +251,17 @@ driver_test_mysql_escape_string(struct s + str_append_c(esc, '\\'); + str_append_c(esc, *ptr); + } +- return str_c(esc); ++ *output_r = str_c(esc); ++ return 0; + } + +-static const char * +-driver_test_escape_string(struct sql_db *_db ATTR_UNUSED, const char *string) ++static int ++driver_test_escape_string(struct sql_db *_db ATTR_UNUSED, const char *string, ++ const char **output_r, ++ const char **error_r ATTR_UNUSED) + { +- return string; ++ *output_r = string; ++ return 0; + } + + static void driver_test_exec(struct sql_db *_db, const char *query) +Index: trixie/src/lib-sql/sql-api-private.h +=================================================================== +--- trixie.orig/src/lib-sql/sql-api-private.h ++++ trixie/src/lib-sql/sql-api-private.h +@@ -81,7 +81,8 @@ struct sql_db_vfuncs { + + int (*connect)(struct sql_db *db); + void (*disconnect)(struct sql_db *db); +- const char *(*escape_string)(struct sql_db *db, const char *string); ++ int (*escape_string)(struct sql_db *db, const char *string, ++ const char **output_r, const char **error_r); + + void (*exec)(struct sql_db *db, const char *query); + /* Only implement this if the driver can really do asynchronous callbacks, +@@ -215,6 +216,7 @@ struct sql_statement { + pool_t pool; + const char *query_template; + ARRAY_TYPE(const_string) args; ++ ARRAY(bool) args_need_escaping; + + /* Tell the driver to not log this query with expanded values. + This works only for prepared statements. */ +@@ -252,6 +254,7 @@ struct sql_transaction_context { + /* commit() must use this query list if head is non-NULL. */ + struct sql_transaction_query *head, *tail; + ++ char *failed_error; + bool non_atomic; + }; + +@@ -277,7 +280,8 @@ inline static const char *sql_db_table_p + void sql_transaction_add_query(struct sql_transaction_context *ctx, pool_t pool, + const char *query, unsigned int *affected_rows); + const char *sql_statement_get_log_query(struct sql_statement *stmt); +-const char *sql_statement_get_query(struct sql_statement *stmt); ++int sql_statement_get_query(struct sql_statement *stmt, ++ const char **query_r, const char **error_r); + + void sql_connection_log_finished(struct sql_db *db); + struct event_passthrough * +Index: trixie/src/lib-sql/sql-api.c +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.c ++++ trixie/src/lib-sql/sql-api.c +@@ -98,7 +98,6 @@ static const struct sql_result_vfuncs sq + .get_error = sql_result_error_get_error, + }; + +-static struct sql_result *sql_result_new_error(const char *error) ATTR_UNUSED; + static struct sql_result *sql_result_new_error(const char *error) + { + struct sql_result_error *result = i_new(struct sql_result_error, 1); +@@ -327,9 +326,10 @@ void sql_disconnect(struct sql_db *db) + db->v.disconnect(db); + } + +-const char *sql_escape_string(struct sql_db *db, const char *string) ++int sql_escape_string(struct sql_db *db, const char *string, ++ const char **output_r, const char **error_r) + { +- return db->v.escape_string(db, string); ++ return db->v.escape_string(db, string, output_r, error_r); + } + + const char *sql_escape_blob(struct sql_db *db, +@@ -391,19 +391,25 @@ default_sql_statement_init_prepared(stru + + const char *sql_statement_get_log_query(struct sql_statement *stmt) + { ++ const char *query, *error; + if (stmt->no_log_expanded_values) + return stmt->query_template; +- return sql_statement_get_query(stmt); ++ if (sql_statement_get_query(stmt, &query, &error) < 0) ++ return stmt->query_template; ++ return query; + } + +-const char *sql_statement_get_query(struct sql_statement *stmt) ++int sql_statement_get_query(struct sql_statement *stmt, ++ const char **query_r, const char **error_r) + { +- string_t *query = t_str_new(128); ++ string_t *query = str_new(default_pool, 128); + const char *const *args; +- unsigned int args_count, arg_pos = 0; ++ const bool *need_escaping_flags; ++ unsigned int args_count, need_escaping_count, arg_pos = 0; + const char *p0, *p1; + + args = array_get(&stmt->args, &args_count); ++ need_escaping_flags = array_get(&stmt->args_need_escaping, &need_escaping_count); + p0 = stmt->query_template; + while ((p1 = strchr(p0, '?')) != NULL) { + /* append until ? */ +@@ -413,7 +419,31 @@ const char *sql_statement_get_query(stru + i_panic("lib-sql: Missing bind for arg #%u in statement: %s", + arg_pos, stmt->query_template); + } +- str_append(query, args[arg_pos++]); ++ if (arg_pos < need_escaping_count && need_escaping_flags[arg_pos]) { ++ const char *escaped; ++ ++ /* Escape in a nested data stack frame so the ++ driver's temporary escape buffer is freed ++ immediately. The escaped value is appended to the ++ heap-allocated query before the frame is popped. */ ++ T_BEGIN { ++ if (sql_escape_string(stmt->db, args[arg_pos], ++ &escaped, error_r) < 0) ++ escaped = NULL; ++ else { ++ str_append_c(query, '\''); ++ str_append(query, escaped); ++ str_append_c(query, '\''); ++ } ++ } T_END_PASS_STR_IF(escaped == NULL, error_r); ++ if (escaped == NULL) { ++ str_free(&query); ++ return -1; ++ } ++ } else { ++ str_append(query, args[arg_pos]); ++ } ++ arg_pos++; + p0 = p1 + 1; + } + str_append(query, p0); +@@ -422,23 +452,37 @@ const char *sql_statement_get_query(stru + i_panic("lib-sql: Too many bind args (%u) for statement: %s", + args_count, stmt->query_template); + } +- return str_c(query); ++ *query_r = t_strdup(str_c(query)); ++ str_free(&query); ++ return 0; + } + + static void + default_sql_statement_query(struct sql_statement *stmt, + sql_query_callback_t *callback, void *context) + { +- sql_query(stmt->db, sql_statement_get_query(stmt), +- callback, context); ++ const char *query, *error; ++ if (sql_statement_get_query(stmt, &query, &error) < 0) { ++ sql_query_callback_delayed(stmt->db, ++ sql_result_new_error(error), ++ callback, context); ++ pool_unref(&stmt->pool); ++ return; ++ } ++ sql_query(stmt->db, query, callback, context); + pool_unref(&stmt->pool); + } + + static struct sql_result * + default_sql_statement_query_s(struct sql_statement *stmt) + { +- struct sql_result *result = +- sql_query_s(stmt->db, sql_statement_get_query(stmt)); ++ const char *query, *error; ++ if (sql_statement_get_query(stmt, &query, &error) < 0) { ++ struct sql_result *result = sql_result_new_error(error); ++ pool_unref(&stmt->pool); ++ return result; ++ } ++ struct sql_result *result = sql_query_s(stmt->db, query); + pool_unref(&stmt->pool); + return result; + } +@@ -447,8 +491,14 @@ static void default_sql_update_stmt(stru + struct sql_statement *stmt, + unsigned int *affected_rows) + { +- ctx->db->v.update(ctx, sql_statement_get_query(stmt), +- affected_rows); ++ const char *query, *error; ++ if (sql_statement_get_query(stmt, &query, &error) < 0) { ++ if (ctx->failed_error == NULL) ++ ctx->failed_error = i_strdup(error); ++ pool_unref(&stmt->pool); ++ return; ++ } ++ ctx->db->v.update(ctx, query, affected_rows); + pool_unref(&stmt->pool); + } + +@@ -487,6 +537,7 @@ sql_statement_init_fields(struct sql_sta + { + stmt->db = db; + p_array_init(&stmt->args, stmt->pool, 8); ++ p_array_init(&stmt->args_need_escaping, stmt->pool, 8); + } + + struct sql_statement * +@@ -545,10 +596,10 @@ void sql_statement_set_no_log_expanded_v + void sql_statement_bind_str(struct sql_statement *stmt, + unsigned int column_idx, const char *value) + { +- const char *escaped_value = +- p_strdup_printf(stmt->pool, "'%s'", +- sql_escape_string(stmt->db, value)); +- array_idx_set(&stmt->args, column_idx, &escaped_value); ++ const char *value_dup = p_strdup(stmt->pool, value); ++ array_idx_set(&stmt->args, column_idx, &value_dup); ++ bool needs_escaping = TRUE; ++ array_idx_set(&stmt->args_need_escaping, column_idx, &needs_escaping); + + if (stmt->db->v.statement_bind_str != NULL) + stmt->db->v.statement_bind_str(stmt, column_idx, value); +@@ -908,6 +959,13 @@ void sql_transaction_commit(struct sql_t + struct sql_db *db = ctx->db; + *_ctx = NULL; + ++ if (ctx->failed_error != NULL) { ++ sql_commit_schedule_delayed(db, ctx->failed_error, callback, context); ++ i_free(ctx->failed_error); ++ ctx->db->v.transaction_rollback(ctx); ++ return; ++ } ++ + if (ctx->db->v.transaction_commit != NULL) { + ctx->db->v.transaction_commit(ctx, callback, context); + return; +@@ -924,6 +982,12 @@ int sql_transaction_commit_s(struct sql_ + struct sql_transaction_context *ctx = *_ctx; + + *_ctx = NULL; ++ if (ctx->failed_error != NULL) { ++ *error_r = t_strdup(ctx->failed_error); ++ i_free(ctx->failed_error); ++ ctx->db->v.transaction_rollback(ctx); ++ return -1; ++ } + return ctx->db->v.transaction_commit_s(ctx, error_r); + } + +@@ -932,6 +996,7 @@ void sql_transaction_rollback(struct sql + struct sql_transaction_context *ctx = *_ctx; + + *_ctx = NULL; ++ i_free(ctx->failed_error); + ctx->db->v.transaction_rollback(ctx); + } + +Index: trixie/src/lib-sql/sql-api.h +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.h ++++ trixie/src/lib-sql/sql-api.h +@@ -114,7 +114,8 @@ int sql_connect(struct sql_db *db); + void sql_disconnect(struct sql_db *db); + + /* Escape the given string if needed and return it. */ +-const char *sql_escape_string(struct sql_db *db, const char *string); ++int sql_escape_string(struct sql_db *db, const char *string, ++ const char **output_r, const char **error_r); + /* Escape the given data as a string. */ + const char *sql_escape_blob(struct sql_db *db, + const unsigned char *data, size_t size); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0005-submission-login-submission-proxy-Recognize-421-4.7..patch dovecot-2.4.1+dfsg1/debian/patches/0005-submission-login-submission-proxy-Recognize-421-4.7..patch --- dovecot-2.4.1+dfsg1/debian/patches/0005-submission-login-submission-proxy-Recognize-421-4.7..patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0005-submission-login-submission-proxy-Recognize-421-4.7..patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,50 @@ +From 8157e395df6b19891cea807fbb1f59e9f8b5a30a Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 7 May 2026 10:58:24 +0000 +Subject: [PATCH 5/5] submission-login: submission-proxy - Recognize 421 4.7.0 + as connection-limit reply + +When the backend signals that the user's connection limit has been +reached, do not classify the response as a generic temporary +authentication failure (which causes the proxy to reconnect). Map it +instead to LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED, which +suppresses retries and reports the failure as proxy_dest_connection_limit +in the login_aborted event/log line. This mirrors what imap-login does +for the [LIMIT] response code and pop3-login for [IN-USE]. + +Other 421 replies (typically server shutdown / fatal error) are mapped +to LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED rather than AUTH_TEMPFAIL: 421 +means "closing transmission channel" (RFC 5321 Section 4.2.1), so a +reconnect on the same destination is unlikely to help and should not +happen automatically. +--- + src/submission-login/submission-proxy.c | 16 +++++++++++++++- + 1 file changed, 15 insertions(+), 1 deletion(-) + +Index: dovecot/src/submission-login/submission-proxy.c +=================================================================== +--- dovecot.orig/src/submission-login/submission-proxy.c ++++ dovecot/src/submission-login/submission-proxy.c +@@ -694,7 +694,21 @@ int submission_proxy_parse_line(struct c + be using only Dovecot as their backend :) */ + enum login_proxy_failure_type failure_type = + LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED; +- if ((status / 100) == 4) ++ if (status == 421) { ++ /* The backend won't accept further AUTH attempts on this ++ connection. If it signaled 4.7.0, the user's connection ++ limit (mail_max_userip_connections) was reached; report ++ that specifically. Otherwise treat as a generic auth ++ reply (e.g. backend shutting down). The reply was ++ prepared from the backend's 421 line; forward it to the ++ client and detach the AUTH command. */ ++ failure_type = (null_strcmp(enh_code, "4.7.0") == 0) ? ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_LIMIT_REACHED_REPLIED : ++ LOGIN_PROXY_FAILURE_TYPE_AUTH_REPLIED; ++ i_assert(subm_client->proxy_reply != NULL); ++ smtp_server_reply_submit(subm_client->proxy_reply); ++ subm_client->pending_auth = NULL; ++ } else if ((status / 100) == 4) + failure_type = LOGIN_PROXY_FAILURE_TYPE_AUTH_TEMPFAIL; + else if (!submission_proxy_handle_redirect( + client, status, enh_code, text, &failure_type, &text)) { diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0006-auth-passdb_sql-connect-before-expanding-query-varia.patch dovecot-2.4.1+dfsg1/debian/patches/0006-auth-passdb_sql-connect-before-expanding-query-varia.patch --- dovecot-2.4.1+dfsg1/debian/patches/0006-auth-passdb_sql-connect-before-expanding-query-varia.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0006-auth-passdb_sql-connect-before-expanding-query-varia.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,35 @@ +From abb51a4040fa21314c54a675cf35c4103e92da9c Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Fri, 29 May 2026 09:28:40 +0300 +Subject: [PATCH 6/6] auth: passdb_sql: connect before expanding query + variables + +sql_lookup_pass() calls settings_get_params() with an SQL escape func that +requires a live connection. If the DB is disconnected, the error propagated +as "Failed to parse configuration" instead of a DB connectivity error. + +Call sql_connect() first; fail with "Not connected to database" directly +if it returns -1. +--- + src/auth/passdb-sql.c | 8 ++++++++ + 1 file changed, 8 insertions(+) + +Index: trixie/src/auth/passdb-sql.c +=================================================================== +--- trixie.orig/src/auth/passdb-sql.c ++++ trixie/src/auth/passdb-sql.c +@@ -183,6 +183,14 @@ static void sql_lookup_pass(struct passd + const struct passdb_sql_settings *set; + const char *error; + ++ if (sql_connect(module->db) < 0) { ++ e_error(authdb_event(sql_request->auth_request), ++ "Not connected to database"); ++ sql_request->callback.verify_plain( ++ PASSDB_RESULT_INTERNAL_FAILURE, ++ sql_request->auth_request); ++ return; ++ } + const struct settings_get_params params = { + .escape_func = passdb_sql_escape, + .escape_context = module->db, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0006-imap-Reset-DEFLATE-dictionary-after-every-tagged-rep.patch dovecot-2.4.1+dfsg1/debian/patches/0006-imap-Reset-DEFLATE-dictionary-after-every-tagged-rep.patch --- dovecot-2.4.1+dfsg1/debian/patches/0006-imap-Reset-DEFLATE-dictionary-after-every-tagged-rep.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0006-imap-Reset-DEFLATE-dictionary-after-every-tagged-rep.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,61 @@ +From 5ea530ea3677f5fe9d0a7040af87623f3f7af7ce Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 17:38:53 +0200 +Subject: [PATCH 6/6] imap: Reset DEFLATE dictionary after every tagged reply + +Prevents CRIME-style cross-command compression oracle attacks +(CVE-class: compression side-channel). Without this fix an observer +who can inject chosen plaintext into one IMAP command's response can +measure the compressed size of a subsequent command's response and +determine whether the secret content matches the injected plaintext. + +After each tagged response line is sent, the DEFLATE compression +dictionary is reset via Z_FULL_FLUSH so that the compression history +from one command cannot influence the compressed size of the next. + +For direct compression (imap_compress_on_proxy=no) the reset is applied +to the local ostream. For proxy-mode compression +(imap_compress_on_proxy=yes) a "dict_reset" command is sent over the +multiplex side channel to the imap-login process. +--- + src/imap/imap-client.c | 17 +++++++++++++++++ + 1 file changed, 17 insertions(+) + +diff --git a/src/imap/imap-client.c b/src/imap/imap-client.c +index 4c8115f9a0..c44a4d810a 100644 +--- a/src/imap/imap-client.c ++++ b/src/imap/imap-client.c +@@ -12,6 +12,7 @@ + #include "istream-concat.h" + #include "ostream.h" + #include "ostream-multiplex.h" ++#include "ostream-zlib.h" + #include "time-util.h" + #include "settings.h" + #include "master-service.h" +@@ -712,6 +713,22 @@ client_default_send_tagline(struct client_command_context *cmd, const char *data + } T_END; + + client->last_output = ioloop_time; ++ ++ /* Reset the DEFLATE compression dictionary after every tagged reply so ++ that cross-command compression correlation attacks (CRIME-style) are ++ not possible. For proxy-mode compression the reset is forwarded to ++ the imap-login process via the side channel; for direct compression ++ it is applied to the local ostream immediately. */ ++ if (client->compress_handler != NULL) { ++ if (client->multiplex_output != NULL && ++ client->set->imap_compress_on_proxy) { ++ i_assert(client->side_channel_output != NULL); ++ o_stream_nsend_str(client->side_channel_output, ++ "dict_reset\n"); ++ } else { ++ o_stream_deflate_reset_dict(client->output); ++ } ++ } + } + + static int +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0006-lib-index-Rename-crc32-variables-parameters-to-hash-.patch dovecot-2.4.1+dfsg1/debian/patches/0006-lib-index-Rename-crc32-variables-parameters-to-hash-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0006-lib-index-Rename-crc32-variables-parameters-to-hash-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0006-lib-index-Rename-crc32-variables-parameters-to-hash-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,366 @@ +From ed3c3d25b3ac571925f19b9b4d0dd8e8a718227e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 13 Apr 2026 12:52:15 +0200 +Subject: [PATCH 06/12] lib-index: Rename crc32 variables/parameters to hash in + strmap + +Pure rename - no functional change. The stored 32-bit value is still +produced by crc32_str_nonzero(); only the identifiers change to prepare +for swapping the hash algorithm in the next commit. + +recs_crc32 -> recs_hash, hash_key.crc32 -> hash_key.hash, +crc32_r -> hash_r, local crc32 -> hash, *crc32 -> *hashes. +--- + src/lib-index/mail-index-strmap.c | 84 +++++++++++++++---------------- + 1 file changed, 42 insertions(+), 42 deletions(-) + +diff --git a/src/lib-index/mail-index-strmap.c b/src/lib-index/mail-index-strmap.c +index 9ce2de9103..254080dbb7 100644 +--- a/src/lib-index/mail-index-strmap.c ++++ b/src/lib-index/mail-index-strmap.c +@@ -31,7 +31,7 @@ struct mail_index_strmap_view { + struct mail_index_view *view; + + ARRAY_TYPE(mail_index_strmap_rec) recs; +- ARRAY(uint32_t) recs_crc32; ++ ARRAY(uint32_t) recs_hash; + struct hash2_table *hash; + + mail_index_strmap_key_cmp_t *key_compare; +@@ -74,7 +74,7 @@ struct mail_index_strmap_view_sync { + + struct mail_index_strmap_hash_key { + const char *str; +- uint32_t crc32; ++ uint32_t hash; + }; + + /* renumber the string indexes when highest string idx becomes larger than +@@ -113,7 +113,7 @@ mail_index_strmap_init(struct mail_index *index, const char *suffix) + + static bool + mail_index_strmap_read_rec_next(struct mail_index_strmap_read_context *ctx, +- uint32_t *crc32_r); ++ uint32_t *hash_r); + + static void + mail_index_strmap_set_syscall_error(struct mail_index_strmap *strmap, +@@ -162,7 +162,7 @@ static unsigned int mail_index_strmap_hash_key(const void *_key) + { + const struct mail_index_strmap_hash_key *key = _key; + +- return key->crc32; ++ return key->hash; + } + + static bool +@@ -197,7 +197,7 @@ mail_index_strmap_view_open(struct mail_index_strmap *strmap, + view->next_str_idx = 1; + + i_array_init(&view->recs, 64); +- i_array_init(&view->recs_crc32, 64); ++ i_array_init(&view->recs_hash, 64); + view->hash = hash2_create(0, sizeof(struct mail_index_strmap_rec), + mail_index_strmap_hash_key, + mail_index_strmap_hash_cmp, view); +@@ -212,7 +212,7 @@ void mail_index_strmap_view_close(struct mail_index_strmap_view **_view) + + *_view = NULL; + array_free(&view->recs); +- array_free(&view->recs_crc32); ++ array_free(&view->recs_hash); + hash2_destroy(&view->hash); + i_free(view); + } +@@ -226,7 +226,7 @@ static void mail_index_strmap_view_reset(struct mail_index_strmap_view *view) + { + view->remap_cb(NULL, 0, 0, view->cb_context); + array_clear(&view->recs); +- array_clear(&view->recs_crc32); ++ array_clear(&view->recs_hash); + hash2_clear(view->hash); + + view->last_added_uid = 0; +@@ -410,13 +410,13 @@ mail_index_strmap_uid_exists(struct mail_index_strmap_read_context *ctx, + + static int + mail_index_strmap_read_rec_first(struct mail_index_strmap_read_context *ctx, +- uint32_t *crc32_r) ++ uint32_t *hash_r) + { + size_t size; + uint32_t n, i, count, str_idx; + int ret; + +- /* *count *count ++ /* *count *count + where + n = 0 -> count=1 (only Message-ID:) + n = 1 -> count=2 (Message-ID: + In-Reply-To:) +@@ -426,11 +426,11 @@ mail_index_strmap_read_rec_first(struct mail_index_strmap_read_context *ctx, + return -1; + count = n < 2 ? n + 1 : n; + /* check that rec_size fits */ +- if (UINT_MAX / (sizeof(ctx->rec.str_idx) + sizeof(*crc32_r)) < count) ++ if (UINT_MAX / (sizeof(ctx->rec.str_idx) + sizeof(*hash_r)) < count) + return -1; + ctx->view->total_ref_count += count; + +- ctx->rec_size = count * (sizeof(ctx->rec.str_idx) + sizeof(*crc32_r)); ++ ctx->rec_size = count * (sizeof(ctx->rec.str_idx) + sizeof(*hash_r)); + ret = mail_index_strmap_uid_exists(ctx, ctx->rec.uid); + if (ret < 0) + return -1; +@@ -454,10 +454,10 @@ mail_index_strmap_read_rec_first(struct mail_index_strmap_read_context *ctx, + /* everything exists. save it. FIXME: these ref_index values + are thread index specific, perhaps something more generic + should be used some day */ +- ctx->end = ctx->data + count * sizeof(*crc32_r); ++ ctx->end = ctx->data + count * sizeof(*hash_r); + + ctx->next_ref_index = 0; +- if (!mail_index_strmap_read_rec_next(ctx, crc32_r)) ++ if (!mail_index_strmap_read_rec_next(ctx, hash_r)) + i_unreached(); + ctx->next_ref_index = n == 1 ? 1 : 2; + return 1; +@@ -465,7 +465,7 @@ mail_index_strmap_read_rec_first(struct mail_index_strmap_read_context *ctx, + + static bool + mail_index_strmap_read_rec_next(struct mail_index_strmap_read_context *ctx, +- uint32_t *crc32_r) ++ uint32_t *hash_r) + { + if (ctx->data == ctx->end) { + i_stream_skip(ctx->view->strmap->input, ctx->rec_size); +@@ -478,7 +478,7 @@ mail_index_strmap_read_rec_next(struct mail_index_strmap_read_context *ctx, + + /* read the record contents */ + memcpy(&ctx->rec.str_idx, ctx->str_idx_base, sizeof(ctx->rec.str_idx)); +- memcpy(crc32_r, ctx->data, sizeof(*crc32_r)); ++ memcpy(hash_r, ctx->data, sizeof(*hash_r)); + + ctx->rec.ref_index = ctx->next_ref_index++; + +@@ -486,7 +486,7 @@ mail_index_strmap_read_rec_next(struct mail_index_strmap_read_context *ctx, + ctx->highest_str_idx = ctx->rec.str_idx; + + /* get to the next record */ +- ctx->data += sizeof(*crc32_r); ++ ctx->data += sizeof(*hash_r); + ctx->str_idx_base += sizeof(ctx->rec.str_idx); + return TRUE; + } +@@ -547,12 +547,12 @@ strmap_read_block_init(struct mail_index_strmap_view *view, + + static int + strmap_read_block_next(struct mail_index_strmap_read_context *ctx, +- uint32_t *crc32_r) ++ uint32_t *hash_r) + { + uint32_t uid_diff; + int ret; + +- if (mail_index_strmap_read_rec_next(ctx, crc32_r)) ++ if (mail_index_strmap_read_rec_next(ctx, hash_r)) + return 1; + + /* get next UID */ +@@ -565,7 +565,7 @@ strmap_read_block_next(struct mail_index_strmap_read_context *ctx, + return -1; + + ctx->rec.uid += uid_diff; +- ret = mail_index_strmap_read_rec_first(ctx, crc32_r); ++ ret = mail_index_strmap_read_rec_first(ctx, hash_r); + } while (ret == 0); + return ret; + } +@@ -635,12 +635,12 @@ strmap_view_sync_handle_conflict(struct mail_index_strmap_read_context *ctx, + + static int + strmap_view_sync_block_check_conflicts(struct mail_index_strmap_read_context *ctx, +- uint32_t crc32) ++ uint32_t hash) + { + struct mail_index_strmap_rec *hash_rec; + struct hash2_iter iter; + +- if (crc32 == 0) { ++ if (hash == 0) { + /* unique string - there are no conflicts */ + return 0; + } +@@ -657,9 +657,9 @@ strmap_view_sync_block_check_conflicts(struct mail_index_strmap_read_context *ct + strmap index until X has been expunged. */ + i_zero(&iter); + while ((hash_rec = hash2_iterate(ctx->view->hash, +- crc32, &iter)) != NULL && ++ hash, &iter)) != NULL && + hash_rec->str_idx != ctx->rec.str_idx) { +- /* CRC32 matches, but string index doesn't */ ++ /* hash matches, but string index doesn't */ + if (!strmap_view_sync_handle_conflict(ctx, hash_rec, &iter)) { + ctx->lost_expunged_uid = hash_rec->uid; + return -1; +@@ -672,10 +672,10 @@ static int + mail_index_strmap_view_sync_block(struct mail_index_strmap_read_context *ctx) + { + struct mail_index_strmap_rec *hash_rec; +- uint32_t crc32, prev_uid = 0; ++ uint32_t hash, prev_uid = 0; + int ret; + +- while ((ret = strmap_read_block_next(ctx, &crc32)) > 0) { ++ while ((ret = strmap_read_block_next(ctx, &hash)) > 0) { + if (ctx->rec.uid <= ctx->view->last_added_uid) { + if (ctx->rec.uid < ctx->view->last_added_uid || + prev_uid != ctx->rec.uid) { +@@ -685,7 +685,7 @@ mail_index_strmap_view_sync_block(struct mail_index_strmap_read_context *ctx) + } + prev_uid = ctx->rec.uid; + +- if (strmap_view_sync_block_check_conflicts(ctx, crc32) < 0) { ++ if (strmap_view_sync_block_check_conflicts(ctx, hash) < 0) { + ret = -1; + break; + } +@@ -693,10 +693,10 @@ mail_index_strmap_view_sync_block(struct mail_index_strmap_read_context *ctx) + + /* add the record to records array */ + array_push_back(&ctx->view->recs, &ctx->rec); +- array_push_back(&ctx->view->recs_crc32, &crc32); ++ array_push_back(&ctx->view->recs_hash, &hash); + + /* add a separate copy of the record to hash */ +- hash_rec = hash2_insert_hash(ctx->view->hash, crc32); ++ hash_rec = hash2_insert_hash(ctx->view->hash, hash); + memcpy(hash_rec, &ctx->rec, sizeof(*hash_rec)); + } + return strmap_read_block_deinit(ctx, ret, TRUE); +@@ -764,7 +764,7 @@ void mail_index_strmap_view_sync_add(struct mail_index_strmap_view_sync *sync, + ref_index > view->last_ref_index)); + + hash_key.str = key; +- hash_key.crc32 = crc32_str_nonzero(key); ++ hash_key.hash = crc32_str_nonzero(key); + + old_rec = hash2_lookup(view->hash, &hash_key); + if (old_rec != NULL) { +@@ -781,7 +781,7 @@ void mail_index_strmap_view_sync_add(struct mail_index_strmap_view_sync *sync, + rec->ref_index = ref_index; + rec->str_idx = str_idx; + array_push_back(&view->recs, rec); +- array_push_back(&view->recs_crc32, &hash_key.crc32); ++ array_push_back(&view->recs_hash, &hash_key.hash); + + view->last_added_uid = uid; + view->last_ref_index = ref_index; +@@ -802,7 +802,7 @@ void mail_index_strmap_view_sync_add_unique(struct mail_index_strmap_view_sync * + rec.ref_index = ref_index; + rec.str_idx = view->next_str_idx++; + array_push_back(&view->recs, &rec); +- array_append_zero(&view->recs_crc32); ++ array_append_zero(&view->recs_hash); + + view->last_added_uid = uid; + view->last_ref_index = ref_index; +@@ -820,7 +820,7 @@ static void mail_index_strmap_view_renumber(struct mail_index_strmap_view *view) + { + struct mail_index_strmap_read_context ctx; + struct mail_index_strmap_rec *recs, *hash_rec; +- uint32_t prev_uid, str_idx, *recs_crc32, *renumber_map; ++ uint32_t prev_uid, str_idx, *recs_hash, *renumber_map; + unsigned int i, dest, count, count2; + int ret; + +@@ -833,7 +833,7 @@ static void mail_index_strmap_view_renumber(struct mail_index_strmap_view *view) + renumber_map = i_new(uint32_t, view->next_str_idx); + str_idx = 0; prev_uid = 0; + recs = array_get_modifiable(&view->recs, &count); +- recs_crc32 = array_get_modifiable(&view->recs_crc32, &count2); ++ recs_hash = array_get_modifiable(&view->recs_hash, &count2); + i_assert(count == count2); + + for (i = dest = 0; i < count; ) { +@@ -856,13 +856,13 @@ static void mail_index_strmap_view_renumber(struct mail_index_strmap_view *view) + renumber_map[recs[i].str_idx] = ++str_idx; + if (i != dest) { + recs[dest] = recs[i]; +- recs_crc32[dest] = recs_crc32[i]; ++ recs_hash[dest] = recs_hash[i]; + } + i++; dest++; + } + i_assert(renumber_map[0] == 0); + array_delete(&view->recs, dest, i-dest); +- array_delete(&view->recs_crc32, dest, i-dest); ++ array_delete(&view->recs_hash, dest, i-dest); + mail_index_strmap_zero_terminate(view); + + /* notify caller of the renumbering */ +@@ -875,7 +875,7 @@ static void mail_index_strmap_view_renumber(struct mail_index_strmap_view *view) + hash2_clear(view->hash); + for (i = 0; i < count; i++) { + recs[i].str_idx = renumber_map[recs[i].str_idx]; +- hash_rec = hash2_insert_hash(view->hash, recs_crc32[i]); ++ hash_rec = hash2_insert_hash(view->hash, recs_hash[i]); + memcpy(hash_rec, &recs[i], sizeof(*hash_rec)); + } + +@@ -889,7 +889,7 @@ static void mail_index_strmap_write_block(struct mail_index_strmap_view *view, + unsigned int i, uint32_t base_uid) + { + const struct mail_index_strmap_rec *recs; +- const uint32_t *crc32; ++ const uint32_t *hashes; + unsigned int j, n, count, count2, uid_rec_count; + uint32_t block_size; + uint8_t *p, packed[MAIL_INDEX_PACK_MAX_SIZE*2]; +@@ -902,7 +902,7 @@ static void mail_index_strmap_write_block(struct mail_index_strmap_view *view, + + /* write records */ + recs = array_get(&view->recs, &count); +- crc32 = array_get(&view->recs_crc32, &count2); ++ hashes = array_get(&view->recs_hash, &count2); + i_assert(count == count2); + while (i < count) { + /* @UNSAFE: */ +@@ -919,7 +919,7 @@ static void mail_index_strmap_write_block(struct mail_index_strmap_view *view, + } + view->total_ref_count += uid_rec_count; + +- /* *count *count - ++ /* *count *count - + FIXME: thread index specific code */ + i_assert(recs[i].ref_index == 0); + if (uid_rec_count == 1) { +@@ -938,7 +938,7 @@ static void mail_index_strmap_write_block(struct mail_index_strmap_view *view, + mail_index_pack_num(&p, n); + o_stream_nsend(output, packed, p-packed); + for (j = 0; j < uid_rec_count; j++) +- o_stream_nsend(output, &crc32[i+j], sizeof(crc32[i+j])); ++ o_stream_nsend(output, &hashes[i+j], sizeof(hashes[i+j])); + for (j = 0; j < uid_rec_count; j++) { + i_assert(j < 2 || recs[i+j].ref_index == j+1); + o_stream_nsend(output, &recs[i+j].str_idx, +@@ -1095,7 +1095,7 @@ mail_index_strmap_write_append(struct mail_index_strmap_view *view) + const struct mail_index_strmap_rec *old_recs; + unsigned int i, old_count; + struct ostream *output; +- uint32_t crc32, next_uid; ++ uint32_t hash, next_uid; + bool full_block; + int ret; + +@@ -1134,7 +1134,7 @@ mail_index_strmap_write_append(struct mail_index_strmap_view *view) + full_block = TRUE; ret = 0; + while (i < old_count && + (ret = strmap_read_block_init(view, &ctx)) > 0) { +- while ((ret = strmap_read_block_next(&ctx, &crc32)) > 0) { ++ while ((ret = strmap_read_block_next(&ctx, &hash)) > 0) { + if (ctx.rec.uid != old_recs[i].uid || + ctx.rec.str_idx != old_recs[i].str_idx) { + /* mismatch */ +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0006-lib-sieve-Log-a-warning-when-cumulative-CPU-exceeds-.patch dovecot-2.4.1+dfsg1/debian/patches/0006-lib-sieve-Log-a-warning-when-cumulative-CPU-exceeds-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0006-lib-sieve-Log-a-warning-when-cumulative-CPU-exceeds-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0006-lib-sieve-Log-a-warning-when-cumulative-CPU-exceeds-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,55 @@ +From 025233779751d7ba8cff7c615f8e229b604d92a5 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 12:25:38 +0000 +Subject: [PATCH 6/7] lib-sieve: Log a warning when cumulative CPU exceeds the + limit + +sieve_binary_record_resource_usage() returned FALSE when the script's +cumulative CPU time crossed sieve_max_cpu_time, but the only log line +on that path was a debug-level "Updated cumulative resource usage" +emitted on every recording, with no signal at the actual disable +transition. Operators had to infer the disable from later "Failed to +open script ... (cumulative resource limit exceeded)" errors on +subsequent deliveries. + +Emit a warning at the transition point, including the cumulative CPU +usage that triggered it. +--- + src/lib-sieve/sieve-binary.c | 17 ++++++++++++++++- + 1 file changed, 16 insertions(+), 1 deletion(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary.c +@@ -248,6 +248,7 @@ bool sieve_binary_record_resource_usage( + struct sieve_binary *sbin, const struct sieve_resource_usage *rusage) + { + struct sieve_resource_usage rusage_total; ++ struct sieve_script *script; + + if (sbin == NULL) + return TRUE; +@@ -262,7 +263,21 @@ bool sieve_binary_record_resource_usage( + e_debug(sbin->event, "Updated cumulative resource usage: %s", + sieve_resource_usage_get_summary(&rusage_total)); + +- return sieve_binary_check_resource_usage(sbin); ++ if (!sieve_resource_usage_is_excessive(sbin->svinst, &rusage_total)) ++ return TRUE; ++ ++ /* Cumulative resource usage is only tracked for personal scripts; ++ all callers gate this on the user's own script. */ ++ script = sbin->script; ++ i_assert(script != NULL); ++ i_assert(sieve_storage_is_personal(script->storage)); ++ ++ e_warning(sbin->event, ++ "Personal sieve processing disabled for script '%s': " ++ "cumulative resource usage limit exceeded (%s)", ++ sieve_script_label(script), ++ sieve_resource_usage_get_summary(&rusage_total)); ++ return FALSE; + } + + void sieve_binary_set_resource_usage(struct sieve_binary *sbin, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0006-lib-storage-mbox-Explicitly-disable-the-header-block.patch dovecot-2.4.1+dfsg1/debian/patches/0006-lib-storage-mbox-Explicitly-disable-the-header-block.patch --- dovecot-2.4.1+dfsg1/debian/patches/0006-lib-storage-mbox-Explicitly-disable-the-header-block.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0006-lib-storage-mbox-Explicitly-disable-the-header-block.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,50 @@ +From 89ed3baf87b789f55d7513252ef2dead41cf4105 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sun, 19 Apr 2026 15:57:08 +0300 +Subject: [PATCH 06/14] lib-storage/mbox: Explicitly disable the header block + size limit + +mbox_sync_parse_next_mail() appends each header's raw bytes into +ctx->header and writes them back when rewriting the mbox file. The +default MESSAGE_HEADER_BLOCK_DEFAULT_MAX_SIZE limit (10 MB) must not +apply here: any truncation would corrupt the mbox on rewrite. +Similarly, mbox_sync_parse_match_mail() feeds full raw header bytes +into the MD5 verifier. + +Today the per-chunk hdr->value delivered by message_parse_header_next() +is not clamped cumulatively, so the limit only bites on full_value and +unknown headers pass through intact. That is about to change - a +subsequent commit will clamp hdr->value cumulatively. Explicitly +setting SIZE_MAX here locks in the intent and prevents a regression. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + src/lib-storage/index/mbox/mbox-sync-parse.c | 5 +++++ + 1 file changed, 5 insertions(+) + +diff --git a/src/lib-storage/index/mbox/mbox-sync-parse.c b/src/lib-storage/index/mbox/mbox-sync-parse.c +index 90f032a017..660e9b63d0 100644 +--- a/src/lib-storage/index/mbox/mbox-sync-parse.c ++++ b/src/lib-storage/index/mbox/mbox-sync-parse.c +@@ -479,6 +479,9 @@ int mbox_sync_parse_next_mail(struct istream *input, + + line_start_pos = 0; + hdr_ctx = message_parse_header_init(input, NULL, 0); ++ /* Rewriting the mbox requires the full raw header bytes, so disable ++ the default per-header block size limit. */ ++ message_parse_header_set_limit(hdr_ctx, SIZE_MAX); + while ((ret = message_parse_header_next(hdr_ctx, &hdr)) > 0) { + if (hdr->eoh) { + ctx->have_eoh = TRUE; +@@ -576,6 +579,8 @@ bool mbox_sync_parse_match_mail(struct mbox_mailbox *mbox, + mbox_md5_ctx = mbox->md5_v.init(); + + hdr_ctx = message_parse_header_init(mbox->mbox_stream, NULL, 0); ++ /* MD5 matching must see the full raw header bytes. */ ++ message_parse_header_set_limit(hdr_ctx, SIZE_MAX); + while ((ret = message_parse_header_next(hdr_ctx, &hdr)) > 0) { + if (hdr->eoh) + break; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0007-lib-index-Add-keyed-xxh64-strmap-format-v2-gated-by-.patch dovecot-2.4.1+dfsg1/debian/patches/0007-lib-index-Add-keyed-xxh64-strmap-format-v2-gated-by-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0007-lib-index-Add-keyed-xxh64-strmap-format-v2-gated-by-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0007-lib-index-Add-keyed-xxh64-strmap-format-v2-gated-by-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,364 @@ +From ef45dd7c36562ef07cccf6cc4ad245e0041af456 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 30 Apr 2026 12:11:57 +0000 +Subject: [PATCH 07/12] lib-index: Add keyed xxh64 strmap format v2, gated by + config version + +The on-disk hash stored in the strmap file is now optionally a keyed +xxh64_to_32() with a per-file random 64-bit IV stored in the file +header. This replaces the previous plain crc32_str_nonzero() output +and hardens the strmap against deliberately-collided message-id +hashes. The v1 (crc32) format remains fully readable and writable +so older configurations keep working without forced rebuilds. + +On-disk: + - v1 header is 8 bytes: version, 3 unused, uid_validity (unchanged). + - v2 header is 16 bytes: version, compat_flags, 2 unused, uid_validity, + hash_iv. The first 8 bytes of v2 align with v1 byte-for-byte, so + the open path reads 8 bytes, dispatches on version, and reads the + trailing 8 bytes only for v2. + +Runtime: + - strmap->enable_xxh64 selects the format used when creating a new + file (or recreating one whose uid_validity has changed). + - view->format_version follows the on-disk file when one exists; for + fresh files it follows the strmap-wide preference. + - Renumber recreate_write() preserves view->format_version, so we + never silently migrate an existing file across the threshold while + it is still in use - the migration only happens at open time. + - When enable_xxh64 is TRUE and the on-disk file is v1, open treats + it as a version mismatch: the file is unlinked and the next sync's + recreate_write() produces a v2 file. Below the threshold v1 stays + v1 indefinitely. + - strmap_hash_str() dispatches per-view, so v1 files keep using + crc32 hashes and v2 files use keyed xxh64 even in mixed setups. +--- + src/lib-index/mail-index-strmap.c | 106 ++++++++++++++++++++++++--- + src/lib-index/mail-index-strmap.h | 17 ++++- + src/lib-master/Makefile.am | 1 + + src/lib-master/storage-version.h | 22 ++++++ + src/lib-storage/index/index-thread.c | 12 ++- + 5 files changed, 143 insertions(+), 15 deletions(-) + create mode 100644 src/lib-master/storage-version.h + +Index: trixie/src/lib-index/mail-index-strmap.c +=================================================================== +--- trixie.orig/src/lib-index/mail-index-strmap.c ++++ trixie/src/lib-index/mail-index-strmap.c +@@ -8,6 +8,8 @@ + #include "file-lock.h" + #include "file-dotlock.h" + #include "crc32.h" ++#include "randgen.h" ++#include "xxh64.h" + #include "safe-mkstemp.h" + #include "str.h" + #include "mail-index-private.h" +@@ -24,6 +26,12 @@ struct mail_index_strmap { + struct file_lock *file_lock; + struct dotlock *dotlock; + struct dotlock_settings dotlock_settings; ++ ++ /* If TRUE, new files are created in v2 format (xxh64-keyed hashes). ++ If FALSE, new files are created in v1 format (crc32). Existing ++ files of either version are always read; on rebuild they are ++ recreated in this preferred format. */ ++ bool enable_xxh64; + }; + + struct mail_index_strmap_view { +@@ -34,6 +42,14 @@ struct mail_index_strmap_view { + ARRAY(uint32_t) recs_hash; + struct hash2_table *hash; + ++ /* On-disk format version that view's hash values are computed ++ against. v1 = crc32_str_nonzero, v2 = keyed xxh64. Set from the ++ file header when opening an existing file; otherwise from ++ strmap->enable_xxh64. */ ++ uint8_t format_version; ++ /* Per-file random IV mixed into the xxh64 hash. Unused for v1. */ ++ uint64_t hash_iv; ++ + mail_index_strmap_key_cmp_t *key_compare; + mail_index_strmap_rec_cmp_t *rec_compare; + mail_index_strmap_remap_t *remap_cb; +@@ -92,7 +108,8 @@ static const struct dotlock_settings def + }; + + struct mail_index_strmap * +-mail_index_strmap_init(struct mail_index *index, const char *suffix) ++mail_index_strmap_init(struct mail_index *index, const char *suffix, ++ bool enable_xxh64) + { + struct mail_index_strmap *strmap; + +@@ -102,6 +119,7 @@ mail_index_strmap_init(struct mail_index + strmap->index = index; + strmap->path = i_strconcat(index->filepath, suffix, NULL); + strmap->fd = -1; ++ strmap->enable_xxh64 = enable_xxh64; + + strmap->dotlock_settings = default_dotlock_settings; + strmap->dotlock_settings.use_excl_lock = +@@ -198,6 +216,9 @@ mail_index_strmap_view_open(struct mail_ + + i_array_init(&view->recs, 64); + i_array_init(&view->recs_hash, 64); ++ view->format_version = strmap->enable_xxh64 ? ++ MAIL_INDEX_STRMAP_VERSION_V2 : MAIL_INDEX_STRMAP_VERSION_V1; ++ random_fill(&view->hash_iv, sizeof(view->hash_iv)); + view->hash = hash2_create(0, sizeof(struct mail_index_strmap_rec), + mail_index_strmap_hash_key, + mail_index_strmap_hash_cmp, view); +@@ -250,7 +271,7 @@ static int mail_index_strmap_open(struct + const struct mail_index_header *idx_hdr; + struct mail_index_strmap_header hdr; + const unsigned char *data; +- size_t size; ++ size_t size, hdr_size; + int ret; + + i_assert(strmap->fd == -1); +@@ -263,7 +284,12 @@ static int mail_index_strmap_open(struct + return -1; + } + strmap->input = i_stream_create_fd(strmap->fd, SIZE_MAX); +- ret = i_stream_read_bytes(strmap->input, &data, &size, sizeof(hdr)); ++ ++ /* Read the smaller v1 header first; bytes 0..7 have identical ++ layout in both formats so we can decide based on the version ++ byte before reading the v2 tail. */ ++ ret = i_stream_read_bytes(strmap->input, &data, &size, ++ MAIL_INDEX_STRMAP_HEADER_V1_SIZE); + if (ret <= 0) { + if (ret < 0) { + mail_index_strmap_set_syscall_error(strmap, "read()"); +@@ -274,23 +300,55 @@ static int mail_index_strmap_open(struct + } + return ret; + } +- memcpy(&hdr, data, sizeof(hdr)); ++ ++ i_zero(&hdr); ++ hdr_size = data[0] == MAIL_INDEX_STRMAP_VERSION_V2 ? ++ MAIL_INDEX_STRMAP_HEADER_V2_SIZE : ++ MAIL_INDEX_STRMAP_HEADER_V1_SIZE; ++ if (hdr_size > MAIL_INDEX_STRMAP_HEADER_V1_SIZE) { ++ ret = i_stream_read_bytes(strmap->input, &data, &size, hdr_size); ++ if (ret <= 0) { ++ if (ret < 0) { ++ mail_index_strmap_set_syscall_error(strmap, "read()"); ++ mail_index_strmap_close(strmap); ++ } else { ++ mail_index_strmap_view_set_corrupted(view); ++ } ++ return ret; ++ } ++ } ++ memcpy(&hdr, data, hdr_size); + + idx_hdr = mail_index_get_header(view->view); +- if (hdr.version != MAIL_INDEX_STRMAP_VERSION || +- hdr.uid_validity != idx_hdr->uid_validity) { ++ uint8_t compat_flags = 0; ++#ifndef WORDS_BIGENDIAN ++ if (hdr.version >= MAIL_INDEX_STRMAP_VERSION_V2) ++ compat_flags |= MAIL_INDEX_COMPAT_LITTLE_ENDIAN; ++#endif ++ if ((hdr.version != MAIL_INDEX_STRMAP_VERSION_V1 && ++ hdr.version != MAIL_INDEX_STRMAP_VERSION_V2) || ++ hdr.compat_flags != compat_flags || ++ hdr.uid_validity != idx_hdr->uid_validity || ++ (strmap->enable_xxh64 && ++ hdr.version < MAIL_INDEX_STRMAP_VERSION_V2)) { + /* need to rebuild. if we already had something in the strmap, +- we can keep it. */ ++ we can keep it. The enable_xxh64 case forces a v1 file to ++ be discarded so the next write creates a v2 file - we never ++ stay on v1 once the dovecot_storage_version threshold has ++ been reached. */ + i_unlink(strmap->path); + mail_index_strmap_close(strmap); + return 0; + } ++ view->format_version = hdr.version; ++ view->hash_iv = hdr.version >= MAIL_INDEX_STRMAP_VERSION_V2 ? ++ hdr.hash_iv : 0; + + /* we'll read the entire file from the beginning */ + view->last_added_uid = 0; + view->last_read_uid = 0; + view->total_ref_count = 0; +- view->last_read_block_offset = sizeof(struct mail_index_strmap_header); ++ view->last_read_block_offset = hdr_size; + view->next_str_idx = 1; + + mail_index_strmap_view_reset(view); +@@ -750,6 +808,17 @@ static inline uint32_t crc32_str_nonzero + return value == 0 ? 1 : value; + } + ++static inline uint32_t ++strmap_hash_str(const struct mail_index_strmap_view *view, const char *str) ++{ ++ if (view->format_version < MAIL_INDEX_STRMAP_VERSION_V2) ++ return crc32_str_nonzero(str); ++ ++ uint32_t value = xxh64_to_32(xxh64_data(str, strlen(str), view->hash_iv)); ++ /* 0 is reserved as a sentinel meaning "unique / no hash stored" */ ++ return value == 0 ? 1 : value; ++} ++ + void mail_index_strmap_view_sync_add(struct mail_index_strmap_view_sync *sync, + uint32_t uid, uint32_t ref_index, + const char *key) +@@ -764,7 +833,7 @@ void mail_index_strmap_view_sync_add(str + ref_index > view->last_ref_index)); + + hash_key.str = key; +- hash_key.hash = crc32_str_nonzero(key); ++ hash_key.hash = strmap_hash_str(view, key); + + old_rec = hash2_lookup(view->hash, &hash_key); + if (old_rec != NULL) { +@@ -971,14 +1040,29 @@ mail_index_strmap_recreate_write(struct + { + const struct mail_index_header *idx_hdr; + struct mail_index_strmap_header hdr; ++ size_t hdr_size; + + idx_hdr = mail_index_get_header(view->view); + ++ /* view->format_version was set either at view_open() time (from the ++ strmap-wide enable_xxh64 preference for fresh files) or in ++ mail_index_strmap_open() from the existing file's header. Reuse ++ it as-is so renumber recreates preserve the file's format. */ ++ + /* write header */ + i_zero(&hdr); +- hdr.version = MAIL_INDEX_STRMAP_VERSION; ++ hdr.version = view->format_version; + hdr.uid_validity = idx_hdr->uid_validity; +- o_stream_nsend(output, &hdr, sizeof(hdr)); ++ if (view->format_version >= MAIL_INDEX_STRMAP_VERSION_V2) { ++#ifndef WORDS_BIGENDIAN ++ hdr.compat_flags |= MAIL_INDEX_COMPAT_LITTLE_ENDIAN; ++#endif ++ hdr.hash_iv = view->hash_iv; ++ hdr_size = MAIL_INDEX_STRMAP_HEADER_V2_SIZE; ++ } else { ++ hdr_size = MAIL_INDEX_STRMAP_HEADER_V1_SIZE; ++ } ++ o_stream_nsend(output, &hdr, hdr_size); + + view->total_ref_count = 0; + mail_index_strmap_write_block(view, output, 0, 1); +Index: trixie/src/lib-index/mail-index-strmap.h +=================================================================== +--- trixie.orig/src/lib-index/mail-index-strmap.h ++++ trixie/src/lib-index/mail-index-strmap.h +@@ -7,12 +7,22 @@ struct mail_index; + struct mail_index_view; + + struct mail_index_strmap_header { +-#define MAIL_INDEX_STRMAP_VERSION 1 ++/* On-disk format version. The header struct below describes version 2; ++ version 1 files use the same first 8 bytes (version + 3 unused + uid_validity) ++ without the trailing compat_flags / unused / hash_iv fields. */ ++#define MAIL_INDEX_STRMAP_VERSION_V1 1 ++#define MAIL_INDEX_STRMAP_VERSION_V2 2 + uint8_t version; +- uint8_t unused[3]; ++ uint8_t compat_flags; /* enum mail_index_header_compat_flags, v2+ */ ++ uint8_t unused[2]; + + uint32_t uid_validity; ++ ++ /* v2+ fields - not present on disk for v1 files. */ ++ uint64_t hash_iv; /* per-file random IV mixed into the xxh64 hash */ + }; ++#define MAIL_INDEX_STRMAP_HEADER_V1_SIZE 8 ++#define MAIL_INDEX_STRMAP_HEADER_V2_SIZE sizeof(struct mail_index_strmap_header) + + struct mail_index_strmap_rec { + uint32_t uid; +@@ -40,7 +50,8 @@ typedef void mail_index_strmap_remap_t(c + unsigned int new_count, void *context); + + struct mail_index_strmap * +-mail_index_strmap_init(struct mail_index *index, const char *suffix); ++mail_index_strmap_init(struct mail_index *index, const char *suffix, ++ bool enable_xxh64); + void mail_index_strmap_deinit(struct mail_index_strmap **strmap); + + /* Returns strmap records and hash that can be used for read-only access. +Index: trixie/src/lib-master/Makefile.am +=================================================================== +--- trixie.orig/src/lib-master/Makefile.am ++++ trixie/src/lib-master/Makefile.am +@@ -39,6 +39,7 @@ headers = \ + master-service-ssl.h \ + service-settings.h \ + stats-client.h \ ++ storage-version.h \ + syslog-util.h + + pkginc_libdir=$(pkgincludedir) +Index: trixie/src/lib-master/storage-version.h +=================================================================== +--- /dev/null ++++ trixie/src/lib-master/storage-version.h +@@ -0,0 +1,22 @@ ++#ifndef STORAGE_VERSION_H ++#define STORAGE_VERSION_H ++ ++#include "version.h" ++ ++/* Returns TRUE if dovecot_storage_version selects the v2 (keyed xxh64) ++ mail_index_strmap on-disk format. */ ++static inline bool ++storage_version_has_mail_index_strmap_v2(const char *version) ++{ ++ if (version == NULL) { ++ /* unit test */ ++ return TRUE; ++ } ++#ifdef DOVECOT_PRO_EDITION ++ return version_cmp(version, "3.1.6") >= 0; ++#else ++ return version_cmp(version, "2.4.0") >= 0; ++#endif ++} ++ ++#endif +Index: trixie/src/lib-storage/index/index-thread.c +=================================================================== +--- trixie.orig/src/lib-storage/index/index-thread.c ++++ trixie/src/lib-storage/index/index-thread.c +@@ -7,6 +7,9 @@ + #include "bsearch-insert-pos.h" + #include "hash2.h" + #include "message-id.h" ++#include "master-service.h" ++#include "master-service-settings.h" ++#include "storage-version.h" + #include "mail-search.h" + #include "mail-search-build.h" + #include "mailbox-search-result-private.h" +@@ -668,8 +671,15 @@ void index_thread_mailbox_opened(struct + box->v.close = mail_thread_mailbox_close; + box->v.free = mail_thread_mailbox_free; + ++ const struct master_service_settings *master_set = ++ master_service_get_service_settings(master_service); ++ bool use_xxh64 = master_set != NULL && ++ storage_version_has_mail_index_strmap_v2( ++ master_set->dovecot_storage_version); ++ + tbox->strmap = mail_index_strmap_init(box->index, +- MAIL_THREAD_INDEX_SUFFIX); ++ MAIL_THREAD_INDEX_SUFFIX, ++ use_xxh64); + tbox->next_msgid_idx = 1; + + tbox->cache = i_new(struct mail_thread_cache, 1); diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0007-lib-mail-istream-header-filter-Use-container_of-for-.patch dovecot-2.4.1+dfsg1/debian/patches/0007-lib-mail-istream-header-filter-Use-container_of-for-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0007-lib-mail-istream-header-filter-Use-container_of-for-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0007-lib-mail-istream-header-filter-Use-container_of-for-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,65 @@ +From c5996d464e31ee9950fd40654e9fedcf9f99719e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 20 Apr 2026 01:14:02 +0300 +Subject: [PATCH 07/14] lib-mail: istream-header-filter - Use container_of() + for struct casts + +Replace the C-style downcasts from struct istream_private/iostream_private +to struct header_filter_istream with container_of(), matching the +convention used by other istream implementations in tree. +--- + src/lib-mail/istream-header-filter.c | 10 +++++----- + 1 file changed, 5 insertions(+), 5 deletions(-) + +diff --git a/src/lib-mail/istream-header-filter.c b/src/lib-mail/istream-header-filter.c +index ee94e4f82b..5fee9cf17b 100644 +--- a/src/lib-mail/istream-header-filter.c ++++ b/src/lib-mail/istream-header-filter.c +@@ -60,7 +60,7 @@ static ssize_t i_stream_header_filter_read(struct istream_private *stream); + static void i_stream_header_filter_destroy(struct iostream_private *stream) + { + struct header_filter_istream *mstream = +- (struct header_filter_istream *)stream; ++ container_of(stream, struct header_filter_istream, istream.iostream); + + if (mstream->hdr_ctx != NULL) + message_parse_header_deinit(&mstream->hdr_ctx); +@@ -470,7 +470,7 @@ handle_end_body_with_lf(struct header_filter_istream *mstream, ssize_t ret) + static ssize_t i_stream_header_filter_read(struct istream_private *stream) + { + struct header_filter_istream *mstream = +- (struct header_filter_istream *)stream; ++ container_of(stream, struct header_filter_istream, istream); + uoff_t v_offset; + ssize_t ret; + +@@ -554,7 +554,7 @@ static void i_stream_header_filter_seek(struct istream_private *stream, + uoff_t v_offset, bool mark ATTR_UNUSED) + { + struct header_filter_istream *mstream = +- (struct header_filter_istream *)stream; ++ container_of(stream, struct header_filter_istream, istream); + + if (stream->istream.v_offset == v_offset) { + /* just reset the input buffer */ +@@ -604,7 +604,7 @@ static int + i_stream_header_filter_stat(struct istream_private *stream, bool exact) + { + struct header_filter_istream *mstream = +- (struct header_filter_istream *)stream; ++ container_of(stream, struct header_filter_istream, istream); + const struct stat *st; + uoff_t old_offset; + +@@ -673,7 +673,7 @@ i_stream_header_filter_snapshot(struct istream_private *stream, + struct istream_snapshot *prev_snapshot) + { + struct header_filter_istream *mstream = +- (struct header_filter_istream *)stream; ++ container_of(stream, struct header_filter_istream, istream); + struct header_filter_istream_snapshot *snapshot; + + if (stream->buffer != mstream->hdr_buf->data) { +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sieve-Log-an-info-line-when-opening-a-CPU-disabl.patch dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sieve-Log-an-info-line-when-opening-a-CPU-disabl.patch --- dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sieve-Log-an-info-line-when-opening-a-CPU-disabl.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sieve-Log-an-info-line-when-opening-a-CPU-disabl.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,52 @@ +From 800697eaa3b753d7a449fb7da62f41a21b02925e Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 3 Jun 2026 12:25:49 +0000 +Subject: [PATCH 7/7] lib-sieve: Log an info line when opening a CPU-disabled + script + +sieve_binary_check_executable() blocks execution when a script's +persisted cumulative CPU usage already exceeds sieve_max_cpu_time, but +only logged the block at debug level. With cumulative tracking now +sticky across redeliveries via the per-user sieve-rusage file, the +script can stay blocked indefinitely until the resource_usage_timeout +decay or admin intervention; operators benefit from a per-attempt +record of that block at info level. + +Promote the existing debug log to e_info and include the cumulative +CPU usage. The caller continues to emit its own error message on the +failed open (lda-sieve, imap-sieve, imap-filter-sieve plugin paths). +--- + src/lib-sieve/sieve-binary-file.c | 14 +++++++++++--- + 1 file changed, 11 insertions(+), 3 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/sieve-binary-file.c ++++ trixie/pigeonhole/src/lib-sieve/sieve-binary-file.c +@@ -935,15 +935,23 @@ int sieve_binary_check_executable(struct + const char **client_error_r) + { + struct sieve_resource_usage rusage; ++ struct sieve_script *script = sbin->script; + + *client_error_r = NULL; + sieve_error_args_init(&error_code_r, NULL); + + sieve_binary_get_resource_usage(sbin, &rusage); + if (sieve_resource_usage_is_excessive(sbin->svinst, &rusage)) { +- e_debug(sbin->event, +- "Binary execution is blocked: " +- "Cumulative resource usage limit exceeded"); ++ /* Cumulative resource usage is only tracked for personal ++ scripts. */ ++ i_assert(script != NULL); ++ i_assert(sieve_storage_is_personal(script->storage)); ++ ++ e_info(sbin->event, ++ "Personal sieve processing disabled for script '%s': " ++ "cumulative resource usage limit exceeded (%s)", ++ sieve_script_label(script), ++ sieve_resource_usage_get_summary(&rusage)); + *error_code_r = SIEVE_ERROR_RESOURCE_LIMIT; + *client_error_r = "cumulative resource usage limit exceeded"; + return 0; diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sql-Run-sql-statement-queries-in-their-own-data-.patch dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sql-Run-sql-statement-queries-in-their-own-data-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sql-Run-sql-statement-queries-in-their-own-data-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0007-lib-sql-Run-sql-statement-queries-in-their-own-data-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,104 @@ +From 30ec9bdcb445011801229a825c1d5a443397463f Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 11 Jun 2026 20:02:15 +0000 +Subject: [PATCH] lib-sql: Run sql statement queries in their own data stack + frame + +--- + src/lib-sql/sql-api.c | 62 +++++++++++++++++++++++++------------------ + 1 file changed, 36 insertions(+), 26 deletions(-) + +Index: trixie/src/lib-sql/sql-api.c +=================================================================== +--- trixie.orig/src/lib-sql/sql-api.c ++++ trixie/src/lib-sql/sql-api.c +@@ -657,31 +657,37 @@ void sql_statement_query(struct sql_stat + struct sql_statement *stmt = *_stmt; + *_stmt = NULL; + +- if (stmt->db->v.statement_query != NULL) +- stmt->db->v.statement_query(stmt, callback, context); +- else if (stmt->db->v.statement_query_s != NULL) { +- struct sql_db *db = stmt->db; +- struct sql_query_result_delayed *cb = +- i_new(struct sql_query_result_delayed, 1); +- cb->db = db; +- cb->callback = callback; +- cb->context = context; +- cb->result = sql_statement_query_s(&stmt); +- cb->to = timeout_add_short(0, sql_query_delayed_callback, cb); +- DLLIST_PREPEND(&db->query_delayed_list, cb); +- } else +- default_sql_statement_query(stmt, callback, context); ++ T_BEGIN { ++ if (stmt->db->v.statement_query != NULL) ++ stmt->db->v.statement_query(stmt, callback, context); ++ else if (stmt->db->v.statement_query_s != NULL) { ++ struct sql_db *db = stmt->db; ++ struct sql_query_result_delayed *cb = ++ i_new(struct sql_query_result_delayed, 1); ++ cb->db = db; ++ cb->callback = callback; ++ cb->context = context; ++ cb->result = sql_statement_query_s(&stmt); ++ cb->to = timeout_add_short(0, sql_query_delayed_callback, cb); ++ DLLIST_PREPEND(&db->query_delayed_list, cb); ++ } else ++ default_sql_statement_query(stmt, callback, context); ++ } T_END; + } + + struct sql_result *sql_statement_query_s(struct sql_statement **_stmt) + { + struct sql_statement *stmt = *_stmt; ++ struct sql_result *result; + + *_stmt = NULL; +- if (stmt->db->v.statement_query_s != NULL) +- return stmt->db->v.statement_query_s(stmt); +- else +- return default_sql_statement_query_s(stmt); ++ T_BEGIN { ++ if (stmt->db->v.statement_query_s != NULL) ++ result = stmt->db->v.statement_query_s(stmt); ++ else ++ result = default_sql_statement_query_s(stmt); ++ } T_END; ++ return result; + } + + void sql_result_ref(struct sql_result *result) +@@ -1011,10 +1017,12 @@ void sql_update_stmt(struct sql_transact + struct sql_statement *stmt = *_stmt; + + *_stmt = NULL; +- if (ctx->db->v.update_stmt != NULL) +- ctx->db->v.update_stmt(ctx, stmt, NULL); +- else +- default_sql_update_stmt(ctx, stmt, NULL); ++ T_BEGIN { ++ if (ctx->db->v.update_stmt != NULL) ++ ctx->db->v.update_stmt(ctx, stmt, NULL); ++ else ++ default_sql_update_stmt(ctx, stmt, NULL); ++ } T_END; + } + + void sql_update_get_rows(struct sql_transaction_context *ctx, const char *query, +@@ -1030,10 +1038,12 @@ void sql_update_stmt_get_rows(struct sql + struct sql_statement *stmt = *_stmt; + + *_stmt = NULL; +- if (ctx->db->v.update_stmt != NULL) +- ctx->db->v.update_stmt(ctx, stmt, affected_rows); +- else +- default_sql_update_stmt(ctx, stmt, affected_rows); ++ T_BEGIN { ++ if (ctx->db->v.update_stmt != NULL) ++ ctx->db->v.update_stmt(ctx, stmt, affected_rows); ++ else ++ default_sql_update_stmt(ctx, stmt, affected_rows); ++ } T_END; + } + + void sql_db_set_state(struct sql_db *db, enum sql_db_state state) diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0008-doveadm-Fix-ubsan-unsigned-integer-overflow-error.patch dovecot-2.4.1+dfsg1/debian/patches/0008-doveadm-Fix-ubsan-unsigned-integer-overflow-error.patch --- dovecot-2.4.1+dfsg1/debian/patches/0008-doveadm-Fix-ubsan-unsigned-integer-overflow-error.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0008-doveadm-Fix-ubsan-unsigned-integer-overflow-error.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,29 @@ +From aba5b8556952dbb47b9ed8b338d170cb73c80ce6 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Thu, 16 Apr 2026 13:27:42 +0200 +Subject: [PATCH 08/12] doveadm: Fix ubsan unsigned integer overflow error + +--- + src/doveadm/doveadm-who.c | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +diff --git a/src/doveadm/doveadm-who.c b/src/doveadm/doveadm-who.c +index 2fbef7bb94..ecdbb88bc7 100644 +--- a/src/doveadm/doveadm-who.c ++++ b/src/doveadm/doveadm-who.c +@@ -49,10 +49,10 @@ static unsigned int who_user_hash(const struct who_user *user) + unsigned int hash = str_hash(user->service); + + if (user->username[0] != '\0') +- hash += str_hash(user->username); ++ hash ^= str_hash(user->username); + else { + who_user_ip(user, &ip); +- hash += net_ip_hash(&ip); ++ hash ^= net_ip_hash(&ip); + } + return hash; + } +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0008-lib-mail-Make-istream-header-filter-s-per-header-siz.patch dovecot-2.4.1+dfsg1/debian/patches/0008-lib-mail-Make-istream-header-filter-s-per-header-siz.patch --- dovecot-2.4.1+dfsg1/debian/patches/0008-lib-mail-Make-istream-header-filter-s-per-header-siz.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0008-lib-mail-Make-istream-header-filter-s-per-header-siz.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,91 @@ +From a444863fef605978c6f18e85db07f382d6f21d23 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sun, 19 Apr 2026 18:15:49 +0300 +Subject: [PATCH 08/14] lib-mail: Make istream-header-filter's per-header size + limit configurable + +Until now the internal message_header_parser_ctx used the default +MESSAGE_HEADER_BLOCK_DEFAULT_MAX_SIZE (10 MB) cap. In practice that cap +only bounded hdr->full_value; per-chunk hdr->value was delivered in full +regardless. A subsequent change to message_parse_header_next() will +clamp hdr->value cumulatively as well, and at that point the 10 MB +default would silently truncate data for callers like mbox-save that +stream raw header bytes to storage. + +Default the filter to SIZE_MAX (unlimited) to preserve the effective +behavior and add i_stream_header_filter_set_max_header_block_size() so +callers that genuinely want a cap (index_mail_get_header_stream()) can +opt in. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + src/lib-mail/istream-header-filter.c | 20 ++++++++++++++++++++ + src/lib-mail/istream-header-filter.h | 8 ++++++++ + 2 files changed, 28 insertions(+) + +Index: trixie/src/lib-mail/istream-header-filter.c +=================================================================== +--- trixie.orig/src/lib-mail/istream-header-filter.c ++++ trixie/src/lib-mail/istream-header-filter.c +@@ -29,6 +29,7 @@ struct header_filter_istream { + buffer_t *hdr_buf; + int snapshot_pending_refcount; + struct message_size header_size; ++ size_t max_header_block_size; + uoff_t skip_count; + uoff_t last_lf_offset; + +@@ -194,6 +195,8 @@ static ssize_t read_header(struct header + mstream->hdr_ctx = + message_parse_header_init(mstream->istream.parent, + NULL, 0); ++ message_parse_header_set_limit(mstream->hdr_ctx, ++ mstream->max_header_block_size); + } + + /* remove skipped data from hdr_buf */ +@@ -709,6 +712,9 @@ i_stream_create_header_filter(struct ist + mstream->pool = pool_alloconly_create(MEMPOOL_GROWING + "header filter stream", 256); + mstream->istream.max_buffer_size = input->real_stream->max_buffer_size; ++ /* Default to no per-header block size limit. Callers that want one ++ call i_stream_header_filter_set_max_header_block_size(). */ ++ mstream->max_header_block_size = SIZE_MAX; + + mstream->headers = headers_count == 0 ? NULL : + p_new(mstream->pool, const char *, headers_count); +@@ -762,3 +768,17 @@ void i_stream_header_filter_add(struct h + buffer_append(input->hdr_buf, data, size); + input->headers_edited = TRUE; + } ++ ++void i_stream_header_filter_set_max_header_block_size( ++ struct istream *input, size_t max_header_block_size) ++{ ++ struct header_filter_istream *mstream = ++ container_of(input->real_stream, struct header_filter_istream, ++ istream); ++ ++ mstream->max_header_block_size = max_header_block_size; ++ if (mstream->hdr_ctx != NULL) { ++ message_parse_header_set_limit(mstream->hdr_ctx, ++ max_header_block_size); ++ } ++} +Index: trixie/src/lib-mail/istream-header-filter.h +=================================================================== +--- trixie.orig/src/lib-mail/istream-header-filter.h ++++ trixie/src/lib-mail/istream-header-filter.h +@@ -49,4 +49,12 @@ i_stream_create_header_filter(struct ist + void i_stream_header_filter_add(struct header_filter_istream *input, + const void *data, size_t size); + ++/* Set a per-header-block maximum size for this filter stream. Data beyond ++ this limit in any single header is dropped from the filter's output and ++ from values delivered to the filter callback. The default is SIZE_MAX ++ (unlimited). Use MESSAGE_HEADER_BLOCK_DEFAULT_MAX_SIZE from ++ message-header-parser.h for the standard cap. */ ++void i_stream_header_filter_set_max_header_block_size( ++ struct istream *input, size_t max_header_block_size); ++ + #endif diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0008-lib-sieve-Reduce-CPU-limit-check-interval-in-contain.patch dovecot-2.4.1+dfsg1/debian/patches/0008-lib-sieve-Reduce-CPU-limit-check-interval-in-contain.patch --- dovecot-2.4.1+dfsg1/debian/patches/0008-lib-sieve-Reduce-CPU-limit-check-interval-in-contain.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0008-lib-sieve-Reduce-CPU-limit-check-interval-in-contain.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,40 @@ +From 01c95a5503ec53c40f12d40de0b508838f6b041d Mon Sep 17 00:00:00 2001 +From: Aki Tuomi +Date: Mon, 25 May 2026 14:54:03 +0000 +Subject: [PATCH] lib-sieve: Reduce CPU limit check interval in :contains and + :matches matchers + +Reduce the interval to 64 so worst-case work between checks is ~64 MiB. +cpu_limit_exceeded() is a signal-counter comparison with no syscall, so +the higher call frequency has no measurable cost. +--- + src/lib-sieve/mcht-contains.c | 2 +- + src/lib-sieve/mcht-matches.c | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + +Index: trixie/pigeonhole/src/lib-sieve/mcht-contains.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/mcht-contains.c ++++ trixie/pigeonhole/src/lib-sieve/mcht-contains.c +@@ -45,7 +45,7 @@ const struct sieve_match_type_def contai + * configured sieve_max_cpu_time (which is otherwise only checked between + * bytecode operations). + */ +-#define SIEVE_CONTAINS_CPU_CHECK_INTERVAL 4096 ++#define SIEVE_CONTAINS_CPU_CHECK_INTERVAL 64 + + static int mcht_contains_match_key + (struct sieve_match_context *mctx, const char *val, size_t val_size, +Index: trixie/pigeonhole/src/lib-sieve/mcht-matches.c +=================================================================== +--- trixie.orig/pigeonhole/src/lib-sieve/mcht-matches.c ++++ trixie/pigeonhole/src/lib-sieve/mcht-matches.c +@@ -53,7 +53,7 @@ const struct sieve_match_type_def matche + * configured sieve_max_cpu_time. Returns 1 on match, 0 on exhaustion, or -1 + * when the CPU time limit was exceeded (mctx->exec_status is set). + */ +-#define SIEVE_MATCHES_CPU_CHECK_INTERVAL 4096 ++#define SIEVE_MATCHES_CPU_CHECK_INTERVAL 64 + + static int + _string_find(struct sieve_match_context *mctx, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0009-config-Assume-dovecot_config_version-0.0.0-is-the-sa.patch dovecot-2.4.1+dfsg1/debian/patches/0009-config-Assume-dovecot_config_version-0.0.0-is-the-sa.patch --- dovecot-2.4.1+dfsg1/debian/patches/0009-config-Assume-dovecot_config_version-0.0.0-is-the-sa.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0009-config-Assume-dovecot_config_version-0.0.0-is-the-sa.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,92 @@ +From f5b9e0c2310b6fa744cf098dea9c03f88a985898 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 29 Sep 2025 14:09:42 +0300 +Subject: [PATCH 09/12] config: Assume dovecot_config_version=0.0.0 is the same + as the latest version + +It's used for git builds. +--- + src/config/config-parser.c | 16 +++++++++++++++- + src/config/config-parser.h | 3 +++ + src/config/doveconf.c | 10 +++++++++- + 3 files changed, 27 insertions(+), 2 deletions(-) + +diff --git a/src/config/config-parser.c b/src/config/config-parser.c +index b2883538ae..7e9db599f4 100644 +--- a/src/config/config-parser.c ++++ b/src/config/config-parser.c +@@ -2604,6 +2604,17 @@ static bool config_version_find(const char *version, const char **error_r) + return TRUE; + } + ++static bool ++dovecot_config_version_equals(struct config_parser_context *ctx, const char *value) ++{ ++ if (strcmp(ctx->dovecot_config_version, value) == 0) ++ return TRUE; ++ if (strcmp(ctx->dovecot_config_version, CONFIG_VERSION_MAX) == 0 && ++ strcmp(value, CONFIG_VERSION_GIT) == 0) ++ return TRUE; ++ return FALSE; ++} ++ + static bool config_parser_get_version(struct config_parser_context *ctx, + const struct config_line *line) + { +@@ -2616,7 +2627,7 @@ static bool config_parser_get_version(struct config_parser_context *ctx, + if (strcmp(line->key, "dovecot_config_version") == 0) { + if (ctx->dovecot_config_version == NULL) + ; +- else if (strcmp(ctx->dovecot_config_version, line->value) != 0) { ++ else if (!dovecot_config_version_equals(ctx, line->value)) { + ctx->error = "dovecot_config_version value can't be changed once set"; + return TRUE; + } else { +@@ -2636,6 +2647,9 @@ static bool config_parser_get_version(struct config_parser_context *ctx, + else if (!config_version_find(line->value, &error)) { + ctx->error = p_strdup_printf(ctx->pool, + "Invalid dovecot_config_version: %s", error); ++ } else if (strcmp(line->value, CONFIG_VERSION_GIT) == 0) { ++ /* git build - this is the same as the latest version. */ ++ ctx->dovecot_config_version = CONFIG_VERSION_MAX; + } else { + ctx->dovecot_config_version = p_strdup(ctx->pool, line->value); + } +diff --git a/src/config/config-parser.h b/src/config/config-parser.h +index 949aac100b..f9d70f1be8 100644 +--- a/src/config/config-parser.h ++++ b/src/config/config-parser.h +@@ -5,6 +5,9 @@ + + #define CONFIG_MODULE_DIR MODULEDIR"/settings" + ++#define CONFIG_VERSION_GIT "0.0.0" ++#define CONFIG_VERSION_MAX "9999.9999.9999" ++ + /* change_counter used for default settings created internally */ + #define CONFIG_PARSER_CHANGE_DEFAULTS 1 + /* change_counter used for settings changed by configuration file */ +diff --git a/src/config/doveconf.c b/src/config/doveconf.c +index 17dafbf006..825d84545c 100644 +--- a/src/config/doveconf.c ++++ b/src/config/doveconf.c +@@ -1297,7 +1297,15 @@ int main(int argc, char *argv[]) + printf("# %u default setting changes since version %s\n", + count, version); + } +- printf("dovecot_config_version = %s\n", version); ++ if (strcmp(version, CONFIG_VERSION_MAX) != 0) ++ printf("dovecot_config_version = %s\n", version); ++ else { ++ /* GIT version was changed to MAX for easier ++ comparisons internally. However, output it ++ back as the original GIT version. */ ++ printf("dovecot_config_version = %s\n", ++ CONFIG_VERSION_GIT); ++ } + } + if (!config_path_specified) + check_wrong_config(config_path); +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0009-lib-storage-Cap-per-header-size-in-index_mail_get_he.patch dovecot-2.4.1+dfsg1/debian/patches/0009-lib-storage-Cap-per-header-size-in-index_mail_get_he.patch --- dovecot-2.4.1+dfsg1/debian/patches/0009-lib-storage-Cap-per-header-size-in-index_mail_get_he.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0009-lib-storage-Cap-per-header-size-in-index_mail_get_he.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,42 @@ +From 6802995e1302718a81bc1e39bdd43d8296421d11 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sun, 19 Apr 2026 18:28:16 +0300 +Subject: [PATCH 09/14] lib-storage: Cap per-header size in + index_mail_get_header_stream() + +Apply MESSAGE_HEADER_BLOCK_DEFAULT_MAX_SIZE (10 MB) to the header-filter +istream used for populating the header cache. A pathological single +header (for example a To: with millions of addresses) otherwise grows +mail->header_data and the cache write buffer in lockstep with the raw +header size, which can push the imap process over vsz_limit on FETCH +ENVELOPE / BODYSTRUCTURE. + +On its own this change does not yet bound hdr->value delivery; that +requires the upcoming change to message_parse_header_next() to clamp +per-chunk value_len cumulatively. Setting the limit here now lets that +follow-up take effect without further touching this file. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + src/lib-storage/index/index-mail-headers.c | 5 +++++ + 1 file changed, 5 insertions(+) + +diff --git a/src/lib-storage/index/index-mail-headers.c b/src/lib-storage/index/index-mail-headers.c +index 18c922239d..a9f4d55c0d 100644 +--- a/src/lib-storage/index/index-mail-headers.c ++++ b/src/lib-storage/index/index-mail-headers.c +@@ -992,6 +992,11 @@ int index_mail_get_header_stream(struct mail *_mail, + HEADER_FILTER_HIDE_BODY, + headers->name, headers->count, + header_cache_callback, mail); ++ /* Cap per-header data so a single pathological header cannot exhaust ++ memory in mail->header_data / the filter's buffer. */ ++ i_stream_header_filter_set_max_header_block_size( ++ mail->data.filter_stream, ++ MESSAGE_HEADER_BLOCK_DEFAULT_MAX_SIZE); + *stream_r = mail->data.filter_stream; + return 0; + } +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0010-lib-mail-Clamp-hdr-value-cumulatively-against-header.patch dovecot-2.4.1+dfsg1/debian/patches/0010-lib-mail-Clamp-hdr-value-cumulatively-against-header.patch --- dovecot-2.4.1+dfsg1/debian/patches/0010-lib-mail-Clamp-hdr-value-cumulatively-against-header.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0010-lib-mail-Clamp-hdr-value-cumulatively-against-header.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,123 @@ +From 64ab59e17533a7eb63cbd4cd8cce81a39b32b2b1 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Mon, 20 Apr 2026 00:16:14 +0300 +Subject: [PATCH 10/14] lib-mail: Clamp hdr->value cumulatively against + header_block_max_size + +Until now header_block_max_size only bounded hdr->full_value via +value_buf. Continued chunks returned to the caller via hdr->value were +left at the raw chunk size, so a caller that consumed hdr->value per +chunk without ever requesting use_full_value (e.g. the header-cache +path in index_mail_parse_header()) could accumulate the full raw header +size. A pathological To: with millions of addresses could grow +mail->header_data and the cache write buffer to tens of megabytes each, +driving the imap process over vsz_limit on FETCH ENVELOPE. + +Reinterpret header_block_total_size as the running sum of line_value_size +across all chunks of all headers, and clamp each new chunk against the +remaining header_block_max_size budget. Propagate the clamped size to +line->value_len in the two continued-line branches that previously left +it untouched. value_buf is bounded implicitly since every append uses +line_value_size. The up-front per-chunk clamp +(line->value_len = MIN(value_len, max_size)) is now subsumed by the +cumulative clamp and has been removed. + +Update the truncation tests that were documenting the old +"value_len stays at raw chunk size" behavior. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + src/lib-mail/message-header-parser.c | 16 +++++++++++----- + src/lib-mail/test-message-header-parser.c | 16 ++++++++++++---- + 2 files changed, 23 insertions(+), 9 deletions(-) + +diff --git a/src/lib-mail/message-header-parser.c b/src/lib-mail/message-header-parser.c +index b240a47884..3026a35f29 100644 +--- a/src/lib-mail/message-header-parser.c ++++ b/src/lib-mail/message-header-parser.c +@@ -96,7 +96,6 @@ int message_parse_header_next(struct message_header_parser_ctx *ctx, + /* new header line */ + line->name_offset = ctx->input->v_offset; + colon_pos = UINT_MAX; +- ctx->header_block_total_size += ctx->value_buf->used; + buffer_set_used_size(ctx->value_buf, 0); + } + +@@ -362,12 +361,17 @@ int message_parse_header_next(struct message_header_parser_ctx *ctx, + } + } + +- line->value_len = I_MIN(line->value_len, ctx->header_block_max_size); + size_t line_value_size = line->value_len; +- size_t header_total_used = ctx->header_block_total_size + ctx->value_buf->used; +- size_t line_available = ctx->header_block_max_size <= header_total_used ? 0 : +- ctx->header_block_max_size - header_total_used; ++ /* Clamp line_value_size against the remaining header_block_max_size ++ budget. header_block_total_size is the running sum of ++ line_value_size across all chunks of all headers; value_buf growth ++ is bounded implicitly because every append to value_buf uses ++ line_value_size. */ ++ size_t line_available = ++ ctx->header_block_max_size <= ctx->header_block_total_size ? 0 : ++ ctx->header_block_max_size - ctx->header_block_total_size; + line_value_size = I_MIN(line_value_size, line_available); ++ ctx->header_block_total_size += line_value_size; + + if (!line->continued) { + /* first header line. make a copy of the line since we can't +@@ -397,10 +401,12 @@ int message_parse_header_next(struct message_header_parser_ctx *ctx, + + line->full_value = ctx->value_buf->data; + line->full_value_len = ctx->value_buf->used; ++ line->value_len = line_value_size; + } else { + /* we didn't want full_value, and this is a continued line. */ + line->full_value = NULL; + line->full_value_len = 0; ++ line->value_len = line_value_size; + } + + /* always reset it */ +diff --git a/src/lib-mail/test-message-header-parser.c b/src/lib-mail/test-message-header-parser.c +index 5395c54ed6..f2c9184e66 100644 +--- a/src/lib-mail/test-message-header-parser.c ++++ b/src/lib-mail/test-message-header-parser.c +@@ -494,11 +494,14 @@ static void test_message_header_truncation_clean_oneline(void) + struct message_header_parser_ctx *parser = message_parse_header_init(input, NULL, MESSAGE_HEADER_PARSER_FLAG_CLEAN_ONELINE); + message_parse_header_set_limit(parser, 96); + ++ /* hdr->value is now also clamped cumulatively against the same ++ header_block_max_size budget as full_value, so later chunks of a ++ multiline header shrink or disappear once the budget is used up. */ + assert_parse_line( 1, "header1", "this is short", "this is short"); + assert_parse_line( 2, "header2", "this is multiline", "this is multiline"); + assert_parse_line( 3, "header2", " and long 343638404244464850525456586062", "this is multiline and long 343638404244464850525456586062"); +- assert_parse_line( 4, "header2", " 64666870727476788082848688909294969800", "this is multiline and long 343638404244464850525456586062 6466687072747678808284868"); +- assert_parse_line( 5, "header2", " 02040608101214161820222426283032343638", "this is multiline and long 343638404244464850525456586062 6466687072747678808284868"); ++ assert_parse_line( 4, "header2", " 6466687072747678808284868", "this is multiline and long 343638404244464850525456586062 6466687072747678808284868"); ++ assert_parse_line( 5, "header2", "", "this is multiline and long 343638404244464850525456586062 6466687072747678808284868"); + assert_parse_line( 6, "header3", "", ""); + test_assert(message_parse_header_next(parser, &hdr) > 0 && hdr->eoh); + +@@ -515,11 +518,16 @@ static void test_message_header_truncation_flag0(void) + struct message_header_parser_ctx *parser = message_parse_header_init(input, NULL, 0); + message_parse_header_set_limit(parser, 96); + ++ /* hdr->value is clamped cumulatively against header_block_max_size. ++ The \n that NO flags inserts between continuations goes into ++ value_buf but is not added to header_block_total_size, so line 4's ++ value hits the same 26-byte cap as in CLEAN_ONELINE and ++ full_value_len is one byte larger than the nominal limit. */ + assert_parse_line( 1, "header1", "this is short", "this is short"); + assert_parse_line( 2, "header2", "this is multiline", "this is multiline"); + assert_parse_line( 3, "header2", " and long 343638404244464850525456586062", "this is multiline\n and long 343638404244464850525456586062"); +- assert_parse_line( 4, "header2", " 64666870727476788082848688909294969800", "this is multiline\n and long 343638404244464850525456586062\n 646668707274767880828486"); +- assert_parse_line( 5, "header2", " 02040608101214161820222426283032343638", "this is multiline\n and long 343638404244464850525456586062\n 646668707274767880828486"); ++ assert_parse_line( 4, "header2", " 6466687072747678808284868", "this is multiline\n and long 343638404244464850525456586062\n 6466687072747678808284868"); ++ assert_parse_line( 5, "header2", "", "this is multiline\n and long 343638404244464850525456586062\n 6466687072747678808284868"); + assert_parse_line( 6, "header3", "", ""); + test_assert(message_parse_header_next(parser, &hdr) > 0 && hdr->eoh); + +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0010-lib-master-Move-config_version_find-from-src-config-.patch dovecot-2.4.1+dfsg1/debian/patches/0010-lib-master-Move-config_version_find-from-src-config-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0010-lib-master-Move-config_version_find-from-src-config-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0010-lib-master-Move-config_version_find-from-src-config-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,123 @@ +From 9d9efd5ea41e0e3183e1e53d4326f40b65dd9ef8 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 1 May 2026 16:56:21 +0000 +Subject: [PATCH 10/12] lib-master: Move config_version_find() from + src/config/config-parser.c + +Rename to dovecot_config_version_find() and expose it via +master-service-settings.h so non-config callers (e.g. the upcoming +dovecot_storage_version validation in master_service_settings_check()) +can share the same supported-versions list. No behaviour change. +--- + src/config/config-parser.c | 31 ++++++------------- + src/lib-master/master-service-settings.c | 38 ++++++++++++++++++++++++ + src/lib-master/master-service-settings.h | 5 ++++ + 3 files changed, 52 insertions(+), 22 deletions(-) + +Index: trixie/src/config/config-parser.c +=================================================================== +--- trixie.orig/src/config/config-parser.c ++++ trixie/src/config/config-parser.c +@@ -2580,30 +2580,6 @@ config_parser_check_warnings(struct conf + hash_table_insert(ctx->seen_settings, path, first_pos); + } + +-static bool config_version_find(const char *version, const char **error_r) +-{ +- const char *const supported_versions[] = { +-#ifdef DOVECOT_PRO_EDITION +- "3.1.0", +-#else +- "2.4.0", +-#endif +- NULL +- }; +- /* FIXME: implement full version checking later */ +- if (!str_array_find(supported_versions, version) && +- strcmp(DOVECOT_CONFIG_VERSION, version) != 0) { +- *error_r = t_strdup_printf( +- "Currently supported versions are: %s%s", +- t_strarray_join(supported_versions, " "), +- str_array_find(supported_versions, +- DOVECOT_CONFIG_VERSION) ? "" : +- t_strdup_printf(" %s", DOVECOT_CONFIG_VERSION)); +- return FALSE; +- } +- return TRUE; +-} +- + static bool + dovecot_config_version_equals(struct config_parser_context *ctx, const char *value) + { +@@ -2644,7 +2620,7 @@ static bool config_parser_get_version(st + + if (line->type != CONFIG_LINE_TYPE_KEYVALUE) + ctx->error = "Invalid dovecot_config_version: value is not a string"; +- else if (!config_version_find(line->value, &error)) { ++ else if (!dovecot_config_version_find(line->value, &error)) { + ctx->error = p_strdup_printf(ctx->pool, + "Invalid dovecot_config_version: %s", error); + } else if (strcmp(line->value, CONFIG_VERSION_GIT) == 0) { +Index: trixie/src/lib-master/master-service-settings.c +=================================================================== +--- trixie.orig/src/lib-master/master-service-settings.c ++++ trixie/src/lib-master/master-service-settings.c +@@ -146,6 +146,44 @@ master_service_set_process_shutdown_filt + master_service_set_process_shutdown_filter(master_service, filter); + } + ++/* List of dovecot_config_versions accepted at parse / startup time. ++ dovecot_storage_version accepts these as well, plus an extra legacy set ++ defined in storage_version_check(). */ ++static const char *const dovecot_config_supported_versions[] = { ++#ifdef DOVECOT_PRO_EDITION ++ "3.1.0", ++ "3.1.1", ++ "3.1.2", ++ "3.1.3", ++ "3.1.4", ++ "3.1.5", ++ "3.2.0", ++#else ++ "2.4.0", ++ "2.4.1", ++ "2.4.2", ++ "2.4.3", ++ "2.4.4", ++#endif ++ NULL ++}; ++ ++bool dovecot_config_version_find(const char *version, const char **error_r) ++{ ++ /* FIXME: implement full version checking later */ ++ if (!str_array_find(dovecot_config_supported_versions, version) && ++ strcmp(DOVECOT_CONFIG_VERSION, version) != 0) { ++ *error_r = t_strdup_printf( ++ "Currently supported versions are: %s%s", ++ t_strarray_join(dovecot_config_supported_versions, " "), ++ str_array_find(dovecot_config_supported_versions, ++ DOVECOT_CONFIG_VERSION) ? "" : ++ t_strdup_printf(" %s", DOVECOT_CONFIG_VERSION)); ++ return FALSE; ++ } ++ return TRUE; ++} ++ + static bool storage_version_check(const char *version, const char **error_r) + { + #define STORAGE_MIN_VERSION "2.3.0" +Index: trixie/src/lib-master/master-service-settings.h +=================================================================== +--- trixie.orig/src/lib-master/master-service-settings.h ++++ trixie/src/lib-master/master-service-settings.h +@@ -95,4 +95,9 @@ master_service_get_service_settings(stru + const char * + master_service_get_import_environment_keyvals(struct master_service *service); + ++/* Returns TRUE if version is in the list of dovecot_config_version values ++ accepted by this build. Sets *error_r to a descriptive list of supported ++ versions on FALSE. */ ++bool dovecot_config_version_find(const char *version, const char **error_r); ++ + #endif diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0011-lib-lib-master-Move-version_-to-lib-version.-ch.patch dovecot-2.4.1+dfsg1/debian/patches/0011-lib-lib-master-Move-version_-to-lib-version.-ch.patch --- dovecot-2.4.1+dfsg1/debian/patches/0011-lib-lib-master-Move-version_-to-lib-version.-ch.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0011-lib-lib-master-Move-version_-to-lib-version.-ch.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,591 @@ +From 0e7ba48c290b251b2f264192dd559b72d48a8005 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Sun, 5 Oct 2025 18:49:13 +0300 +Subject: [PATCH 11/12] lib, lib-master: Move version_*() to lib/version.[ch] + +--- + src/anvil/anvil-connection.c | 1 + + src/config/config-connection.c | 1 + + src/config/config-parser.c | 3 +- + src/config/old-set-parser.c | 2 +- + src/doveadm/client-connection-tcp.c | 2 +- + src/doveadm/doveadm-penalty.c | 2 +- + src/doveadm/doveadm-stats.c | 2 +- + src/doveadm/doveadm-util.c | 1 + + src/doveadm/doveadm-who.c | 2 +- + src/doveadm/dsync/dsync-ibc-stream.c | 2 +- + src/imap-urlauth-login/imap-urlauth-login.c | 2 +- + src/imap/imap-client-hibernate.c | 2 +- + src/lib-doveadm/doveadm-client.c | 2 +- + src/lib-master/Makefile.am | 5 -- + src/lib-master/master-service-settings.c | 1 + + src/lib-master/master-service.c | 85 ------------------ + src/lib-master/master-service.h | 15 ---- + src/lib/Makefile.am | 3 + + src/lib/test-lib.inc | 1 + + .../test-version.c} | 16 ++-- + src/lib/version.c | 90 +++++++++++++++++++ + src/lib/version.h | 19 ++++ + src/util/script-login.c | 1 + + src/util/script.c | 1 + + 24 files changed, 136 insertions(+), 125 deletions(-) + rename src/{lib-master/test-master-service.c => lib/test-version.c} (83%) + create mode 100644 src/lib/version.c + create mode 100644 src/lib/version.h + +diff --git a/src/anvil/anvil-connection.c b/src/anvil/anvil-connection.c +index 13c57bf3e7..9cd44563ed 100644 +--- a/src/anvil/anvil-connection.c ++++ b/src/anvil/anvil-connection.c +@@ -11,6 +11,7 @@ + #include "connection.h" + #include "str.h" + #include "strescape.h" ++#include "version.h" + #include "master-service.h" + #include "master-interface.h" + #include "connect-limit.h" +diff --git a/src/config/config-connection.c b/src/config/config-connection.c +index d9e0a13207..265c702601 100644 +--- a/src/config/config-connection.c ++++ b/src/config/config-connection.c +@@ -7,6 +7,7 @@ + #include "ostream.h" + #include "ostream-unix.h" + #include "strescape.h" ++#include "version.h" + #include "settings-parser.h" + #include "master-service.h" + #include "master-service-settings.h" +diff --git a/src/config/config-parser.c b/src/config/config-parser.c +index 55e124e88b..07dca715f9 100644 +--- a/src/config/config-parser.c ++++ b/src/config/config-parser.c +@@ -9,9 +9,9 @@ + #include "strescape.h" + #include "istream.h" + #include "module-dir.h" ++#include "version.h" + #include "settings.h" + #include "service-settings.h" +-#include "master-service.h" + #include "master-service-settings.h" + #include "all-settings.h" + #include "old-set-parser.h" +@@ -22,6 +22,7 @@ + + #include "default-settings-import.h" + ++#include + #include + #include + #include +diff --git a/src/config/old-set-parser.c b/src/config/old-set-parser.c +index 4ae8506dc7..ee2cb513c9 100644 +--- a/src/config/old-set-parser.c ++++ b/src/config/old-set-parser.c +@@ -2,7 +2,7 @@ + + #include "lib.h" + #include "array.h" +-#include "master-service.h" ++#include "version.h" + #include "settings-history.h" + #include "config-parser-private.h" + #include "old-set-parser.h" +diff --git a/src/doveadm/client-connection-tcp.c b/src/doveadm/client-connection-tcp.c +index 9299596f29..936bb03f01 100644 +--- a/src/doveadm/client-connection-tcp.c ++++ b/src/doveadm/client-connection-tcp.c +@@ -8,9 +8,9 @@ + #include "istream.h" + #include "ostream.h" + #include "strescape.h" ++#include "version.h" + #include "iostream-ssl.h" + #include "ostream-multiplex.h" +-#include "master-service.h" + #include "master-service-ssl.h" + #include "mail-storage-service.h" + #include "doveadm-util.h" +diff --git a/src/doveadm/doveadm-penalty.c b/src/doveadm/doveadm-penalty.c +index 8a098b3453..71a997e2e8 100644 +--- a/src/doveadm/doveadm-penalty.c ++++ b/src/doveadm/doveadm-penalty.c +@@ -7,7 +7,7 @@ + #include "hash.h" + #include "strescape.h" + #include "time-util.h" +-#include "master-service.h" ++#include "version.h" + #include "doveadm.h" + #include "doveadm-print.h" + +diff --git a/src/doveadm/doveadm-stats.c b/src/doveadm/doveadm-stats.c +index bd7552211a..87aef26a89 100644 +--- a/src/doveadm/doveadm-stats.c ++++ b/src/doveadm/doveadm-stats.c +@@ -6,7 +6,7 @@ + #include "str.h" + #include "strescape.h" + #include "write-full.h" +-#include "master-service.h" ++#include "version.h" + #include "doveadm.h" + #include "doveadm-print.h" + #include "stats-settings.h" +diff --git a/src/doveadm/doveadm-util.c b/src/doveadm/doveadm-util.c +index c21dbee6c0..4bea2437cc 100644 +--- a/src/doveadm/doveadm-util.c ++++ b/src/doveadm/doveadm-util.c +@@ -7,6 +7,7 @@ + #include "ostream.h" + #include "net.h" + #include "time-util.h" ++#include "version.h" + #include "master-service.h" + #include "module-dir.h" + #include "doveadm-settings.h" +diff --git a/src/doveadm/doveadm-who.c b/src/doveadm/doveadm-who.c +index ecdbb88bc7..29172afba0 100644 +--- a/src/doveadm/doveadm-who.c ++++ b/src/doveadm/doveadm-who.c +@@ -8,7 +8,7 @@ + #include "hash.h" + #include "str.h" + #include "strescape.h" +-#include "master-service.h" ++#include "version.h" + #include "doveadm.h" + #include "doveadm-print.h" + #include "doveadm-who.h" +diff --git a/src/doveadm/dsync/dsync-ibc-stream.c b/src/doveadm/dsync/dsync-ibc-stream.c +index 957c569cee..472d1fc517 100644 +--- a/src/doveadm/dsync/dsync-ibc-stream.c ++++ b/src/doveadm/dsync/dsync-ibc-stream.c +@@ -10,7 +10,7 @@ + #include "ostream.h" + #include "str.h" + #include "strescape.h" +-#include "master-service.h" ++#include "version.h" + #include "mail-cache.h" + #include "mail-storage-private.h" + #include "dsync-serializer.h" +diff --git a/src/imap-urlauth-login/imap-urlauth-login.c b/src/imap-urlauth-login/imap-urlauth-login.c +index 23a76f6c81..68b4aab159 100644 +--- a/src/imap-urlauth-login/imap-urlauth-login.c ++++ b/src/imap-urlauth-login/imap-urlauth-login.c +@@ -8,7 +8,7 @@ + #include "ioloop.h" + #include "istream.h" + #include "ostream.h" +-#include "master-service.h" ++#include "version.h" + #include "auth-client.h" + #include "client-common.h" + +diff --git a/src/imap/imap-client-hibernate.c b/src/imap/imap-client-hibernate.c +index ade5d250ea..067b984cc0 100644 +--- a/src/imap/imap-client-hibernate.c ++++ b/src/imap/imap-client-hibernate.c +@@ -9,7 +9,7 @@ + #include "base64.h" + #include "str.h" + #include "strescape.h" +-#include "master-service.h" ++#include "version.h" + #include "compression.h" + #include "mailbox-watch.h" + #include "imap-state.h" +diff --git a/src/lib-doveadm/doveadm-client.c b/src/lib-doveadm/doveadm-client.c +index 47b09f677f..6d570169ec 100644 +--- a/src/lib-doveadm/doveadm-client.c ++++ b/src/lib-doveadm/doveadm-client.c +@@ -10,8 +10,8 @@ + #include "ostream-dot.h" + #include "str.h" + #include "strescape.h" ++#include "version.h" + #include "iostream-ssl.h" +-#include "master-service.h" + #include "doveadm-protocol.h" + #include "doveadm-client.h" + #include "dns-lookup.h" +diff --git a/src/lib-master/Makefile.am b/src/lib-master/Makefile.am +index d6fe251e43..8939f759de 100644 +--- a/src/lib-master/Makefile.am ++++ b/src/lib-master/Makefile.am +@@ -47,7 +47,6 @@ pkginc_lib_HEADERS = $(headers) + + test_programs = \ + test-event-stats \ +- test-master-service \ + test-master-service-settings + + noinst_PROGRAMS = $(test_programs) +@@ -71,10 +70,6 @@ test_event_stats_SOURCES = test-event-stats.c + test_event_stats_LDADD = $(test_libs) + test_event_stats_DEPENDENCIES = $(test_deps) + +-test_master_service_SOURCES = test-master-service.c +-test_master_service_LDADD = $(test_libs) +-test_master_service_DEPENDENCIES = $(test_deps) +- + test_master_service_settings_SOURCES = test-master-service-settings.c + test_master_service_settings_LDADD = $(test_libs) + test_master_service_settings_DEPENDENCIES = $(test_deps) +diff --git a/src/lib-master/master-service-settings.c b/src/lib-master/master-service-settings.c +index 71438c0c4f..4194c19fab 100644 +--- a/src/lib-master/master-service-settings.c ++++ b/src/lib-master/master-service-settings.c +@@ -11,6 +11,7 @@ + #include "eacces-error.h" + #include "env-util.h" + #include "execv-const.h" ++#include "version.h" + #include "settings.h" + #include "stats-client.h" + #include "master-service-private.h" +diff --git a/src/lib-master/master-service.c b/src/lib-master/master-service.c +index a18cb39dbe..8f2ccacee1 100644 +--- a/src/lib-master/master-service.c ++++ b/src/lib-master/master-service.c +@@ -2035,91 +2035,6 @@ void master_status_update(struct master_service *service) + master_status_send(service, important_update); + } + +-bool version_string_verify(const char *line, const char *service_name, +- unsigned int major_version) +-{ +- unsigned int minor_version; +- +- return version_string_verify_full(line, service_name, +- major_version, &minor_version); +-} +- +-bool version_string_verify_full(const char *line, const char *service_name, +- unsigned int major_version, +- unsigned int *minor_version_r) +-{ +- size_t service_name_len = strlen(service_name); +- bool ret; +- +- if (!str_begins(line, "VERSION\t", &line)) +- return FALSE; +- +- if (strncmp(line, service_name, service_name_len) != 0 || +- line[service_name_len] != '\t') +- return FALSE; +- line += service_name_len + 1; +- +- T_BEGIN { +- const char *p = strchr(line, '\t'); +- +- if (p == NULL) +- ret = FALSE; +- else { +- ret = str_uint_equals(t_strdup_until(line, p), +- major_version); +- if (str_to_uint(p+1, minor_version_r) < 0) +- ret = FALSE; +- } +- } T_END; +- return ret; +-} +- +-int version_cmp(const char *version1, const char *version2) +-{ +- unsigned int v1, v2; +- +- do { +- if (str_parse_uint(version1, &v1, &version1) < 0) +- i_unreached(); +- if (str_parse_uint(version2, &v2, &version2) < 0) +- i_unreached(); +- if (*version1 == '.') +- version1++; +- else +- i_assert(*version1 == '\0'); +- if (*version2 == '.') +- version2++; +- else +- i_assert(*version2 == '\0'); +- +- if (v1 < v2) +- return -1; +- if (v1 > v2) +- return 1; +- } while (*version1 != '\0' && *version2 != '\0'); +- +- if (*version1 != '\0') +- return 1; +- if (*version2 != '\0') +- return -1; +- return 0; +-} +- +-bool version_is_valid(const char *version) +-{ +- unsigned int i; +- +- for (i = 0; version[i] != '\0'; i++) { +- if (version[i] == '.') { +- if (i == 0 || version[i-1] == '.' || +- version[i+1] == '\0') +- return FALSE; +- } else if (version[i] < '0' || version[i] > '9') +- return FALSE; +- } +- return i > 0; +-} +- + void master_service_set_process_shutdown_filter(struct master_service *service, + struct event_filter *filter) + { +diff --git a/src/lib-master/master-service.h b/src/lib-master/master-service.h +index 1dca3a6403..6c13ddaca7 100644 +--- a/src/lib-master/master-service.h ++++ b/src/lib-master/master-service.h +@@ -308,21 +308,6 @@ void master_service_deinit(struct master_service **service); + */ + void master_service_deinit_forked(struct master_service **_service); + +-/* Returns TRUE if line contains compatible service name and major version. +- The line is expected to be in format: +- VERSION service_name major version minor version */ +-bool version_string_verify(const char *line, const char *service_name, +- unsigned int major_version); +-/* Same as version_string_verify(), but return the minor version. */ +-bool version_string_verify_full(const char *line, const char *service_name, +- unsigned int major_version, +- unsigned int *minor_version_r); +-/* Compare number[.number[...]] style version numbers. Assert-crash if the +- version strings are invalid. */ +-int version_cmp(const char *version1, const char *version2); +-/* Returns TRUE if version string is a valid number[.number[...]] string. */ +-bool version_is_valid(const char *version); +- + /* Sets process shutdown filter */ + void master_service_set_process_shutdown_filter(struct master_service *service, + struct event_filter *filter); +diff --git a/src/lib/Makefile.am b/src/lib/Makefile.am +index eb9b9ddda3..45a4f1160e 100644 +--- a/src/lib/Makefile.am ++++ b/src/lib/Makefile.am +@@ -197,6 +197,7 @@ liblib_la_SOURCES = \ + uri-util.c \ + utc-offset.c \ + utc-mktime.c \ ++ version.c \ + wildcard-match.c \ + write-full.c + +@@ -358,6 +359,7 @@ headers = \ + uri-util.h \ + utc-offset.h \ + utc-mktime.h \ ++ version.h \ + wildcard-match.h \ + write-full.h + +@@ -464,6 +466,7 @@ test_lib_SOURCES = \ + test-unichar.c \ + test-utc-mktime.c \ + test-uri.c \ ++ test-version.c \ + test-wildcard-match.c + + test_headers = \ +diff --git a/src/lib/test-lib.inc b/src/lib/test-lib.inc +index 2fceca2f9b..52259e4b96 100644 +--- a/src/lib/test-lib.inc ++++ b/src/lib/test-lib.inc +@@ -109,4 +109,5 @@ TEST(test_time_util) + TEST(test_unichar) + TEST(test_uri) + TEST(test_utc_mktime) ++TEST(test_version) + TEST(test_wildcard_match) +diff --git a/src/lib-master/test-master-service.c b/src/lib/test-version.c +similarity index 83% +rename from src/lib-master/test-master-service.c +rename to src/lib/test-version.c +index f30b250628..e938333f08 100644 +--- a/src/lib-master/test-master-service.c ++++ b/src/lib/test-version.c +@@ -1,7 +1,7 @@ +-/* Copyright (c) 2023 Dovecot authors, see the included COPYING file */ ++/* Copyright (c) 2023-2025 Dovecot authors, see the included COPYING file */ + +-#include "lib.h" +-#include "master-service.h" ++#include "test-lib.h" ++#include "version.h" + #include "test-common.h" + + static void test_version_is_valid(void) +@@ -56,12 +56,8 @@ static void test_version_cmp(void) + test_end(); + } + +-int main(void) ++void test_version(void) + { +- static void (*const test_functions[])(void) = { +- test_version_is_valid, +- test_version_cmp, +- NULL +- }; +- return test_run(test_functions); ++ test_version_is_valid(); ++ test_version_cmp(); + } +diff --git a/src/lib/version.c b/src/lib/version.c +new file mode 100644 +index 0000000000..f8bec420ab +--- /dev/null ++++ b/src/lib/version.c +@@ -0,0 +1,90 @@ ++/* Copyright (c) 2005-2025 Dovecot authors, see the included COPYING file */ ++ ++#include "lib.h" ++#include "version.h" ++ ++bool version_string_verify(const char *line, const char *service_name, ++ unsigned int major_version) ++{ ++ unsigned int minor_version; ++ ++ return version_string_verify_full(line, service_name, ++ major_version, &minor_version); ++} ++ ++bool version_string_verify_full(const char *line, const char *service_name, ++ unsigned int major_version, ++ unsigned int *minor_version_r) ++{ ++ size_t service_name_len = strlen(service_name); ++ bool ret; ++ ++ if (!str_begins(line, "VERSION\t", &line)) ++ return FALSE; ++ ++ if (strncmp(line, service_name, service_name_len) != 0 || ++ line[service_name_len] != '\t') ++ return FALSE; ++ line += service_name_len + 1; ++ ++ T_BEGIN { ++ const char *p = strchr(line, '\t'); ++ ++ if (p == NULL) ++ ret = FALSE; ++ else { ++ ret = str_uint_equals(t_strdup_until(line, p), ++ major_version); ++ if (str_to_uint(p+1, minor_version_r) < 0) ++ ret = FALSE; ++ } ++ } T_END; ++ return ret; ++} ++ ++int version_cmp(const char *version1, const char *version2) ++{ ++ unsigned int v1, v2; ++ ++ do { ++ if (str_parse_uint(version1, &v1, &version1) < 0) ++ i_unreached(); ++ if (str_parse_uint(version2, &v2, &version2) < 0) ++ i_unreached(); ++ if (*version1 == '.') ++ version1++; ++ else ++ i_assert(*version1 == '\0'); ++ if (*version2 == '.') ++ version2++; ++ else ++ i_assert(*version2 == '\0'); ++ ++ if (v1 < v2) ++ return -1; ++ if (v1 > v2) ++ return 1; ++ } while (*version1 != '\0' && *version2 != '\0'); ++ ++ if (*version1 != '\0') ++ return 1; ++ if (*version2 != '\0') ++ return -1; ++ return 0; ++} ++ ++bool version_is_valid(const char *version) ++{ ++ unsigned int i; ++ ++ for (i = 0; version[i] != '\0'; i++) { ++ if (version[i] == '.') { ++ if (i == 0 || version[i-1] == '.' || ++ version[i+1] == '\0') ++ return FALSE; ++ } else if (version[i] < '0' || version[i] > '9') ++ return FALSE; ++ } ++ return i > 0; ++} ++ +diff --git a/src/lib/version.h b/src/lib/version.h +new file mode 100644 +index 0000000000..f305f7f4e1 +--- /dev/null ++++ b/src/lib/version.h +@@ -0,0 +1,19 @@ ++#ifndef VERSION_H ++#define VERSION_H ++ ++/* Returns TRUE if line contains compatible service name and major version. ++ The line is expected to be in format: ++ VERSION service_name major version minor version */ ++bool version_string_verify(const char *line, const char *service_name, ++ unsigned int major_version); ++/* Same as version_string_verify(), but return the minor version. */ ++bool version_string_verify_full(const char *line, const char *service_name, ++ unsigned int major_version, ++ unsigned int *minor_version_r); ++/* Compare number[.number[...]] style version numbers. Assert-crash if the ++ version strings are invalid. */ ++int version_cmp(const char *version1, const char *version2); ++/* Returns TRUE if version string is a valid number[.number[...]] string. */ ++bool version_is_valid(const char *version); ++ ++#endif +diff --git a/src/util/script-login.c b/src/util/script-login.c +index ed356747dc..4df7f23174 100644 +--- a/src/util/script-login.c ++++ b/src/util/script-login.c +@@ -7,6 +7,7 @@ + #include "restrict-access.h" + #include "str.h" + #include "strescape.h" ++#include "version.h" + #include "settings-parser.h" + #include "mail-storage-service.h" + #include "master-interface.h" +diff --git a/src/util/script.c b/src/util/script.c +index 5838cdad31..391b87bc06 100644 +--- a/src/util/script.c ++++ b/src/util/script.c +@@ -8,6 +8,7 @@ + #include "execv-const.h" + #include "write-full.h" + #include "restrict-access.h" ++#include "version.h" + #include "master-interface.h" + #include "master-service.h" + +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0011-lib-mail-Introduce-struct-message_part_data_limits-a.patch dovecot-2.4.1+dfsg1/debian/patches/0011-lib-mail-Introduce-struct-message_part_data_limits-a.patch --- dovecot-2.4.1+dfsg1/debian/patches/0011-lib-mail-Introduce-struct-message_part_data_limits-a.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0011-lib-mail-Introduce-struct-message_part_data_limits-a.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,323 @@ +From 3e8ffe61ee5719c05e68d9be7f913dc3cd2a0e6c Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 15:54:48 +0200 +Subject: [PATCH 11/14] lib-mail: Introduce struct message_part_data_limits and + thread it through parsers + +Add an empty struct message_part_data_limits and pass it by pointer through +message_part_data_parse_from_header() and message_part_envelope_parse_from_header(). + +No behaviour change: the struct has no fields yet and no limits are applied. +Subsequent commits add the individual limit fields and enforcement. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/lib-imap/test-imap-bodystructure.c | 3 ++- + src/lib-imap/test-imap-envelope.c | 4 +++- + src/lib-mail/message-part-data.c | 5 ++++- + src/lib-mail/message-part-data.h | 7 +++++++ + src/lib-mail/test-message-decoder.c | 16 ++++++++++++---- + src/lib-mail/test-message-parser.c | 3 ++- + src/lib-mail/test-message-part-data.c | 3 ++- + src/lib-storage/index/index-mail-headers.c | 10 +++++++--- + src/lib-storage/index/index-mail.c | 21 +++++++++++++++++---- + src/lib-storage/index/index-mail.h | 2 ++ + 10 files changed, 58 insertions(+), 16 deletions(-) + +diff --git a/src/lib-imap/test-imap-bodystructure.c b/src/lib-imap/test-imap-bodystructure.c +index 40c8a8c6ec..abe35ac6f3 100644 +--- a/src/lib-imap/test-imap-bodystructure.c ++++ b/src/lib-imap/test-imap-bodystructure.c +@@ -398,10 +398,11 @@ msg_parse(pool_t pool, const char *message, unsigned int max_nested_mime_parts, + + input = i_stream_create_from_data(message, strlen(message)); + parser = message_parser_init(pool, input, &parser_set); ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + while ((ret = message_parser_parse_next_block(parser, &block)) > 0) { + if (parse_bodystructure) { + message_part_data_parse_from_header(pool, block.part, +- block.hdr); ++ &limits, block.hdr); + } + } + test_assert(ret < 0); +diff --git a/src/lib-imap/test-imap-envelope.c b/src/lib-imap/test-imap-envelope.c +index c9b92b4be2..498c6ac9f4 100644 +--- a/src/lib-imap/test-imap-envelope.c ++++ b/src/lib-imap/test-imap-envelope.c +@@ -132,9 +132,11 @@ msg_parse(pool_t pool, const char *message) + + input = i_stream_create_from_data(message, strlen(message)); + parser = message_parser_init(pool, input, &parser_set); ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + while ((ret = message_parser_parse_next_block(parser, &block)) > 0) { + i_assert(block.part->parent == NULL); +- message_part_envelope_parse_from_header(pool, &envlp, block.hdr); ++ message_part_envelope_parse_from_header(pool, &envlp, &limits, ++ block.hdr); + } + test_assert(ret < 0); + +diff --git a/src/lib-mail/message-part-data.c b/src/lib-mail/message-part-data.c +index acd50e5210..c302fe568b 100644 +--- a/src/lib-mail/message-part-data.c ++++ b/src/lib-mail/message-part-data.c +@@ -173,6 +173,7 @@ envelope_get_field(const char *name) + + void message_part_envelope_parse_from_header(pool_t pool, + struct message_part_envelope **data, ++ struct message_part_data_limits *limits ATTR_UNUSED, + struct message_header_line *hdr) + { + struct message_part_envelope *d; +@@ -480,6 +481,7 @@ parse_content_header(struct message_part_data *data, + + void message_part_data_parse_from_header(pool_t pool, + struct message_part *part, ++ struct message_part_data_limits *limits, + struct message_header_line *hdr) + { + struct message_part_data *part_data; +@@ -525,7 +527,8 @@ void message_part_data_parse_from_header(pool_t pool, + + if (parent_rfc822) { + /* message/rfc822, we need the envelope */ +- message_part_envelope_parse_from_header(pool, &part_data->envelope, hdr); ++ message_part_envelope_parse_from_header(pool, &part_data->envelope, ++ limits, hdr); + } + } + +diff --git a/src/lib-mail/message-part-data.h b/src/lib-mail/message-part-data.h +index 9513bfc2f0..1514f0daf2 100644 +--- a/src/lib-mail/message-part-data.h ++++ b/src/lib-mail/message-part-data.h +@@ -8,6 +8,11 @@ + + struct message_header_line; + ++struct message_part_data_limits { ++}; ++ ++#define MESSAGE_PART_DATA_LIMITS_INIT { } ++ + struct message_part_param { + const char *name; + const char *value; +@@ -91,11 +96,13 @@ bool message_part_is_attachment(struct message_part *part, + /* Update envelope data based from given header field */ + void message_part_envelope_parse_from_header(pool_t pool, + struct message_part_envelope **_data, ++ struct message_part_data_limits *limits, + struct message_header_line *hdr); + + /* Parse a single header. Note that this modifies part->context. */ + void message_part_data_parse_from_header(pool_t pool, + struct message_part *part, ++ struct message_part_data_limits *limits, + struct message_header_line *hdr); + + #endif +diff --git a/src/lib-mail/test-message-decoder.c b/src/lib-mail/test-message-decoder.c +index 83c267187b..ad212c8546 100644 +--- a/src/lib-mail/test-message-decoder.c ++++ b/src/lib-mail/test-message-decoder.c +@@ -260,8 +260,10 @@ static void test_message_decoder_content_transfer_encoding(void) + parser = message_parser_init(pool, istream, &parser_set); + decoder = message_decoder_init(NULL, 0); + ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + while ((ret = message_parser_parse_next_block(parser, &input)) > 0) { +- message_part_data_parse_from_header(pool, input.part, input.hdr); ++ message_part_data_parse_from_header(pool, input.part, &limits, ++ input.hdr); + if (message_decoder_decode_next_block(decoder, &input, &output) && + output.hdr == NULL && output.size > 0) + str_append_data(str_out, output.data, output.size); +@@ -349,8 +351,10 @@ static void test_message_decoder_invalid_content_transfer_encoding(void) + parser = message_parser_init(pool, istream, &parser_set); + decoder = message_decoder_init(NULL, 0); + ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + while ((ret = message_parser_parse_next_block(parser, &input)) > 0) { +- message_part_data_parse_from_header(pool, input.part, input.hdr); ++ message_part_data_parse_from_header(pool, input.part, &limits, ++ input.hdr); + if (input.hdr != NULL && + strcasecmp(input.hdr->name, "content-transfer-encoding") == 0) { + enum message_cte cte = message_decoder_parse_cte(input.hdr); +@@ -480,8 +484,10 @@ UNICODE_REPLACEMENT_CHAR_UTF8; + parser = message_parser_init(pool, istream, &parser_set); + decoder = message_decoder_init(NULL, 0); + ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + while ((ret = message_parser_parse_next_block(parser, &input)) > 0) { +- message_part_data_parse_from_header(pool, input.part, input.hdr); ++ message_part_data_parse_from_header(pool, input.part, &limits, ++ input.hdr); + if (message_decoder_decode_next_block(decoder, &input, &output) && + output.hdr == NULL && output.size > 0) + str_append_data(str_out, output.data, output.size); +@@ -531,8 +537,10 @@ static void test_message_decoder_charset_mime_part_change(void) + parser = message_parser_init(pool, istream, &parser_set); + decoder = message_decoder_init(NULL, 0); + ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + while ((ret = message_parser_parse_next_block(parser, &input)) > 0) { +- message_part_data_parse_from_header(pool, input.part, input.hdr); ++ message_part_data_parse_from_header(pool, input.part, &limits, ++ input.hdr); + if (message_decoder_decode_next_block(decoder, &input, &output) && + output.hdr == NULL && output.size > 0) + str_append_data(str_out, output.data, output.size); +diff --git a/src/lib-mail/test-message-parser.c b/src/lib-mail/test-message-parser.c +index 3b94034c09..b66e0a664e 100644 +--- a/src/lib-mail/test-message-parser.c ++++ b/src/lib-mail/test-message-parser.c +@@ -51,12 +51,13 @@ static int message_parse_stream(pool_t pool, struct istream *input, + struct message_parser_ctx *parser; + struct message_block block; + ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + i_zero(&block); + parser = message_parser_init(pool, input, set); + while ((ret = message_parser_parse_next_block(parser, &block)) > 0) + if (parse_data) + message_part_data_parse_from_header(pool, block.part, +- block.hdr); ++ &limits, block.hdr); + message_parser_deinit(&parser, parts_r); + test_assert(input->stream_errno == 0); + return ret; +diff --git a/src/lib-mail/test-message-part-data.c b/src/lib-mail/test-message-part-data.c +index 470d739b5e..63228d1b3d 100644 +--- a/src/lib-mail/test-message-part-data.c ++++ b/src/lib-mail/test-message-part-data.c +@@ -14,12 +14,13 @@ static int message_parse_stream(pool_t pool, struct istream *input, + struct message_parser_ctx *parser; + struct message_block block; + ++ struct message_part_data_limits limits = MESSAGE_PART_DATA_LIMITS_INIT; + i_zero(&block); + parser = message_parser_init(pool, input, set); + while ((ret = message_parser_parse_next_block(parser, &block)) > 0) + if (parse_data) + message_part_data_parse_from_header(pool, block.part, +- block.hdr); ++ &limits, block.hdr); + message_parser_deinit(&parser, parts_r); + test_assert(input->stream_errno == 0); + return ret; +diff --git a/src/lib-storage/index/index-mail-headers.c b/src/lib-storage/index/index-mail-headers.c +index a9f4d55c0d..9d608acc61 100644 +--- a/src/lib-storage/index/index-mail-headers.c ++++ b/src/lib-storage/index/index-mail-headers.c +@@ -308,12 +308,15 @@ void index_mail_parse_header(struct message_part *part, + if (data->save_bodystructure_header && + !data->parsed_bodystructure_header) { + i_assert(part != NULL); +- message_part_data_parse_from_header(mail->mail.data_pool, part, hdr); ++ message_part_data_parse_from_header(mail->mail.data_pool, part, ++ &mail->data.part_data_limits, ++ hdr); + } + + if (data->save_envelope) { + message_part_envelope_parse_from_header(mail->mail.data_pool, +- &data->envelope_data, hdr); ++ &data->envelope_data, ++ &mail->data.part_data_limits, hdr); + + if (hdr == NULL) + index_mail_parse_finish_imap_envelope(mail); +@@ -510,7 +513,8 @@ imap_envelope_parse_callback(struct message_header_line *hdr, + struct index_mail *mail) + { + message_part_envelope_parse_from_header(mail->mail.data_pool, +- &mail->data.envelope_data, hdr); ++ &mail->data.envelope_data, ++ &mail->data.part_data_limits, hdr); + + if (hdr == NULL) + index_mail_parse_finish_imap_envelope(mail); +diff --git a/src/lib-storage/index/index-mail.c b/src/lib-storage/index/index-mail.c +index 70060ba4f7..3911f6db1c 100644 +--- a/src/lib-storage/index/index-mail.c ++++ b/src/lib-storage/index/index-mail.c +@@ -696,11 +696,16 @@ void index_mail_cache_pop3_data(struct mail *_mail, + &order, sizeof(order)); + } + ++struct parse_bodystructure_ctx { ++ pool_t pool; ++ struct message_part_data_limits *limits; ++}; ++ + static void parse_bodystructure_part_header(struct message_part *part, + struct message_header_line *hdr, +- pool_t pool) ++ struct parse_bodystructure_ctx *ctx) + { +- message_part_data_parse_from_header(pool, part, hdr); ++ message_part_data_parse_from_header(ctx->pool, part, ctx->limits, hdr); + } + + static bool want_plain_bodystructure_cached(struct index_mail *mail) +@@ -1317,9 +1322,13 @@ static int index_mail_parse_body(struct index_mail *mail, + /* bodystructure header is parsed, we want the body's mime + headers too */ + i_assert(data->parsed_bodystructure_header); ++ struct parse_bodystructure_ctx bsctx = { ++ .pool = mail->mail.data_pool, ++ .limits = &mail->data.part_data_limits, ++ }; + message_parser_parse_body(data->parser_ctx, + parse_bodystructure_part_header, +- mail->mail.data_pool); ++ &bsctx); + } else { + message_parser_parse_body(data->parser_ctx, + *null_message_part_header_callback, NULL); +@@ -1860,6 +1869,8 @@ static void index_mail_init_data(struct index_mail *mail) + data->received_date = (time_t)-1; + data->sent_date.time = (uint32_t)-1; + data->dont_cache_field_idx = UINT_MAX; ++ data->part_data_limits = (struct message_part_data_limits) ++ MESSAGE_PART_DATA_LIMITS_INIT; + + data->wanted_fields = mail->mail.wanted_fields; + if (mail->mail.wanted_headers != NULL) { +@@ -2321,7 +2332,9 @@ void index_mail_cache_parse_continue(struct mail *_mail) + mail->data.header_parsed = TRUE; + } else { + message_part_data_parse_from_header(mail->mail.data_pool, +- block.part, block.hdr); ++ block.part, ++ &mail->data.part_data_limits, ++ block.hdr); + } + } + } +diff --git a/src/lib-storage/index/index-mail.h b/src/lib-storage/index/index-mail.h +index 9e2159898e..d1625b3f6e 100644 +--- a/src/lib-storage/index/index-mail.h ++++ b/src/lib-storage/index/index-mail.h +@@ -2,6 +2,7 @@ + #define INDEX_MAIL_H + + #include "message-size.h" ++#include "message-part-data.h" + #include "mail-cache.h" + #include "mail-storage-private.h" + +@@ -84,6 +85,7 @@ struct index_mail_data { + const char *envelope, *body, *bodystructure, *guid, *filename; + const char *from_envelope, *body_snippet; + struct message_part_envelope *envelope_data; ++ struct message_part_data_limits part_data_limits; + + uint32_t cache_flags; + uint64_t modseq, pvt_modseq; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0012-config-doveconf-dF-Add-dovecot_storage_version.patch dovecot-2.4.1+dfsg1/debian/patches/0012-config-doveconf-dF-Add-dovecot_storage_version.patch --- dovecot-2.4.1+dfsg1/debian/patches/0012-config-doveconf-dF-Add-dovecot_storage_version.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0012-config-doveconf-dF-Add-dovecot_storage_version.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,63 @@ +From ba5efe2541fa89e9cfe4e7fae4526e0f5dc18a9c Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Wed, 9 Apr 2025 14:11:42 +0300 +Subject: [PATCH 12/12] config: doveconf -dF - Add dovecot_storage_version + +--- + src/config/config-parser.c | 14 ++++++++++++++ + src/config/config-parser.h | 3 +++ + src/config/doveconf.c | 2 ++ + 3 files changed, 19 insertions(+) + +Index: trixie/src/config/config-parser.c +=================================================================== +--- trixie.orig/src/config/config-parser.c ++++ trixie/src/config/config-parser.c +@@ -2958,6 +2958,20 @@ int config_parse_file(const char *path, + hook_config_parser_begin(&ctx); + } T_END; + ++ if ((flags & CONFIG_PARSE_FLAG_DEFAULT_VERSION) != 0) { ++ /* Use default settings. Set dovecot_storage_version to the ++ latest version, so it won't cause a failure. ++ ++ When building from git we don't know the latest version, so ++ just use 9999. The version validity checks are disabled for ++ git builds, so this should work. */ ++ const char *version = version_is_valid(DOVECOT_VERSION) ? ++ DOVECOT_VERSION : "9999"; ++ if (config_apply_exact_line(&ctx, NULL, "dovecot_storage_version", ++ version) < 0) ++ i_panic("Couldn't set default dovecot_storage_version: %s", ctx.error); ++ } ++ + internal.path = "Internal config_import"; + ctx.cur_input->input = config_import == NULL ? + i_stream_create_from_data("", 0) : +Index: trixie/src/config/config-parser.h +=================================================================== +--- trixie.orig/src/config/config-parser.h ++++ trixie/src/config/config-parser.h +@@ -40,6 +40,9 @@ enum config_parse_flags { + CONFIG_PARSE_FLAG_MERGE_DEFAULT_FILTERS = BIT(9), + /* Ignore unknown settings in the config file. */ + CONFIG_PARSE_FLAG_IGNORE_UNKNOWN = BIT(10), ++ /* Executing "doveconf -dF" - add default dovecot_storage_version ++ setting. */ ++ CONFIG_PARSE_FLAG_DEFAULT_VERSION = BIT(11), + }; + + /* Used to track changed settings for a setting_parser_info. Initially only +Index: trixie/src/config/doveconf.c +=================================================================== +--- trixie.orig/src/config/doveconf.c ++++ trixie/src/config/doveconf.c +@@ -1234,6 +1234,8 @@ int main(int argc, char *argv[]) + flags |= CONFIG_PARSE_FLAG_DELAY_ERRORS; + if (exec_args == NULL) + flags |= CONFIG_PARSE_FLAG_EXTERNAL_HOOKS; ++ else if (dump_defaults) ++ flags |= CONFIG_PARSE_FLAG_DEFAULT_VERSION; + T_BEGIN { + ret = config_parse_file(dump_defaults ? NULL : config_path, + flags, have_dump_filter ? &dump_filter : NULL, diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0012-lib-mail-Limit-total-address-count-per-message-to-10.patch dovecot-2.4.1+dfsg1/debian/patches/0012-lib-mail-Limit-total-address-count-per-message-to-10.patch --- dovecot-2.4.1+dfsg1/debian/patches/0012-lib-mail-Limit-total-address-count-per-message-to-10.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0012-lib-mail-Limit-total-address-count-per-message-to-10.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,71 @@ +From f42717983640b9df13ce3214cec311102d9dcb6b Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 15:55:48 +0200 +Subject: [PATCH 12/14] lib-mail: Limit total address count per message to 100 + 000 + +A message with millions of addresses across all its envelope headers can +exhaust memory when parsed (each struct message_address is ~100 bytes +regardless of whether the raw address is only a few bytes long). + +Add remaining_addresses to struct message_part_data_limits, initialised to +MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES (100 000). Pass the remaining budget +as max_addresses to message_address_parse_full() so parsing stops at the +limit, then deduct the actual count parsed from the budget. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/lib-mail/message-part-data.c | 7 +++++-- + src/lib-mail/message-part-data.h | 6 +++++- + 2 files changed, 10 insertions(+), 3 deletions(-) + +diff --git a/src/lib-mail/message-part-data.c b/src/lib-mail/message-part-data.c +index c302fe568b..24e1a31964 100644 +--- a/src/lib-mail/message-part-data.c ++++ b/src/lib-mail/message-part-data.c +@@ -173,7 +173,7 @@ envelope_get_field(const char *name) + + void message_part_envelope_parse_from_header(pool_t pool, + struct message_part_envelope **data, +- struct message_part_data_limits *limits ATTR_UNUSED, ++ struct message_part_data_limits *limits, + struct message_header_line *hdr) + { + struct message_part_envelope *d; +@@ -238,9 +238,12 @@ void message_part_envelope_parse_from_header(pool_t pool, + if (addr_p != NULL) { + message_address_parse_full(pool, hdr->full_value, + hdr->full_value_len, +- UINT_MAX, ++ limits->remaining_addresses, + MESSAGE_ADDRESS_PARSE_FLAG_FILL_MISSING, + &new_addr); ++ i_assert(new_addr.count <= limits->remaining_addresses); ++ limits->remaining_addresses -= new_addr.count; ++ + /* Merge multiple headers the same as if they were comma + separated in a single line. This is better from security + point of view, because attacker could intentionally write +diff --git a/src/lib-mail/message-part-data.h b/src/lib-mail/message-part-data.h +index 1514f0daf2..af09ea715e 100644 +--- a/src/lib-mail/message-part-data.h ++++ b/src/lib-mail/message-part-data.h +@@ -8,10 +8,14 @@ + + struct message_header_line; + ++#define MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES 100000 ++ + struct message_part_data_limits { ++ unsigned int remaining_addresses; + }; + +-#define MESSAGE_PART_DATA_LIMITS_INIT { } ++#define MESSAGE_PART_DATA_LIMITS_INIT \ ++ { MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES } + + struct message_part_param { + const char *name; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0013-lib-mail-Limit-total-Content-Language-tag-count-per-.patch dovecot-2.4.1+dfsg1/debian/patches/0013-lib-mail-Limit-total-Content-Language-tag-count-per-.patch --- dovecot-2.4.1+dfsg1/debian/patches/0013-lib-mail-Limit-total-Content-Language-tag-count-per-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0013-lib-mail-Limit-total-Content-Language-tag-count-per-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,99 @@ +From 5292d2277474680902437ec2a23c684041cdab61 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 15:56:37 +0200 +Subject: [PATCH 13/14] lib-mail: Limit total Content-Language tag count per + message to 100 000 + +A multipart message with many MIME parts each containing many language tags +can exhaust memory: the per-part RFC 2231 parser had no cumulative limit. + +Add remaining_language_tags to struct message_part_data_limits, initialised +to MESSAGE_PART_DATA_MAX_TOTAL_LANGUAGE_TAGS (100 000). Break out of the +tag-parsing loop in parse_content_language() once the budget reaches zero. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/lib-mail/message-part-data.c | 15 ++++++++++----- + src/lib-mail/message-part-data.h | 5 ++++- + 2 files changed, 14 insertions(+), 6 deletions(-) + +diff --git a/src/lib-mail/message-part-data.c b/src/lib-mail/message-part-data.c +index 24e1a31964..2eed994e45 100644 +--- a/src/lib-mail/message-part-data.c ++++ b/src/lib-mail/message-part-data.c +@@ -380,7 +380,8 @@ parse_content_disposition(struct message_part_data *data, + + static void + parse_content_language(struct message_part_data *data, +- pool_t pool, const unsigned char *value, size_t value_len) ++ pool_t pool, const unsigned char *value, size_t value_len, ++ struct message_part_data_limits *limits) + { + struct rfc822_parser_context parser; + ARRAY_TYPE(const_string) langs; +@@ -398,8 +399,11 @@ parse_content_language(struct message_part_data *data, + + rfc822_skip_lwsp(&parser); + while (rfc822_parse_atom(&parser, str) >= 0) { +- const char *lang = p_strdup(pool, str_c(str)); ++ if (limits->remaining_language_tags == 0) ++ break; ++ limits->remaining_language_tags--; + ++ const char *lang = p_strdup(pool, str_c(str)); + array_push_back(&langs, &lang); + str_truncate(str, 0); + +@@ -419,7 +423,8 @@ parse_content_language(struct message_part_data *data, + + static void + parse_content_header(struct message_part_data *data, +- pool_t pool, struct message_header_line *hdr) ++ pool_t pool, struct message_header_line *hdr, ++ struct message_part_data_limits *limits) + { + const char *name = hdr->name + strlen("Content-"); + +@@ -459,7 +464,7 @@ parse_content_header(struct message_part_data *data, + if (strcasecmp(name, "Language") == 0 && + data->content_language == NULL) { + parse_content_language(data, pool, +- hdr->full_value, hdr->full_value_len); ++ hdr->full_value, hdr->full_value_len, limits); + } else if (strcasecmp(name, "Location") == 0 && + data->content_location == NULL) { + data->content_location = +@@ -524,7 +529,7 @@ void message_part_data_parse_from_header(pool_t pool, + + if (str_begins_icase_with(hdr->name, "Content-")) { + T_BEGIN { +- parse_content_header(part_data, pool, hdr); ++ parse_content_header(part_data, pool, hdr, limits); + } T_END; + } + +diff --git a/src/lib-mail/message-part-data.h b/src/lib-mail/message-part-data.h +index af09ea715e..7f1f9c3e1f 100644 +--- a/src/lib-mail/message-part-data.h ++++ b/src/lib-mail/message-part-data.h +@@ -9,13 +9,16 @@ + struct message_header_line; + + #define MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES 100000 ++#define MESSAGE_PART_DATA_MAX_TOTAL_LANGUAGE_TAGS 100000 + + struct message_part_data_limits { + unsigned int remaining_addresses; ++ unsigned int remaining_language_tags; + }; + + #define MESSAGE_PART_DATA_LIMITS_INIT \ +- { MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES } ++ { MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES, \ ++ MESSAGE_PART_DATA_MAX_TOTAL_LANGUAGE_TAGS } + + struct message_part_param { + const char *name; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/0014-lib-mail-Limit-total-MIME-parameter-count-per-messag.patch dovecot-2.4.1+dfsg1/debian/patches/0014-lib-mail-Limit-total-MIME-parameter-count-per-messag.patch --- dovecot-2.4.1+dfsg1/debian/patches/0014-lib-mail-Limit-total-MIME-parameter-count-per-messag.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/0014-lib-mail-Limit-total-MIME-parameter-count-per-messag.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,130 @@ +From 6a71e37c3edae89cd446a1bb8b1f7137b5a25754 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Fri, 17 Apr 2026 15:57:24 +0200 +Subject: [PATCH 14/14] lib-mail: Limit total MIME parameter count per message + to 200 000 + +Content-Type and Content-Disposition parameters are both parsed through +parse_mime_parameters(). A message with many MIME parts each having many +parameters can accumulate millions of struct message_part_param entries. + +Add remaining_mime_params to struct message_part_data_limits (combined +budget for both Content-Type and Content-Disposition), initialised to +MESSAGE_PART_DATA_MAX_TOTAL_MIME_PARAMS (200 000). Cap params_count to the +remaining budget before allocating and deduct accordingly. + +Co-Authored-By: Claude Sonnet 4.6 +--- + src/lib-mail/message-part-data.c | 21 ++++++++++++++------- + src/lib-mail/message-part-data.h | 6 +++++- + 2 files changed, 19 insertions(+), 8 deletions(-) + +diff --git a/src/lib-mail/message-part-data.c b/src/lib-mail/message-part-data.c +index 2eed994e45..e0a3676aae 100644 +--- a/src/lib-mail/message-part-data.c ++++ b/src/lib-mail/message-part-data.c +@@ -263,7 +263,8 @@ void message_part_envelope_parse_from_header(pool_t pool, + static void + parse_mime_parameters(struct rfc822_parser_context *parser, + pool_t pool, const struct message_part_param **params_r, +- unsigned int *params_count_r) ++ unsigned int *params_count_r, ++ struct message_part_data_limits *limits) + { + const char *const *results; + struct message_part_param *params; +@@ -275,6 +276,10 @@ parse_mime_parameters(struct rfc822_parser_context *parser, + i_assert((params_count % 2) == 0); + params_count /= 2; + ++ if (params_count > limits->remaining_mime_params) ++ params_count = limits->remaining_mime_params; ++ limits->remaining_mime_params -= params_count; ++ + if (params_count > 0) { + params = p_new(pool, struct message_part_param, params_count); + for (i = 0; i < params_count; i++) { +@@ -289,7 +294,8 @@ parse_mime_parameters(struct rfc822_parser_context *parser, + + static void + parse_content_type(struct message_part_data *data, +- pool_t pool, struct message_header_line *hdr) ++ pool_t pool, struct message_header_line *hdr, ++ struct message_part_data_limits *limits) + { + struct rfc822_parser_context parser; + string_t *str; +@@ -332,7 +338,7 @@ parse_content_type(struct message_part_data *data, + + parse_mime_parameters(&parser, pool, + &data->content_type_params, +- &data->content_type_params_count); ++ &data->content_type_params_count, limits); + rfc822_parser_deinit(&parser); + } + +@@ -357,7 +363,8 @@ parse_content_transfer_encoding(struct message_part_data *data, + + static void + parse_content_disposition(struct message_part_data *data, +- pool_t pool, struct message_header_line *hdr) ++ pool_t pool, struct message_header_line *hdr, ++ struct message_part_data_limits *limits) + { + struct rfc822_parser_context parser; + string_t *str; +@@ -374,7 +381,7 @@ parse_content_disposition(struct message_part_data *data, + + parse_mime_parameters(&parser, pool, + &data->content_disposition_params, +- &data->content_disposition_params_count); ++ &data->content_disposition_params_count, limits); + rfc822_parser_deinit(&parser); + } + +@@ -453,7 +460,7 @@ parse_content_header(struct message_part_data *data, + case 't': + case 'T': + if (strcasecmp(name, "Type") == 0 && data->content_type == NULL) +- parse_content_type(data, pool, hdr); ++ parse_content_type(data, pool, hdr, limits); + else if (strcasecmp(name, "Transfer-Encoding") == 0 && + data->content_transfer_encoding == NULL) + parse_content_transfer_encoding(data, pool, hdr); +@@ -482,7 +489,7 @@ parse_content_header(struct message_part_data *data, + hdr->full_value_len); + else if (strcasecmp(name, "Disposition") == 0 && + data->content_disposition_params == NULL) +- parse_content_disposition(data, pool, hdr); ++ parse_content_disposition(data, pool, hdr, limits); + break; + } + } +diff --git a/src/lib-mail/message-part-data.h b/src/lib-mail/message-part-data.h +index 7f1f9c3e1f..f439926c90 100644 +--- a/src/lib-mail/message-part-data.h ++++ b/src/lib-mail/message-part-data.h +@@ -10,15 +10,19 @@ struct message_header_line; + + #define MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES 100000 + #define MESSAGE_PART_DATA_MAX_TOTAL_LANGUAGE_TAGS 100000 ++/* Combined limit for Content-Type + Content-Disposition parameters */ ++#define MESSAGE_PART_DATA_MAX_TOTAL_MIME_PARAMS 200000 + + struct message_part_data_limits { + unsigned int remaining_addresses; + unsigned int remaining_language_tags; ++ unsigned int remaining_mime_params; + }; + + #define MESSAGE_PART_DATA_LIMITS_INIT \ + { MESSAGE_PART_DATA_MAX_TOTAL_ADDRESSES, \ +- MESSAGE_PART_DATA_MAX_TOTAL_LANGUAGE_TAGS } ++ MESSAGE_PART_DATA_MAX_TOTAL_LANGUAGE_TAGS, \ ++ MESSAGE_PART_DATA_MAX_TOTAL_MIME_PARAMS } + + struct message_part_param { + const char *name; +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/lib-mail-istream-header-filter-Fix-potential-assert-.patch dovecot-2.4.1+dfsg1/debian/patches/lib-mail-istream-header-filter-Fix-potential-assert-.patch --- dovecot-2.4.1+dfsg1/debian/patches/lib-mail-istream-header-filter-Fix-potential-assert-.patch 1970-01-01 00:00:00.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/lib-mail-istream-header-filter-Fix-potential-assert-.patch 2026-09-16 19:06:35.000000000 +0000 @@ -0,0 +1,84 @@ +From 3ff6022baa757583faf579251fc4033f373d4244 Mon Sep 17 00:00:00 2001 +From: Timo Sirainen +Date: Tue, 2 Sep 2025 13:28:44 +0300 +Subject: [PATCH] lib-mail: istream-header-filter - Fix potential assert-crash + +In some situations multiple snapshots were created, which broke the logic +of using a single snapshot_pending boolean. Changed it to refcount. + +Fixes: +Panic: file istream-header-filter.c: line 665 (i_stream_header_filter_snapshot_free): assertion failed: (snapshot->mstream->snapshot_pending) +--- + src/lib-mail/istream-header-filter.c | 14 +++++++------- + 1 file changed, 7 insertions(+), 7 deletions(-) + +diff --git a/src/lib-mail/istream-header-filter.c b/src/lib-mail/istream-header-filter.c +index ee94e4f82b..2d938bdaad 100644 +--- a/src/lib-mail/istream-header-filter.c ++++ b/src/lib-mail/istream-header-filter.c +@@ -27,6 +27,7 @@ struct header_filter_istream { + void *context; + + buffer_t *hdr_buf; ++ int snapshot_pending_refcount; + struct message_size header_size; + uoff_t skip_count; + uoff_t last_lf_offset; +@@ -50,7 +51,6 @@ struct header_filter_istream { + bool eoh_not_matched:1; + bool callbacks_called:1; + bool prev_matched:1; +- bool snapshot_pending:1; + }; + + header_filter_callback *null_header_filter_callback = NULL; +@@ -66,7 +66,7 @@ static void i_stream_header_filter_destroy(struct iostream_private *stream) + message_parse_header_deinit(&mstream->hdr_ctx); + if (array_is_created(&mstream->match_change_lines)) + array_free(&mstream->match_change_lines); +- if (!mstream->snapshot_pending) ++ if (mstream->snapshot_pending_refcount == 0) + buffer_free(&mstream->hdr_buf); + else { + /* Clear hdr_buf to make sure +@@ -168,7 +168,7 @@ static ssize_t hdr_stream_update_pos(struct header_filter_istream *mstream) + + static void hdr_buf_realloc_if_needed(struct header_filter_istream *mstream) + { +- if (!mstream->snapshot_pending) ++ if (mstream->snapshot_pending_refcount == 0) + return; + + /* hdr_buf exists in a snapshot. Leave it be and create a copy of it +@@ -177,7 +177,7 @@ static void hdr_buf_realloc_if_needed(struct header_filter_istream *mstream) + mstream->hdr_buf = buffer_create_dynamic(default_pool, + I_MAX(1024, old_buf->used)); + buffer_append(mstream->hdr_buf, old_buf->data, old_buf->used); +- mstream->snapshot_pending = FALSE; ++ mstream->snapshot_pending_refcount = 0; + + mstream->istream.buffer = mstream->hdr_buf->data; + } +@@ -662,8 +662,8 @@ i_stream_header_filter_snapshot_free(struct istream_snapshot *_snapshot) + if (snapshot->mstream->hdr_buf != snapshot->hdr_buf) + buffer_free(&snapshot->hdr_buf); + else { +- i_assert(snapshot->mstream->snapshot_pending); +- snapshot->mstream->snapshot_pending = FALSE; ++ i_assert(snapshot->mstream->snapshot_pending_refcount > 0); ++ snapshot->mstream->snapshot_pending_refcount--; + } + i_free(snapshot); + } +@@ -687,7 +687,7 @@ i_stream_header_filter_snapshot(struct istream_private *stream, + snapshot->hdr_buf = mstream->hdr_buf; + snapshot->snapshot.free = i_stream_header_filter_snapshot_free; + snapshot->snapshot.prev_snapshot = prev_snapshot; +- mstream->snapshot_pending = TRUE; ++ mstream->snapshot_pending_refcount++; + return &snapshot->snapshot; + } + +-- +2.47.3 + diff -Nru dovecot-2.4.1+dfsg1/debian/patches/series dovecot-2.4.1+dfsg1/debian/patches/series --- dovecot-2.4.1+dfsg1/debian/patches/series 2026-05-18 20:03:51.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/patches/series 2026-09-16 19:06:35.000000000 +0000 @@ -64,3 +64,149 @@ CVE-2026-40020-2.patch CVE-2026-40020-3.patch CVE-2026-42006.patch +lib-mail-istream-header-filter-Fix-potential-assert-.patch +# CVE-2026-52687 +0001-imap-Restrict-COMPRESS-to-DEFLATE-only.patch +0002-doveadm-compress-connect-Don-t-hang-when-server-reje.patch +0003-doveadm-compress-Delay-failing-if-used-COMPRESS-algo.patch +# CVE-2026-52681 +0001-lib-storage-Allow-NULL-path-in-mailbox_list_mkdir_ro.patch +0002-lib-storage-Simplify-mailbox_mkdir-using-mailbox_lis.patch +0001-lib-sieve-sieve-storage-Add-is_personal-bit.patch +0001-lib-sieve-sieve-storage-Fix-panic-caused-by-flawed-d.patch +0001-lib-sieve-Add-per-user-sieve-rusage-file-for-cumulat.patch +0002-lib-sieve-Drop-binary-header-resource-usage-tracking.patch +0003-lib-sieve-Rename-on-disk-header-rusage-fields-to-unu.patch +0004-lib-sieve-Switch-sieve_max_cpu_time-enforcement-to-t.patch +0005-lib-sieve-file-storage-Remove-orphan-compiled-binari.patch +0006-lib-sieve-Log-a-warning-when-cumulative-CPU-exceeds-.patch +0007-lib-sieve-Log-an-info-line-when-opening-a-CPU-disabl.patch +0008-lib-sieve-Reduce-CPU-limit-check-interval-in-contain.patch +# CVE-2026-42391 +0001-imap-login-Add-comments-to-internal-ID-command-param.patch +0001-imap-login-Fix-excessive-memory-growth-with-pre-logi.patch +# CVE-2026-40204 +0001-lib-lda-mail-deliver-Autocreate-delivery-mailbox-via.patch +0002-lib-storage-Remove-MAILBOX_FLAG_AUTO_CREATE-and-MAIL.patch +0001-lib-sieve-actions-Split-mailbox-creation-into-sieve_.patch +0002-lib-sieve-actions-Create-fileinto-mailbox-without-th.patch +# CVE-2026-42394 +0001-lib-i_close_fd-Document-that-it-preserves-errno.patch +0002-lib-path-util-Add-t_openat_safe-for-jailed-openat-re.patch +0003-lib-Add-t_openat_safe_dir.patch +0004-acl-dovecot-acl-list-is-never-supposed-to-be-a-symli.patch +0005-lib-Add-comments-about-memory-allocations-and-string.patch +0001-lib-sieve-storage-file-Refuse-symlinks-escaping-pers.patch +0002-lib-sieve-storage-file-Validate-script-stat-path-thr.patch +# CVE-2026-73209 +0004-lib-compression-test-Add-zero-len-frame-test-for-all.patch +0003-lib-compression-istream-zstd-Guard-against-no-progre.patch +0001-lib-compression-istream-lz4-Ensure-uncompressed-chun.patch +0001-lib-compression-istream-lz4-Try-again-if-no-data-was.patch +0001-lib-compression-Don-t-calculate-crc32-if-using-zlib-.patch +0001-lib-compression-istream-zlib-Use-container_of-macro.patch +0001-global-Fix-spelling.patch +0001-lib-compression-wrap-_read-in-for-loop-reindent-only.patch +0002-lib-compression-Iterate-instead-of-recursing-on-zero.patch +# CVE-2026-40018 +0001-lib-sql-sql-api-Implement-async-calls-for-drivers-th.patch +0001-lib-sql-Add-sql_result_new_error-helper.patch +0002-lib-sql-Extract-sql_query_callback_delayed-helper.patch +0003-lib-sql-Add-sql_commit_schedule_delayed-helper.patch +0004-lib-var-expand-Change-escape-func-signature-to-retur.patch +0001-lib-sql-driver-sqlite-Use-sqlite3_snprintf-to-quote-.patch +0001-lib-sql-Use-strchr-based-while-loop-to-fill-template.patch +0005-lib-sql-Make-escape_string-return-int-with-error_r-f.patch +0006-auth-passdb_sql-connect-before-expanding-query-varia.patch +0007-lib-sql-Run-sql-statement-queries-in-their-own-data-.patch +# CVE-2026-40205 and CVE-2026-73208 +0001-auth-db-oauth2-Reduce-nesting-in-token_in_scope-via-.patch +0002-auth-db-oauth2-Fix-scope-check-to-require-all-config.patch +0003-auth-db-oauth2-Add-oauth2_audience-setting-deprecate.patch +# CVE-2026-33604 +0001-lib-mail-ostream-dot-Optimize-stream-writing.patch +0001-lib-mail-o_stream_dot_sendv-Use-I_MIN.patch +0002-lib-mail-o_stream_dot_sendv-Fix-boundary-off-by-one.patch +0003-lib-mail-o_stream_dot_sendv-Use-enumeration-to-defin.patch +0001-lib-test-Add-test_assert_memcmp.patch +0001-lib-test-Require-both-lengths-in-test_assert_memcmp.patch +0004-lib-mail-o_stream_dot_sendv-Do-not-send-unguarded-.-.patch +# CVE-2026-33607 +0001-lib-imap-imap-match-Fix-excessive-CPU-usage-caused-b.patch +# CVE-2026-27852 +0001-lib-mail-Reset-charset-translation-buffer-between-MI.patch +0002-imap-Stream-BODYSTRUCTURE-to-client-without-ostream-.patch +0003-imap-Stream-ENVELOPE-to-client-without-ostream-memor.patch +0004-lib-mail-message-parser-from-parts-Enforce-50-MB-all.patch +0005-lib-mail-Add-count-field-to-struct-message_address_l.patch +0006-lib-storage-mbox-Explicitly-disable-the-header-block.patch +0007-lib-mail-istream-header-filter-Use-container_of-for-.patch +0008-lib-mail-Make-istream-header-filter-s-per-header-siz.patch +0009-lib-storage-Cap-per-header-size-in-index_mail_get_he.patch +0010-lib-mail-Clamp-hdr-value-cumulatively-against-header.patch +0011-lib-mail-Introduce-struct-message_part_data_limits-a.patch +0012-lib-mail-Limit-total-address-count-per-message-to-10.patch +0013-lib-mail-Limit-total-Content-Language-tag-count-per-.patch +0014-lib-mail-Limit-total-MIME-parameter-count-per-messag.patch +# CVE-2026-40014 +0001-lib-storage-thread-Avoid-excessive-data-stack-growth.patch +0002-lib-storage-thread-Limit-References-header-msgid-cou.patch +0003-lib-storage-thread-Limit-ancestor-chain-traversal-de.patch +# CVE-2026-40013 +0001-lib-sieve-sieve-binary-code-Fix-single-byte-oob-stac.patch +# CVE-2026-33605 +0001-lib-managesieve-managesieve-parser-Fix-handling-of-l.patch +0002-managesieve-login-client_skip_line-Discard-data-when.patch +# CVE-2026-40015 +0001-imap-hibernate-Fix-out-of-bounds-read-when-parsing-D.patch +0002-imap-hibernate-Use-imap-parser-API-for-parsing-DONE-.patch +# CVE-2026-40017 +0001-lib-Add-XXH64-hash-implementation.patch +0001-lib-xxh64-fix-byte-ordering-bug-on-big-endian-system.patch +0002-lib-Replace-str_hash-strcase_hash-with-xxh64.patch +0003-lib-Add-str_stable_hash.patch +0004-lib-Key-str_hash-strcase_hash-with-a-random-seed-to-.patch +0005-lib-index-mail-index-strmap-Fix-OOB-read-from-trunca.patch +0006-lib-index-Rename-crc32-variables-parameters-to-hash-.patch +0007-lib-index-Add-keyed-xxh64-strmap-format-v2-gated-by-.patch +0008-doveadm-Fix-ubsan-unsigned-integer-overflow-error.patch +0009-config-Assume-dovecot_config_version-0.0.0-is-the-sa.patch +0010-lib-master-Move-config_version_find-from-src-config-.patch +0011-lib-lib-master-Move-version_-to-lib-version.-ch.patch +0012-config-doveconf-dF-Add-dovecot_storage_version.patch +# CVE-2026-42007 +0001-lib-sieve-util-edit-mail-Fix-writing-to-freed-memory.patch +0002-lib-sieve-util-test-edit-mail-Adjust-to-changes-in-d.patch +0003-lib-sieve-edit_mail_headers_parse-Fix-info-leak-via-.patch +# CVE-2026-33263 +0001-login-common-client-common-Add-client_disconnect-vfu.patch +0002-submission-login-client-Fix-panic-occurring-at-mail_.patch +0003-lib-smtp-smtp-server-Expose-smtp_server_connection_r.patch +0004-submission-login-client-authenticate-Reply-421-4.7.0.patch +0001-commin-Rename-LOGIN_PROXY_FAILURE_TYPE_AUTH-to-LOGIN.patch +0001-managesieve-login-Adjust-to-core-s-login_proxy_failu.patch +0001-login-common-login-Add-proxy_dest_connection_limit-e.patch +0005-submission-login-submission-proxy-Recognize-421-4.7..patch +0001-managesieve-login-Support-LOGIN_PROXY_FAILURE_TYPE_A.patch +0001-login-Add-LOGIN_PROXY_FAILURE_TYPE_AUTH_NOT_REPLIED.patch +# CVE-2026-33606 +0001-dsync-Avoid-potentially-excessive-data-stack-growth-.patch +0002-dsync-Fix-escaping-mail-or-attribute-value-that-begi.patch +# CVE-2026-40203 +0001-imap-Extract-side-channel-ostream-creation-into-help.patch +0002-imap-Recreate-multiplex-ostream-side-channel-after-u.patch +0003-lib-compression-Add-o_stream_deflate_reset_dict.patch +0004-imap-login-Add-dict_reset-side-channel-command-to-im.patch +# CVE-2026-42008 +0005-imap-Add-imap_compress_on_proxy-hidden-setting.patch +0006-imap-Reset-DEFLATE-dictionary-after-every-tagged-rep.patch +0001-submission-Fix-settings-leak-on-error-paths-in-clien.patch +0002-auth-Fix-prefixing-forward_fields-without-a-value-fr.patch +# CVE-2026-42392 +0001-lib-imap-urlauth-Fix-leaking-uninitialized-memory-in.patch +# CVE-2026-42393 +0001-lib-Add-str_equals_timing_safe.patch +0002-doveadm-Avoid-leaking-doveadm_password-or-doveadm_ap.patch +# CVE-2026-42395 +0001-login-common-Fix-crash-when-XCLIENT-FORWARD-base64-c.patch +0002-login-common-Avoid-array_front-panic-on-empty-forwar.patch diff -Nru dovecot-2.4.1+dfsg1/debian/rules dovecot-2.4.1+dfsg1/debian/rules --- dovecot-2.4.1+dfsg1/debian/rules 2026-05-18 20:03:51.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/rules 2026-09-16 19:06:35.000000000 +0000 @@ -124,8 +124,9 @@ override_dh_autoreconf: dh_autoreconf debian/autogen.sh +override_dh_auto_configure: TEST_DIR_OVERRIDE=$(shell mktemp -d) override_dh_auto_configure: - KRB5CONFIG=krb5-config.mit systemdsystemunitdir=/usr/lib/systemd/system dh_auto_configure -- \ + TEST_DIR_OVERRIDE='$(TEST_DIR_OVERRIDE)' KRB5CONFIG=krb5-config.mit systemdsystemunitdir=/usr/lib/systemd/system dh_auto_configure -- \ --enable-experimental-mail-utf8 \ --with-ldap=plugin \ --with-ssl=openssl \ diff -Nru dovecot-2.4.1+dfsg1/debian/salsa-ci.yml dovecot-2.4.1+dfsg1/debian/salsa-ci.yml --- dovecot-2.4.1+dfsg1/debian/salsa-ci.yml 2026-05-18 20:03:51.000000000 +0000 +++ dovecot-2.4.1+dfsg1/debian/salsa-ci.yml 2026-09-16 19:06:35.000000000 +0000 @@ -6,8 +6,4 @@ variables: SALSA_CI_DISABLE_REPROTEST: 1 SALSA_CI_DISABLE_CROSSBUILD_ARM64: 1 - SALSA_CI_DISABLE_BUILD_PACKAGE_TWICE: 0 - -test-build-twice: - extends: .test-build-package-twice - timeout: 2h + SALSA_CI_DISABLE_BUILD_PACKAGE_TWICE: 1