Timo Sirainen [Wed, 29 Jul 2026 06:45:29 +0000 (06:45 +0000)]
lib-master: Fix kick reason when KICK-USER-SIGNAL command arrives late
anvil kicks processes with client_limit=1 by connecting to the process's
master-admin socket, writing the KICK-USER-SIGNAL command and sending
SIGTERM. The command is normally read by the SIGTERM handler itself,
which accept()s the connection. If the process's ioloop happens to
accept the connection first, master_admin_client_initial_read() waits
for the command for 100 ms with SIGTERM blocked.
If the command wasn't received within those 100 ms, the kick was lost:
the SIGTERM handler's accept() failed with EAGAIN, because the
connection had already been accepted, and last_kick_signal_user was
still unset. The process then shut down as if it wasn't kicked at all,
logging "Killed with signal 15" and disconnecting the client with
"Server shutting down" instead of "User kicked".
The same happened even when the command was already readable: the
delayed SIGTERM handler stops the ioloop, which stops running the rest
of the io callbacks in the current ioloop iteration, so the master-admin
connection's input was never handled.
Fix this by waiting a while for the pending master-admin commands in the
delayed SIGTERM handler before deciding that the process wasn't kicked,
and by doing the username match already in
master_service_set_last_kick_signal_user() when the SIGTERM was handled
before the command was received.
Timo Sirainen [Sun, 5 Jul 2026 17:06:42 +0000 (17:06 +0000)]
login-proxy: Log admin kick reason when exiting due to a kicked proxy
In high-security mode a kicked proxy connection terminates the login
process via SIGTERM, and the proxy is freed in login_proxy_deinit() with
the generic "Process shutting down" reason. Use "Kicked by admin" instead
when the process was killed by a user kick, matching the reason logged by
the admin-socket kick path.
Timo Sirainen [Sun, 5 Jul 2026 17:06:15 +0000 (17:06 +0000)]
login-proxy: Fix doveadm kick for proxied connections in high-security mode
When imap-login runs with client_limit=1 (restart_request_count=1, the
community-edition default), a detached proxy connection is kicked via
SIGTERM + KICK-USER-SIGNAL. The master service signal handler matches the
kick against current_user, which login processes never set, so the signal
was treated as a mismatch and ignored: doveadm reported the connection
kicked while the client stayed connected.
Set current_user to the proxied user while proxying with client_limit=1,
so the kick is recognized and terminates the connection. With
client_limit>1 the kick uses the admin socket and current_user stays unset.
Markus Valentin [Wed, 29 Jul 2026 00:52:50 +0000 (00:52 +0000)]
lib: ostream-file - Avoid memcpy(NULL) assert with max_buffer_size=0
o_stream_set_max_buffer_size(output, 0) on a stream that has never
buffered anything leaves fstream->buffer NULL, because
o_stream_grow_buffer() caps the allocation to max_buffer_size. If a
write to the fd could then not be fully flushed, o_stream_add() did a
zero-length memcpy() into the NULL buffer:
This is not triggered by any of the current max_buffer_size=0 callers.
o_stream_add() also already returned the correct 0 here and the
memcpy() copied nothing, so this only became fatal with the
assert-crashing memcpy() macro in 38dbeec767ad10021020e3833b1dbc3d04442f85.
Timo Sirainen [Tue, 14 Jul 2026 05:41:06 +0000 (05:41 +0000)]
lib: test-ostream-multiplex - Add STREAM format backpressure and buffering tests
Cover the recent ostream-multiplex STREAM fixes and some previously
untested paths:
- STREAM must write through a max_buffer_size==0 parent instead of
wedging the data in the channel buffer.
- A single sendv larger than IO_BLOCK_SIZE to such a parent must make
forward progress rather than repeatedly buffering nothing and
busy-looping.
- A congested socket must apply backpressure so o_stream_send_istream()
stops and waits, instead of buffering the whole body into the parent
without bound. A regression spins forever, so this runs in a
subprocess whose watchdog kills the hung child and turns the hang into
a clean failure.
- A max_buffer_size==0 PACKET parent must fail its assert loudly.
- The STREAM_CONTINUE format had no coverage at all.
Timo Sirainen [Wed, 29 Jul 2026 01:00:24 +0000 (01:00 +0000)]
lib-mail: message-parser - Fix skipping the epilogue boundary line
preparsed_parse_epilogue_boundary() looks for the end of the close
delimiter line with memchr(). When it isn't found the code asks for more
data, but stops asking once it holds BOUNDARY_END_MAX_LEN bytes, the
input buffer is full, or no more data is coming. It then fell through
to:
ctx->skip = cur - data + 1;
with cur == NULL. That is undefined behavior, and in practice yields a
skip of roughly 2^64 - (address of data). The skip is applied by the
next message_parser_read_more(), whose i_stream_skip() then seeks the
stream far past any real offset. The walk ends at EOF with
broken_reason unset, so message_parser_deinit_from_parts() reports
success for a truncated parse. If the multipart's parent had a further
sibling, preparsed_parse_next_header_init() would instead see the
stream past that sibling's cached physical_pos.
The forward parser doesn't stop looking for the line's end:
parse_next_body_skip_boundary_line() consumes whole blocks until it
finds the newline. Do the same here, in a new
preparsed_parse_epilogue_skip_boundary_line() state, so replaying a
cached tree returns the same blocks the forward parser returns for the
same input. Give up only if the line doesn't end within the part, which
is the pre-existing "Epilogue boundary end not at expected position"
check.
This wasn't reachable with the current callers: the epilogue is only
parsed with MESSAGE_PARSER_FLAG_INCLUDE_MULTIPART_BLOCKS, and the
from-parts callers (index-mail-headers, message-search) do not set it.
Add tests for the ways the line's end can be missing: a close delimiter
with no trailing newline that ends exactly at the multipart's end, a
stream truncated inside the close delimiter line, and a line whose
newline is further away than the input buffer can reach -- the last one
comparing the returned blocks against a forward parse of the same input.
Timo Sirainen [Mon, 27 Jul 2026 11:25:20 +0000 (11:25 +0000)]
lib-mail: message-part-serialize - Enforce message part offset consistency
The message_part tree is serialized to the cache and later deserialized
and walked by message_parser_init_from_parts(). That walk requires the
parts to be laid out in order with non-overlapping siblings, but neither
end enforced it fully:
- message_part_deserialize() advanced its expected-position check only
for parts that have children, never past a leaf body. Overlapping
leaf siblings therefore passed validation and could later crash the
from-parts walk. Advance the position past leaf bodies too so such a
tree is rejected as corrupted (leading to a re-parse).
This also makes the existing "child part location exceeds our size"
check effective for child lists ending in a leaf: previously the
position was still at the parent's body start when the recursion
returned, so a last child overrunning its parent's body was accepted.
- message_part_serialize() only checked that non-composite parts have
no children. Its input is freshly parsed in-memory data, so an
inconsistent tree is a parser/programming bug, not corrupted input:
assert the same ordering the deserializer requires. This catches a
bad producer at write time rather than as a confusing read-side
failure, and guarantees anything serialized will deserialize.
The position is only used for validation and is never stored into the
returned parts, so trees that still deserialize are unchanged.
Add tests for the deserializer rejecting overlapping leaf siblings and
for the serializer asserting on them.
Timo Sirainen [Mon, 27 Jul 2026 11:25:11 +0000 (11:25 +0000)]
lib-mail: message-parser - Handle inconsistent cached parts in from-parts
message_parser_init_from_parts() walks a cached message_part tree using
its cached physical positions and sizes. If the tree is internally
inconsistent -- a sibling part recorded as starting before the previous
part ends -- preparsed_parse_next_header_init() finds the stream already
past the part's cached physical_pos and i_assert-panics:
The sibling body_init and epilogue_init paths already treat the same
condition as a broken message. Do the same here: set broken_reason and
return -1 instead of asserting, so a corrupt cache degrades to a
re-parse instead of crashing the process.
Add a test that parses tricky multipart messages at every input buffer
size and checks the resulting tree offsets never overlap and always
re-parse, plus a test that a manually overlapped tree fails the
from-parts walk gracefully.
Timo Sirainen [Tue, 28 Jul 2026 23:44:38 +0000 (23:44 +0000)]
lib: test-iostream-pump - Fix duplicated test name
test_iostream_pump_failure_end_read() printed itself as "failure mid-read",
which is what test_iostream_pump_failure_mid_read() is already called. This
made it ambiguous which of the two tests a CI failure belonged to.
Timo Sirainen [Tue, 28 Jul 2026 23:44:01 +0000 (23:44 +0000)]
lib: test-iostream-pump - Fix flakiness on stalled machines
The tests used a 3000 ms ioloop timeout as a watchdog for the pump to
finish. The ioloop compensates for time moving forwards only while it is
waiting, not while timeout and IO callbacks are running. When the wall
clock jumps forwards during that window, or the process doesn't get
scheduled, every timeout that became due is called in the same run, so the
watchdog fires before the pump has had the chance to make any progress.
Use a 30 second watchdog instead, so that only a machine that is stalled
that long can trigger it. Keep alarm() a bit above the watchdog, so that it
still only triggers when the ioloop itself is stuck.
Timo Sirainen [Tue, 28 Jul 2026 23:43:48 +0000 (23:43 +0000)]
lib: test-lib-signals - Fix flakiness on stalled machines
The tests used a 400 ms (or 100 ms) ioloop timeout as a watchdog for a
delayed signal handler that was expected to be called much sooner. The
ioloop compensates for time moving forwards only while it is waiting, not
while timeout and IO callbacks are running. When the wall clock jumps
forwards during that window, or the process doesn't get scheduled, every
timeout that became due is called in the same run in the order it expired.
The watchdog then runs immediately after the timeout that sent the signal,
and the test fails before the ioloop ever gets to call the delayed signal
handler.
Use a 30 second watchdog instead, so that only a machine that is stalled
that long can trigger it. The timeouts whose expiry is the expected outcome
of the test are left as they were.
Aki Tuomi [Fri, 1 May 2026 19:41:50 +0000 (22:41 +0300)]
config: Implement heredoc support
Adds <<MARKER heredoc syntax to the Dovecot config file format,
allowing multi-line values to be set without escaping:
key = <<EOD
line one
line two
EOD
The marker is arbitrary (any C identifier) and is preserved through
the binary config format so doveconf reproduces the exact marker in
its output (e.g. <<FOO → doveconf outputs <<FOO).
Storage: values set via heredoc are stored with prefix byte
CONFIG_VALUE_PREFIX_HEREDOC (4) followed by the marker, a newline,
and the value. SET_FILE settings use HEREDOC|FILE_INLINE (12) to
preserve the marker while flagging the content as inline.
The "inline:" assignment form (ssl_ca = inline:cert) is still
accepted for backward compatibility but is now stored as
CONFIG_VALUE_PREFIX_FILE_INLINE (8). doveconf always outputs
SET_FILE inline content using heredoc syntax; the "inline:" prefix
is no longer emitted. The "inline:" is still accepted, and not
deprecated from passdb/userdb result.
Aki Tuomi [Fri, 1 May 2026 19:41:38 +0000 (22:41 +0300)]
config: Replace config_value_prefix #defines with enum
Replaces the three CONFIG_VALUE_PREFIX_* character constants with a
proper enum config_value_prefix using powers-of-two values so that
prefix flags can be combined with bitwise OR.
Also adds CONFIG_VALUE_PREFIX_FILE_INLINE (8) for SET_FILE inline
content, allowing it to be stored directly without the previous
\001\n two-byte hack. The HEREDOC|FILE_INLINE (12) combination
preserves both the heredoc marker and the inline-content flag.
Aki Tuomi [Tue, 14 Jul 2026 07:12:02 +0000 (07:12 +0000)]
m4: Require signed time_t
An unsigned time_t is not forbidden by POSIX, but it is essentially
nonexistent on POSIX systems. Requiring a signed time_t simplifies
overflow checks in time arithmetic.
Aki Tuomi [Sat, 11 Jul 2026 18:53:40 +0000 (18:53 +0000)]
lib: ioloop - Use time_max_safe_value() for infinite-wait sentinel
Reuse the shared bound instead of duplicating the TIME_T_MAX_BITS
computation. Widens the sentinel from 2^(TIME_T_MAX_BITS-1)-1 to
2^TIME_T_MAX_BITS-1, matching time_max_safe_value()'s bound.
Aki Tuomi [Sat, 11 Jul 2026 17:54:16 +0000 (17:54 +0000)]
lib-imap: Use time helpers for date arithmetic overflow checks
tm_is_too_large() now shares its max-time_t bound via
time_max_safe_value() instead of duplicating the #if TIME_T_MAX_BITS
block. imap_parse_datetime() and imap_to_datetime_tz() use
time_sub_secs()/time_add_secs() instead of unchecked time_t
arithmetic when applying the timezone offset.
Adding/subtracting seconds to/from a time_t without an overflow check
is a recurring bug source (e.g. Retry-After parsing). Add shared
helpers that reject out-of-range results instead of invoking UB.
Aki Tuomi [Mon, 13 Jul 2026 21:09:14 +0000 (21:09 +0000)]
lib-http: Document why client response parser is lenient
Record why http_response_parser_init() is intentionally not passed
STRICT here: the client parses responses from arbitrary upstream
servers, some of which send legitimately non-compliant framing, and
the response is consumed internally rather than re-emitted
downstream, so the response-splitting precondition that STRICT
guards against doesn't apply to this connection's use.
Aki Tuomi [Mon, 13 Jul 2026 21:09:07 +0000 (21:09 +0000)]
lib/uri-util: Fix out-of-bounds reads on unterminated buffers
uri_parse_ip_literal() scanned for the closing ']' with memchr()
starting at cur+1 but passing length end-cur, one byte too many.
uri_parse_pct_encoded_data() dereferenced both bytes after a '%'
before checking either against pend.
Both are masked when parsing a '\0'-terminated string (the extra
read lands on the terminator), which covers every caller currently
in tree, but uri_parser_init_data() is public API that allows a
bounded, non-terminated buffer, so a real out-of-bounds read is
possible via that path.
Add a regression test using uri_check_data() with an exact-size,
non-terminated heap buffer so valgrind can catch a reintroduced
overread; a '\0'-terminated test table would pass unchanged whether
or not the bug is present.
Aki Tuomi [Mon, 13 Jul 2026 21:08:55 +0000 (21:08 +0000)]
lib-http: Reject bare LF as line terminator in strict mode
Every CR-handling state in the header parser, request-line parser
and chunked transfer parser treated CR as optional, so a lone LF
always terminated the line even with STRICT set. This let an
untrusted peer choose between CRLF and LF at each line boundary,
which is a request-smuggling-relevant desync risk against an
upstream that requires full CRLF.
Add a strict flag to the chunked transfer istream (previously it had
no strict/lenient distinction at all) and thread it from the message
parser, so server-side (request) parsing rejects bare LF while
client-side (response) parsing stays lenient, matching the existing
header parser split.
Aki Tuomi [Tue, 14 Jul 2026 05:55:27 +0000 (05:55 +0000)]
lib-smtp: address - Fix assert on empty broken localpart
smtp_address_parse_path_full() with an end pointer and
ALLOW_BAD_LOCALPART could leave the separator search pointer one before
the parse position for an empty address (e.g. a leading space),
underflowing into p_strdup_until() and aborting with an assertion. Clamp
the pointer to the parse position. Add a test covering this case.
Aki Tuomi [Tue, 14 Jul 2026 05:52:10 +0000 (05:52 +0000)]
submission-login: proxy - Fail cleanly on malformed AUTH reply
When the backend answered the proxied AUTH command with a malformed
status line, the AUTHENTICATE state broke out into the shared failure
block, which assumes proxy_reply was already created. That assumption
does not hold for an invalid line, so the login process aborted with an
assertion. Handle the invalid line as a protocol failure and return
immediately instead.
Aki Tuomi [Tue, 14 Jul 2026 05:47:11 +0000 (05:47 +0000)]
submission-login: proxy - Cap AUTH reply continuation lines
A malicious or compromised backend could answer the proxied AUTH command
with an endless multi-line reply, growing proxy_reply without bound until
the login process ran out of memory (only loosely bounded by the login
timeout). Limit the number of continuation lines and fail the proxy
connection with a protocol error when the cap is exceeded.
Markus Valentin [Mon, 13 Jul 2026 13:46:32 +0000 (15:46 +0200)]
lib: ostream-multiplex - Fix panic when destroying corked channel 0 after parent failed
If the parent ostream fails (e.g. EPIPE when the client disconnects)
while the multiplex has corked it for sending, the uncork at the end of
o_stream_multiplex_sendv() is a no-op on the failed stream, so
parent_corked stays TRUE. Destroying the still-corked channel 0
afterwards tried to transfer the cork back to the parent and panicked.
Fixes:
Panic: file ostream-multiplex.c: line 518 (o_stream_multiplex_try_destroy): assertion failed: (!mstream->parent_corked)
Timo Sirainen [Fri, 10 Jul 2026 07:08:48 +0000 (07:08 +0000)]
doveadm: fs delete - Fix crash with concurrent async deletes
When deleting multiple objects asynchronously (doveadm fs delete -n), an
error on one file could leave another file's async lookup still running
when it was deinitialized. The still-pending EAGAIN was masked by the
errored file's ret=-1, and the drain loop in
doveadm_fs_delete_async_finish() was gated on doveadm_exit_code==0, so it
skipped draining once any file failed. Deinitializing a file mid-lookup
tripped the object_id_lookup_state != RUNNING assert in fs-dictmap.
Track pending separately from ret so it's no longer masked, and always
drain pending async operations before deinit, regardless of errors. Also
deinit files that hit a terminal error immediately, so they aren't
retried - otherwise the retry restarts their async lookup and keeps the
drain loop pending forever.
rootvector2 [Thu, 28 May 2026 06:34:25 +0000 (12:04 +0530)]
lib-oauth2: jwt - Avoid use-after-realloc of jwt_node front pointer
oauth2_jwt_copy_fields() captures subroot from array_front(&nodes)
then calls array_append_space(&nodes) inside the inner loop. When the
append cannot extend the buffer in place it relocates it, so subroot
aliases the previous slot and later subroot->prefix / subroot->array
reads operate on the abandoned location. Refresh subroot after the
append.
Aki Tuomi [Mon, 13 Jul 2026 05:27:23 +0000 (05:27 +0000)]
config: Delay module dlclose() until after lib_deinit()
module_dir_unload() ran deinit() and dlclose()d config modules before
master_service_deinit()/lib_deinit(). If a module (e.g. the
managesieve settings plugin, which pulls in libdovecot-sieve.so and
registers an event category) got dlclose()d, its memory was unmapped
before lib_event_deinit()'s fuzzing-build category cleanup ran,
causing a crash on process exit.
Split module_dir_unload() into module_dir_deinit() (while lib is
still alive) followed by master_service_deinit(), and only then
dlclose() the modules via module_dir_unload().
Hector Cao [Sat, 11 Jul 2026 08:04:51 +0000 (10:04 +0200)]
lib-ssl-iostream: Don't call OPENSSL_cleanup() for openssl 4.0
With OpenSSL 4.0 the lib-dcrypt unit tests (test-crypto, test-stream)
segfault at process exit.
dovecot_openssl_common_global_ref() installs custom OpenSSL memory
functions via CRYPTO_set_mem_functions(); their code lives inside the
dynamically loaded OpenSSL/dcrypt module. It also passes
OPENSSL_INIT_NO_ATEXIT so that libcrypto does not register its own
atexit(OPENSSL_cleanup) handler.
dovecot_openssl_common_global_unref() then called OPENSSL_cleanup()
explicitly. On OpenSSL 4.0 this explicit cleanup re-arms an internal
exit-time cleanup that runs after main() returns [1], i.e. after the module
(and therefore the custom memory functions) has been dlclose()d, so
libcrypto calls the now-unmapped free() function and the process crashes
with SIGSEGV.
Aki Tuomi [Fri, 10 Jul 2026 07:49:32 +0000 (07:49 +0000)]
lib-http: chunked - Fix UB on chunk-size overflow
Overflow check ran after the shift, so chunk_size <<= 4 itself
overflowed once the top 4 bits were set, triggering UBSan. Check
before shifting instead, same as str_parse_uintmax_hex() in strnum.c.
Add a test case with an oversized chunk-size to catch this.
Timo Sirainen [Thu, 9 Jul 2026 20:29:29 +0000 (20:29 +0000)]
lib: ostream-multiplex - Fix busy-loop on a single sendv larger than IO_BLOCK_SIZE
o_stream_multiplex_ochannel_sendv() grows the effective avail to
IO_BLOCK_SIZE when the channel buffer is small and the parent has no
un-flushed data, so a max_buffer_size==0 parent (which always reports
avail==0) can still be written to. But the following "avail < total"
block then re-read avail from the real buffer (0 again) and returned 0,
discarding the grown value. When a single sendv was larger than
IO_BLOCK_SIZE this buffered nothing and returned 0, so the caller made
no forward progress.
With o_stream_send_istream() of an in-memory istream - which returns all
of its data in one read - a field larger than IO_BLOCK_SIZE is sent as
one oversized sendv. The channel kept accepting nothing while the parent
socket stayed writable, so the flush was rescheduled immediately and the
process busy-looped at ~100% CPU.
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.