Timo Sirainen [Thu, 9 Jul 2026 11:51:02 +0000 (11:51 +0000)]
anvil: admin-client - Fix busy loop on disconnected admin socket
The connection destroy callback failed pending commands but never removed
the connection's io. The connection framework's connection_closed() calls
v.destroy() expecting the connection to be torn down, but the actual
io_remove() happens only in connection_deinit() via admin_client_unref()
when refcount drops to 0. Since the pool or kick target still holds a
reference, connection_deinit() was never reached.
The io therefore stayed registered on the dead fd and kept firing, calling
destroy repeatedly for the same connection and spinning anvil at ~90% CPU.
Disconnect the connection in the destroy callback so the io is removed and
conn.disconnected is set, matching admin_client_send_cmd()'s reconnect check.
Timo Sirainen [Thu, 2 Jul 2026 12:01:49 +0000 (12:01 +0000)]
imap: imap-state - Add unit tests for parser helpers
Test the seq-range/string import helpers (round-trip, truncation,
overflow rejection) and the compress/enabled-feature/searchres
state-block import handlers.
Timo Sirainen [Thu, 2 Jul 2026 12:01:37 +0000 (12:01 +0000)]
imap: imap-state - Expose parser helpers via imap-state-private.h
Add imap-state-private.h that declares the seq-range, string and
state-block import/export helpers, and make them non-static so they can
be unit tested. Rename all exposed functions to use the imap_state_
prefix for consistency.
Timo Sirainen [Wed, 8 Jul 2026 07:12:39 +0000 (07:12 +0000)]
doveadm: Use STREAM multiplex format at protocol v1.4
The doveadm server->client output used the deprecated PACKET multiplex
format. Bump the protocol to v1.4 and use the STREAM format when the client
is new enough to understand it (minor version >=
DOVEADM_PROTOCOL_MIN_VERSION_MULTIPLEX_STREAM); older clients still get
PACKET. The client istream side auto-detects the format, so no client change
is needed beyond advertising the newer version.
Timo Sirainen [Wed, 8 Jul 2026 07:12:39 +0000 (07:12 +0000)]
anvil: Use STREAM multiplex format instead of the deprecated PACKET
Both ends of an anvil connection are internal Dovecot processes from the
same build, so the format can be switched without protocol negotiation (the
istream side auto-detects the format anyway). Move off the deprecated PACKET
format to STREAM.
Timo Sirainen [Wed, 8 Jul 2026 07:12:39 +0000 (07:12 +0000)]
lib: ostream-multiplex - Mark the PACKET format deprecated
The original packet-based framing requires the parent ostream to buffer a
whole packet and cannot stream to a parent with max_buffer_size==0, so it
does not work with senders that stream directly to the socket. New code
should use the STREAM format; PACKET is kept only for backwards
compatibility and is being phased out.
Timo Sirainen [Wed, 8 Jul 2026 09:53:37 +0000 (09:53 +0000)]
lib: ostream-multiplex - Apply backpressure in the STREAM format
o_stream_multiplex_ochannel_sendv() always bumped the available space up
to IO_BLOCK_SIZE when the channel buffer was small, to batch tiny writes.
This ignored whether the parent could actually accept the data. A parent
with max_buffer_size==0 (as imap-fetch sets while streaming a body
straight to the socket) always reports avail==0, so the bump made the
channel keep accepting data unconditionally. Since o_stream_multiplex_sendv()
no longer gates the STREAM format on parent space, the data was buffered
into the parent without bound and writev() was retried on the full socket
in a tight loop - o_stream_send_istream() never got a 0 return to make it
stop and wait for the socket to become writable.
Only apply the IO_BLOCK_SIZE floor while the parent has no un-flushed
backlog, i.e. the socket is keeping up. Once the parent has buffered data
the socket is congested and the real avail==0 is honored, so the channel
sendv returns 0 and backpressure propagates to the caller.
Timo Sirainen [Wed, 8 Jul 2026 06:37:03 +0000 (06:37 +0000)]
lib: ostream-multiplex - Don't gate STREAM flushing on a max_buffer_size=0 parent
o_stream_multiplex_sendv() skipped sending a channel's buffered data unless
the parent stream reported at least 6 bytes of available space (room for a
frame header). A parent with max_buffer_size==0 always reports
get_buffer_avail_size()==0, so this gate blocks every send to such a parent.
The gate is only correct for the PACKET format, which writes a whole header
and its data with a single sendv and can't cope with a partial parent write.
The STREAM format sends incrementally through pending_buf and already handles
partial parent writes, so it must attempt the send regardless; a full or
streaming parent then applies backpressure naturally through short writes.
imap-fetch sets the output's max_buffer_size to 0 around the send_istream()
that streams a body, so that it goes straight to the socket instead of being
buffered. When the output is an ostream-multiplex channel - as with the
imap-login proxy that multiplexes the backend connection - that 0 reaches the
multiplex parent and the gate stops STREAM from writing through it while it is
set. Under the login-proxy relay this wedged the connection (observed as
either a CPU busy-loop or an idle stall) and the client timed out.
Apply the available-space gate only for the PACKET format. Also assert that a
gated PACKET parent can actually buffer a header, so an unsupported
max_buffer_size==0 PACKET parent fails loudly instead of hanging.
Timo Sirainen [Wed, 10 Jun 2026 22:27:09 +0000 (22:27 +0000)]
lib-auth-client: Use master_service_ioloop_run_interruptible()
auth_master_request_wait() and auth_master_wait() both hand-rolled the same
kill-poll pattern: a 100ms timeout whose handler checked
master_service_is_killed() and stopped the ioloop. Replace both with
master_service_ioloop_run_interruptible().
Marco Bettini [Tue, 7 Jul 2026 10:24:30 +0000 (10:24 +0000)]
auth: Pass resolved credentials scheme via lookup_credentials callback
passdb_get_credentials() mutated request->wanted_credentials_scheme to
report the resolved scheme, letting it leak into the next passdb
during a multi-passdb continue lookup and wrongly reject a different
scheme there as SCHEME_NOT_AVAILABLE. Pass the resolved scheme as a
callback parameter instead of mutating shared request state.
wanted_credentials_scheme stays immutable.
Timo Sirainen [Sun, 28 Jun 2026 11:56:08 +0000 (11:56 +0000)]
lib-var-expand: Add iso8601 filter
Add an iso8601 filter that formats a UNIX timestamp as an ISO 8601 /
RFC 3339 date-time string, e.g. 2025-06-08T10:40:00Z. The tz parameter
selects the timezone: utc (alias gmt, the default), which uses the 'Z'
suffix, or local, which uses a +HH:MM / -HH:MM offset. This produces
RFC 3339 compliant output, which the strftime-based date filter cannot
(strftime's %z lacks the colon and cannot emit 'Z').
Reuses iso8601_date_create_tm() and utc_offset() from lib.
Timo Sirainen [Sun, 28 Jun 2026 08:24:49 +0000 (08:24 +0000)]
lib-var-expand: Add date filter
Add a date filter that formats a UNIX timestamp using strftime(3). The
input may be a plain seconds value or the "<seconds>.<nanoseconds>" form
from the time:unix provider; any fractional part is ignored. The tz
parameter selects the timezone used to break the timestamp into calendar
fields: utc (alias gmt, the default) or local.
Timo Sirainen [Sun, 28 Jun 2026 08:23:45 +0000 (08:23 +0000)]
lib-var-expand: Add from_epoch filter
Add a from_epoch filter, the inverse of epoch: it converts an integer
UNIX timestamp expressed in s/ms/us/ns into the canonical
"<seconds>.<nanoseconds>" form. This is useful for feeding millisecond
or nanosecond timestamps from external input into the date filter.
Timo Sirainen [Sun, 28 Jun 2026 08:22:56 +0000 (08:22 +0000)]
lib-var-expand: Add epoch filter
Add an epoch filter that converts a UNIX timestamp into an integer in
the requested unit (s, ms, us or ns). The input may be a plain seconds
value or the "<seconds>.<nanoseconds>" form returned by the time:unix
provider. The output never contains a decimal point.
Timo Sirainen [Sun, 28 Jun 2026 08:21:40 +0000 (08:21 +0000)]
lib-var-expand: Add time:unix provider
Add a new %{time:unix} provider that returns the current time as a
"<seconds>.<nanoseconds>" UNIX timestamp. This is intended as the input
for the epoch and date filters.
last-login: Add support for updating multiple dictionary fields
Extend the last-login plugin to support a new 'last_login_dict_fields'
setting. This allows users to specify a list of key/value pairs (with
variable expansion support) to be updated during the login process.
Timo Sirainen [Mon, 6 Jul 2026 21:14:06 +0000 (21:14 +0000)]
lib-dict-extra: Increase dict client request timeout to 65s
The dict client request timeout was 30 seconds, which is lower than the
backend SQL/Cassandra request timeout default of 60 seconds
(SQL_QUERY_TIMEOUT_SECS). A slow backend query would therefore hit the
dict client timeout first, logging a "Dict server timeout" warning and
forcing a reconnect, instead of letting the backend abort the query with
a proper error.
Raise the timeout to 65 seconds so it sits above the backend timeouts.
Timo Sirainen [Fri, 3 Jul 2026 13:04:53 +0000 (13:04 +0000)]
lib-storage: Fix crash when reverting a failed mailbox deletion
If a backend's delete_box fails after the mailbox was marked deleted,
mailbox_delete_real() reverts the mark by calling mailbox_mark_index_deleted()
which reopens the mailbox. That open can fail before the index is opened,
leaving box->index NULL, and mailbox_try_undelete() then dereferenced it in
mail_index_get_modification_time(), crashing.
Return FALSE from mailbox_try_undelete() when box->index is NULL - there is
nothing to undelete.
Timo Sirainen [Fri, 3 Jul 2026 11:33:31 +0000 (11:33 +0000)]
fts: Don't run FTS optimize on forced resync
This was an obsolete design decision, which is only making force-resyncs
slower and can cause them to fail. FTS optimization has nothing to do with
index rebuilds.
Timo Sirainen [Mon, 6 Jul 2026 16:19:16 +0000 (16:19 +0000)]
lib-storage: Fix NFC subscription rewrite for mUTF7 storage names
mailbox_list_subscriptions_refresh() normalized the raw subscription file
entry, which is a storage name (e.g. mUTF7). With mUTF7 the non-NFC
characters are hidden inside the encoding and the storage name looks like
plain ASCII, so uni_utf8_to_nfc() was a no-op and the subscription was
never rewritten to NFC form.
The mailbox directory itself does get renamed to NFC during listing (that
code normalizes the decoded vname), leaving the subscription pointing at a
non-existent non-NFC name. This triggered the NFC rename fallback on every
iteration: repeated 'Failed to rename mailbox for NFC normalization:
Mailbox doesn't exist' errors and a bogus GUID-suffixed subscription entry.
Normalize the decoded vname instead and convert the result back to a
storage name, matching what mailbox listing does.
Timo Sirainen [Fri, 3 Jul 2026 16:01:08 +0000 (16:01 +0000)]
imapc: Fix COPY checking expunged state of wrong mailbox
imapc_copy() checked imapc_is_mail_expunged() against the destination
mailbox (ctx->mbox) while passing a source UID. The check looks up the
UID in the given mailbox's delayed_expunged_uids / delayed_sync_trans,
i.e. in that mailbox's own UID space, so it must be given the source
mailbox (ctx->src_mbox).
Because of this, a source mail whose EXPUNGE had already been received
but not yet applied to the msgmap was not detected. The message map
still contained the UID, so the copy proceeded and a UID COPY was sent
for an already-expunged mail. The remote server then silently skipped
it (UID COPY ignores non-existent UIDs rather than failing), returning
fewer COPYUIDs than mails requested. That mismatch tripped the
all-or-nothing saved_uids assertion in
mailbox_transaction_commit_get_changes() and crashed imap. This showed
up as random failures in the obox-tests/imapc-shared-folder test.
Check the source mailbox so such mails are detected up front and the
copy is aborted with MAIL_ERROR_EXPUNGED before any UID COPY is sent.
Timo Sirainen [Thu, 2 Jul 2026 08:24:51 +0000 (08:24 +0000)]
lib-sql: Make sqlite busy_timeout configurable
Add sqlite_busy_timeout setting (default 1s, unchanged from the previous
hardcoded value). Under WAL only one writer runs at a time, so concurrent
writers can still hit SQLITE_BUSY; a configurable timeout lets deployments
with slow storage widen the retry window.
lib-imap: imap-bodystructure - Don't read past args in message/rfc822 parsing
The message/rfc822 branch of imap_bodystructure_parse_args_int()
dereferences the child bodystructure at args[1] before it validates the
envelope at args[0]. A BODYSTRUCTURE truncated right after the size field,
e.g. ("message" "rfc822" NIL NIL NIL "7bit" 0), leaves args pointing at
the argument list's IMAP_ARG_EOL terminator, so reading args[1] indexes one
struct imap_arg past the end of the EOL-terminated array.
Bail out when args[0] is IMAP_ARG_EOL before indexing args[1], matching the
ordering already used by the sibling imap_parse_bodystructure_args(). This is
reachable via imapc from an upstream server's FETCH BODYSTRUCTURE reply.
Timo Sirainen [Thu, 2 Jul 2026 15:00:03 +0000 (15:00 +0000)]
lib-ldap: Improve request timeout error when connection setup fails
When an LDAP request times out while the connection has not yet reached a
usable (bound) state, it timed out while still connecting rather than while
waiting for a server reply. Report that, and when TLS is in use also point at
the certificate.
This matters for OpenLDAP built against GnuTLS: that backend does not report a
handshake or certificate verification failure back to lib-ldap at all, so such
a failure previously surfaced only as a generic "Aborting LDAP request after
timeout".
Timo Sirainen [Fri, 26 Jun 2026 05:55:38 +0000 (05:55 +0000)]
lib-smtp: smtp-server-recipient - Assert cmd is set in get_reply
smtp_server_recipient_get_reply() dereferences rcpt->cmd (which is NULLed
in _approved() and re-set by _data_command()) relying on the DATA
lifecycle invariant. The sibling _is_replied()/_replyv() helpers already
assert rcpt->cmd != NULL; add the same assertion here so the invariant is
explicit and a violation fails loudly rather than dereferencing NULL.
Timo Sirainen [Fri, 26 Jun 2026 05:54:18 +0000 (05:54 +0000)]
lib-imap: imap-parser - Make list_add_ghost_eol iterative
list_add_ghost_eol() recursed once per list nesting level when finishing
a line that left an open list. Pre-auth parsers cap the list count at 1
so this is not pre-auth reachable, but convert it to an iterative walk
over the parent chain as defense in depth against C-stack growth from a
deeply nested list.
Timo Sirainen [Fri, 26 Jun 2026 05:53:46 +0000 (05:53 +0000)]
lib-otp: otp-parse - Make word-count invariant explicit
otp_read_words() appends decoded words into a fixed buffer_t backed by
bits[OTP_WORDS_NUMBER]. The loop condition and the trailing add_word()
guard already keep the count within capacity, but assert it explicitly
in add_word() so any future change that lets count reach the capacity is
caught rather than silently relying on buffer_t's own overflow panic.
Timo Sirainen [Fri, 26 Jun 2026 05:53:15 +0000 (05:53 +0000)]
lib-oauth2: oauth2-jwt - Percent-encode bare '.'/'..' identifiers
escape_identifier() only escaped '/' and '%', so a bare '.' or '..' in
the attacker-controlled azp/kid passed through unchanged into the
shared/<azp>/<alg>/<kid> dict key. The shipped flat-file dict treats the
key literally, but a path-mapping dict backend could be made to traverse
(CVE-2021-29157 class). Percent-encode a whole-segment '.'/'..' so it
cannot act as a relative path component.
Timo Sirainen [Fri, 26 Jun 2026 05:52:33 +0000 (05:52 +0000)]
lib-oauth2: oauth2-jwt - Guard against empty JWT body segment
The JWT header segment is rejected when it decodes to an empty buffer,
but the body segment was passed straight to oauth2_json_tree_build()
without the same check. Add the symmetric body->used == 0 guard so the
two segments are handled consistently. The 'empty body and signature'
case in test-oauth2-jwt already exercises this path.
Timo Sirainen [Fri, 26 Jun 2026 05:51:07 +0000 (05:51 +0000)]
imap: imap-search - Detect overflow in SEARCH RETURN PARTIAL range
imap_partial_range_parse() accumulated the partial range bounds into
uint32_t by hand with no overflow check, so a value such as 99999999999 wrapped to an unrelated number. The wrapped values are only
used as a result-index comparison window and echoed back, so this is a
correctness issue rather than a memory-safety one, but it should still
be rejected. Parse with str_parse_uint32(), which fails on overflow.
In the SUBMISSION_PROXY_AUTHENTICATE state a non-334 reply line that is
not an invalid line ran i_assert(proxy_reply == NULL) and then created
proxy_reply, returning early (return 0) for a non-final line without
clearing it. proxy_reply is only reset on connection reset, so a backend
answering the proxied AUTH with a multi-line reply (e.g. a two-line
'535-...' / '535 ...' rejection) re-entered this state with proxy_reply
already set and tripped the assert, i_panic()ing the submission-login
process - a deterministic DoS triggerable by a malicious/compromised
backend.
Create proxy_reply only on the first line and append each subsequent
line's text to it.
Timo Sirainen [Fri, 26 Jun 2026 05:49:57 +0000 (05:49 +0000)]
submission-login: submission-proxy - Don't read past end of short reply
submission_proxy_parse_line() inspected line[3] (twice) before checking
the reply length. A backend reply line shorter than 4 bytes made these
reads run past the string's NUL terminator - a small out-of-bounds read
reachable from a malicious/compromised proxied backend.
Parse the 3-digit status code first with str_parse_uint() and use the
returned end pointer as the separator position, which is always within
the string (at worst the NUL terminator).
i_stream_binary_converter_read() tail-recursed into itself whenever a
parsed block produced no stream output (new_size == old_size), e.g. a
header line buffered into hdr_buf without advancing istream.pos. A MIME
part with many short folded header lines could drive thousands of
recursion frames before the 32 kB cap flushes. Production -O2 usually
tail-call-optimizes this away, but -O0/-O1/sanitizer builds do not.
Move the per-block work into i_stream_binary_converter_read_block() and
drive it from a loop in i_stream_binary_converter_read(), so a block that
produces no output reads the next block instead of recursing.
smtp_reply_parse_enhanced_code() read text[0] and text[1] before any
length check. For an empty string text[1] is one byte past the NUL
terminator, a 1-byte out-of-bounds read. The function is exported, so a
future caller could reach it with a short string even though the current
callers happen to guard the length themselves.
Timo Sirainen [Mon, 29 Jun 2026 08:36:31 +0000 (08:36 +0000)]
lib-mail: rfc822-parser - Decode punycode from non-NUL-terminated input
rfc822_decode_punycode() took a const char * but already received a
length, yet still relied on NUL termination: strchr()/str_begins()
scanned for a NUL, forcing rfc822_parse_domain() to t_strndup() a
NUL-terminated copy of the label run first.
Take const unsigned char * and treat input as exactly len bytes: bound
the "xn--" prefix test by the label length instead of str_begins(). This
lets the caller pass str_data() directly and drop the t_strndup() copy.
Timo Sirainen [Mon, 29 Jun 2026 08:35:22 +0000 (08:35 +0000)]
lib-mail: rfc822-parser - Don't copy trailing NUL into decoded domain
For a domain label with no trailing '.', rfc822_decode_punycode()
computed delim == end (the NUL terminator of the t_strndup()'d buffer)
and then copied delim - pos + 1 bytes, i.e. one byte too many, appending
a stray NUL to the decoded domain. This left a NUL inside addr.domain,
confusing strlen()-based consumers.
Copy only the label bytes (delim - pos) and append the '.' separator
explicitly, and bound the delimiter search with memchr() so it cannot
scan past the given length.
The post-loop "if (pos < end)" append was dead code: the loop only exits
once pos has advanced past end (pos = delim + 1, with delim <= end), so
the condition is never true. Drop it; no behaviour change.
Timo Sirainen [Fri, 26 Jun 2026 05:39:57 +0000 (05:39 +0000)]
lib: punycode - Fix meaningless bounds assertion
i_assert(out < sizeof(label)) compared an item count against the size of
the ARRAY() wrapper struct (~16 bytes), and 'out' is always 0 at this
point (it is only assigned from array_count() afterwards). The check was
effectively '0 < 16' and never meaningful.
Assert the actual invariant instead: the number of basic code points
appended never exceeds the input length.
Timo Sirainen [Fri, 26 Jun 2026 05:39:33 +0000 (05:39 +0000)]
lib: punycode - Reject invalid Unicode code points in decoder
punycode_decode() reconstructs code points into an unsigned int and
feeds them to uni_ucs4_to_utf8(), which i_assert()s that each value is a
valid Unicode scalar. The decode loop only guarded against integer
wraparound and a lower bound, so a crafted label could decode to a
surrogate (0xD800..0xDFFF) or a value above U+10FFFF and trip the
assert, i_panic()ing the process.
With experimental mail UTF-8 enabled this path is reachable from
untrusted address domains (xn-- labels) via rfc822_decode_punycode(),
making it a remote content DoS.
Reject such code points with -1 before inserting them; callers already
treat a negative return as 'consider it as data', so it fails safe.
Marco Bettini [Wed, 27 May 2026 09:11:53 +0000 (09:11 +0000)]
login-common: Reject proxy credentials with ASCII control characters
POP3 sends proxy credentials unencoded in USER/PASS, where control
characters could inject protocol commands. Such characters are never
valid in credentials anyway, since PRECIS (RFC 8264/65) disallows
them.
Marco Bettini [Mon, 29 Jun 2026 13:09:47 +0000 (13:09 +0000)]
lib-ldap: Fall back to system default CA paths for TLS
OpenLDAP built against GnuTLS does not load the system trust store on
its own (unlike its OpenSSL backend), so without this the TLS handshake
has no trust anchors and fails. The existing handle settings are
checked via ldap_get_option() so an inherited ldap.conf CA is not
overridden.
Broken by: 333ac4c837cf (lib-ldap: Create a new TLS context for all LDAP TLS connections)
Marco Bettini [Wed, 10 Jun 2026 13:02:59 +0000 (13:02 +0000)]
auth: Wrap var_expand_program_execute() for auth_request var expansion
The var_expand providers expect params.context to be a struct
auth_request_var_expand_ctx and dereference ctx->auth_request, but the
passwd-file backends erroneously passed raw auth_request as params.context,
giving garbage %{passdb:...} and %{userdb:...} lookups and potential crashes.
Add auth_request_var_expand_program_execute(), which builds the context
internally and enforces it to be of the proper type.
Broken since d0b4a58cb9 ("auth: Use new var_expand").
Timo Sirainen [Wed, 1 Jul 2026 08:40:40 +0000 (08:40 +0000)]
lib-settings: Treat empty built-in default as "no default" for non-emptyable settings
A built-in default (setting_parser_info.default_settings) with an empty value
for a setting that cannot be empty (e.g. a number or time) previously failed
with an "Invalid ..." error when applied directly via lib-settings. Treat it
instead as "no default" and leave the value to be inherited from a less
specific filter.
This lets a named filter keep a default-settings entry - so settings-history
can override the value for old config versions - without forcing that value for
current versions. The config process already added this in 2b98d64515f5c679b5b9d884c63f19dc4c73aa46, but lib-settings was forgotten.
Timo Sirainen [Mon, 29 Jun 2026 15:47:36 +0000 (15:47 +0000)]
lib-dns-client: dns-lookup - Fix use-after-free in dns_client_destroy()
dns_client_destroy() calls dns_client_disconnect() to abort pending
lookups. For one-shot clients (deinit_client_at_free=TRUE) this frees the
last lookup via dns_lookup_free(), which calls dns_client_deinit() and
frees the client (and the embedded connection). dns_client_destroy() then
kept using the freed client.
Clear deinit_client_at_free while aborting the lookups so the client is
not freed from under us, tear down the connection, and only then perform
the deinit if it was requested.
Timo Sirainen [Sun, 7 Jun 2026 14:32:57 +0000 (17:32 +0300)]
lib-settings: Expand %variables in strlist and boollist keys
Until now only strlist/boollist values were %variable-expanded by
settings_get(); the keys were always used literally. Expand the keys as
well, using the same rules as the values:
- Keys coming from the configuration are trusted and are always
expanded.
- Override keys are expanded only for SETTINGS_OVERRIDE_TYPE_DEFAULT,
exactly like override values. Keys from -o parameters
(SETTINGS_OVERRIDE_TYPE_CLI_PARAM) and userdb
(SETTINGS_OVERRIDE_TYPE_USERDB) are kept literal.
The expansion is done before settings_parse_list_has_key(), so list key
deduplication and overriding operate on the final key.
Timo Sirainen [Fri, 12 Jun 2026 22:07:00 +0000 (22:07 +0000)]
auth: oauth2 mech - snapshot worker-supplied fields before credential lookup
On the auth worker path (oauth2_use_worker_with_mech=yes) the mech applies
the fields returned by the worker with auth_request_set_fields() and then
runs auth_request_lookup_credentials(). If that lookup reaches a passdb
returning PASSDB_RESULT_SCHEME_NOT_AVAILABLE, the passdb rollback wipes
fields.userdb_reply (and extra_fields), dropping the userdb_* fields the
worker just supplied. Snapshot them right after set_fields so the rollback
keeps them. The in-process path is handled in db_oauth2_add_extra_fields().
Timo Sirainen [Fri, 12 Jun 2026 21:59:31 +0000 (21:59 +0000)]
auth: db-oauth2 - snapshot userdb_reply after extra-field set
On the SASL OAUTHBEARER path, auth_request_lookup_credentials reaches
the oauth2 passdb's PASSDB_RESULT_SCHEME_NOT_AVAILABLE branch, whose
rollback wipes fields.userdb_reply - including the userdb_* entries
db_oauth2_add_extra_fields just set. Snapshot userdb_reply right after
the field-set loop so the rollback keeps them. extra_fields is already
snapshotted by the caller; only userdb_reply needs explicit
protection.
Timo Sirainen [Thu, 30 Apr 2026 17:53:17 +0000 (17:53 +0000)]
lib-storage: mailbox_list_get_escaped_mailbox_name() - Match storage_name escape rules
The function previously escaped only the storage_escape_char and the
hierarchy separator, while mailbox_list_default_get_storage_name() applies
mailbox_list_escape_name_params() rules: leading '~' on the first part,
literal '/', and a dirstart maildir_name. Names round-tripping through the
mailbox list index lookup hash relied on the two encoders producing
identical output, so any name containing one of those characters could
not be looked up by its storage name.
Use mailbox_list_escape_name_params_to_str() so both producers share the
same escape logic. node->parent == NULL marks the leading hierarchy part
for the leading-'~' rule.
Allows callers to append the escaped name into an existing string_t
without an intermediate t_str_new() allocation. Existing function
becomes a thin wrapper around the new helper.
Timo Sirainen [Tue, 9 Jun 2026 07:50:00 +0000 (07:50 +0000)]
config: doveconf - Hide flat global settings overridden by a named filter
doveconf -a printed both the flat global setting (e.g. cassandra_hosts)
and its grouped override (cassandra { hosts }). The flat global is only
the default value and is confusing when a named filter overrides the same
setting, because the flat default and the grouped override are shown side
by side in different scopes.
Hide the flat global from the full human-readable output when a top-level
named filter overrides the same prefixed setting. Settings that are not
overridden are still shown as flat globals, and explicitly querying a
single setting still shows it.
Timo Sirainen [Tue, 23 Jun 2026 17:51:53 +0000 (17:51 +0000)]
config: settings-history: Prefer exact filter-path default over setting-name default
old_settings_default() matched on either the setting name or the full filter
path and returned whichever entry came first in the version-sorted history
array. When both a generic and a filter-specific entry existed for the same
version, the winner was arbitrary. Always prefer the more specific
filter-path match.
Timo Sirainen [Tue, 23 Jun 2026 17:51:53 +0000 (17:51 +0000)]
config: Treat empty built-in default as "no default" for non-emptyable settings
A built-in default with an empty value for a setting that cannot be empty
(e.g. a number or time) previously failed with an "Invalid ..." error. Treat
it instead as "no default" and leave the value to be inherited from a less
specific filter.
This lets a named filter keep a default-settings entry - so settings-history
can override the value for old config versions - without forcing that value
for current versions.
Aki Tuomi [Fri, 12 Jun 2026 06:09:16 +0000 (06:09 +0000)]
lib-smtp: xclient - Reject invalid HELO parameter with 501
The XCLIENT spec states that invalid attribute values must result in
a 501 syntax error. All other attributes (ADDR, PORT, PROTO, ...) already
return 501 on bad input; invalid HELO= was silently ignored instead.
Aki Tuomi [Fri, 5 Jun 2026 11:31:38 +0000 (11:31 +0000)]
login-common: client-common-auth - Use overflow-safe auth response bound
client_auth_read_line() bounded the accumulated SASL response with
str_len(auth_response) + i > LOGIN_MAX_AUTH_BUF_SIZE, where both operands
are size_t. With the shipped LOGIN_MAX_INBUF_SIZE the sum can't approach
SIZE_MAX, so this can't wrap in practice, but the additive form would be
the load-bearing bound if the inbuf size were ever raised. Rewrite it in
overflow-safe form so the bound holds independently of the inbuf size.
Aki Tuomi [Fri, 5 Jun 2026 11:31:38 +0000 (11:31 +0000)]
lib-imap: imap-parser - Track quoted-string escape with a bool
parser->str_first_escape held the offset of the first '\' escape in a
quoted string in a signed int, but that offset was only ever tested for
>= 0 - it was never used as a position. Storing a size_t offset into a
signed int would truncate/negate for an offset past INT_MAX, which can't
happen under the login parsers' 8 KiB line cap but is fragile for parser
instances configured with a much larger max_line_size.
Replace it with a plain bool str_have_escape, which is all the code
actually needs and removes the signedness concern entirely.
Add a quoted-string test covering both the escaped and non-escaped paths
to confirm unescaping still works.
Aki Tuomi [Fri, 5 Jun 2026 11:31:37 +0000 (11:31 +0000)]
lib-http: http-header-parser - Reject obs-fold in strict mode
http_header_parse() accepted obsolete line folding (obs-fold: CRLF
followed by SP/HTAB) unconditionally, replacing the CRLF with a single
space and folding the continuation into the previous field value. RFC
7230, Section 3.2.4 says a server that receives obs-fold in a request
MUST reject it with 400 (Bad Request), because differing obs-fold
handling between Dovecot and an upstream HTTP agent is a request
desynchronization / smuggling primitive.
The HTTP server parses requests with HTTP_HEADER_PARSE_FLAG_STRICT
(http-server-connection.c), so gate the rejection on that flag. Lenient
parsing (HTTP response parsing, where we accommodate bad servers) keeps
folding as before.
Add a strict-mode obs-fold case to the invalid header tests; the existing
lenient folding test continues to pass.
Aki Tuomi [Fri, 5 Jun 2026 11:31:37 +0000 (11:31 +0000)]
lib-imap: imap-arg - Don't read past an empty list in imap_arg_get_list_full()
When the backing array of an IMAP_ARG_LIST is empty - holding no elements
at all, not even the temporary IMAP_ARG_EOL that imap-parser appends when
it stops early - array_get() returns count 0. The code then fell through
to the early-stop branch and read (*list_r)[0], indexing one element past
the zero-length array (an out-of-bounds read, or an assertion failure on
the bogus type).
Treat count == 0 as an empty list instead. Add a regression test that
calls imap_arg_get_list_full() on a list arg with an empty array.
Aki Tuomi [Wed, 10 Jun 2026 11:39:32 +0000 (11:39 +0000)]
lib: punycode - Don't scan past len in punycode_decode()
punycode_decode(input, len, ...) located the delimiter with
strrchr(input, '-'), which scans the whole NUL-terminated string rather
than just the first len bytes. rfc822_decode_punycode() calls it with
input pointing into a longer dot-atom buffer and len equal to a single
label's length, so a '-' in a later label lies past len: strrchr() then
returned a pointer >= end and the i_assert(delim < end) aborted the
process (reachable via a crafted IDN address when experimental mail
UTF-8 is enabled). It was also an out-of-bounds read for any
non-NUL-terminated caller.
Use i_memrchr() to search only the first len bytes, which also makes
the previous delim<end assertion unnecessary.
Aki Tuomi [Wed, 10 Jun 2026 11:39:19 +0000 (11:39 +0000)]
lib: Add i_memrchr() helper
memrchr() is not in POSIX. Wrap it with a portable fallback that
scans backwards through the first size bytes of data, returning a
pointer to the last occurrence of c or NULL if not found.
Aki Tuomi [Fri, 5 Jun 2026 11:31:37 +0000 (11:31 +0000)]
lib-index: mail-index-map - Fix out-of-bounds read on empty keyword name area
mail_index_map_parse_keywords() computes
name_area_end_offset = (kw_hdr + ext->hdr_size) - name
as an unsigned int. When the on-disk keywords extension header has no name
area - e.g. keywords_count==0 with hdr_size equal to just the
mail_index_keyword_header - name_area_end_offset is 0 (the preceding bound
check uses '>' so this passes). The code then evaluated
name[name_area_end_offset-1]
i.e. name[(unsigned)-1] == name[0xFFFFFFFF], a ~4 GB out-of-bounds read,
on a header read straight from dovecot.index.
Skip the terminating-NUL check when the name area is empty; a keyword
header with zero names is a valid (empty) keyword set.