Release 2026.09.29-b90d8bc1
Breaking Changes
- kumo.configure_local_logs
now enforces a
max_record_size
(default 128 MiB): a record whose serialized content reaches the limit is
dropped rather than written, counted by the
log_record_dropped_too_largemetric and a rate-limited error log. Previously every record was written regardless of size. The default matches the reader'smax_line_size(also 128 MiB), keeping every written record readable. Before this version the reader was fixed at roughly 128 KiB and could not be adjusted, so a record larger than that was silently unreadable. Raisemax_record_sizeto keep larger records. - kumo.jsonl.new_writer's
write_lineandwrite_recordnow raise an error for a record that reachesmax_record_size(default 128 MiB) instead of writing it. The default matches the reader'smax_line_size, keeping every written record readable; before this version the reader was fixed at roughly 128 KiB and could not be raised.
Other Changes and Enhancements
Fixes
-
Fixed a panic when re-serializing a message whose multipart
Content-Typedoesn't declare a usable boundary. A crafted message passing throughcheck_fix_conformanceor the HTTP injection API could trigger this issue. We now treat an empty or whitespace boundary as invalid, record theMIME_INVALID_BOUNDARYconformance issue, and preserve the part verbatim instead of splitting it into nonsensical parts. Another path leading to this is if you had configured DSN generation and the message to be included in the report was malformed. That message is now preserved verbatim in the report. A message that cannot be serialized is omitted rather than failing report generation. -
Fixed incorrect matching when evaluating an SPF record with a
/0prefix, or a CIDR length wider than the address family allows (for exampleip4:0.0.0.0/33). A/0prefix now correctly matches every address, and an out-of-range CIDR length is rejected when the record is parsed. Previously such records matched the wrong (sometimes broader) range of addresses without any error, yielding an incorrect SPF result. While SPF records are fetched from DNS (and thus remotely controlled), the sending domain owns that DNS record chain and could already authorize arbitrary senders, so this issue didn't allow an unrelated party to subvert the SPF authorization check. -
kumo.jsonl.new_tailer no longer discards the rest of a log segment when it meets a record larger than the fixed zstd output block. Previously the decompression buffer was fixed at that block size (roughly 128 KiB) with no way to adjust it, so any larger record stalled decompression and the tailer skipped the rest of the segment. The buffer now grows on demand to read records up to the new configurable
max_line_size(default 128 MiB). A record larger than that limit is dropped, and the rest of the segment is still read. -
Fixed line-length enforcement for messages that use bare LF (or CR) line endings under
invalid_line_endings="Fix"or"Allow". Such a message was rejected as having an over-long line even when its lines were within the limit. It is now measured and accepted correctly. -
RFC 3464 report parsing no longer embeds a copy of the entire message in its error context on failure. In most cases this error context was discarded, but if your policy script triggered an explicit parse, you might see the error context in the lua error that it would trigger in that case. Since it was not very useful we now simply report the input message size for the context instead. The actual parse error is still the primary reason in the error chain, so this is not a loss in information.
-
Fixed quadratic CPU cost when repairing the line endings of an inbound SMTP message under
invalid_line_endings="Fix". The repair inserted each missing CR or LF into the message buffer one at a time. Each insertion shifted every byte after it down the buffer, an O(N) cost. A message of N bare newlines needed N such insertions, for O(N^2) work overall. The repair now builds the normalized copy in one linear pass. -
Fixed quadratic CPU cost when rebuilding a message whose
Content-TypeorContent-Dispositionheader contains many parameters. Three steps each rescanned the whole parameter list once per parameter, for O(N^2) work in the parameter count N: decoding the parameter map and re-emitting the header, then merging the original parameters back into the rebuilt header. A crafted header with a large parameter count could pin a CPU for the duration. This is reached whenever a message is rebuilt: throughmsg:check_fix_conformanceduring reception, and through the/api/inject/v1HTTP endpoint when submitting a full RFC822 message. The parameters are now grouped and merged in single passes. -
Capped the number of headers accepted from a header block at 1000, putting an upper bound on the memory a crafted message can consume when parsed. A block that exceeds the cap is rejected by the parser as malformed. Real mail messages are far below this limit, so this should not inhibit real messages.
-
A MIME header parameter that is specified more than once (a malformed header, such as a
Content-Typewith twocharsetparameters) now takes the last value rather than concatenating the values. RFC 2231 continuation sections (name*0,name*1, ...) are unaffected and still combine in order. -
Hardened
Authentication-Resultsencoding against header injection. Several sources can put control characters into values reaching the encoder: a sender-controlled DMARC record whose tag values flow intopolicy.*properties, the multi-line diagnostic that a malformed inboundARC-Authentication-Resultsheader (such as the non-standardaction=nonetoken some providers emit) leaves in the ARCreason, and values parsed from an existing header and re-emitted during ARC sealing. An embedded CR/LF could split the trustedAuthentication-Resultsheader that kumod adds, forging a result line or pushing real headers into the body against downstream systems. The encoder now drops control characters from every value it emits and reduces each property key to the characters a valid key may contain, closing off header injection at the point where every value is serialized rather than at each producer. #523. Thanks to @raphting! -
Fixed a remotely triggerable panic in MTA-STS policy handling. A destination domain could publish a policy with a
max_agelarge enough that computing its expiry overflowed and aborted the process; because the triggering message stayed spooled and was retried, this crash looped on the outbound path. Themax_agevalue is now clamped to the RFC 8461 maximum of 31557600 seconds. -
Fixed a panic that aborted the process when
kumo.fs.glob(orkumo.glob) was passed an absolute pattern containing a**recursive wildcard, such as/opt/kumomta/etc/config/vmtas/**/*.toml. Such patterns now match as expected and return absolute paths. Fixed by upgrading thefilenamegendependency to 0.2.8. #578 -
Fixed corruption of a multipart message whose body begins with a blank line or other preamble text before the first boundary. Adding a missing
Date,Message-ID, orMIME-Versionheader viamsg:check_fix_conformancewrote the\r\nin front of the first boundary back as\n\r, so the boundary no longer started a line and the altered bytes broke DKIM signatures. #607 -
Fixed
msg:check_fix_conformancedropping theContent-Dispositionheader of a text part (such as atext/calendarinvitation) when rebuilding the message. #604 #584 -
Fixed a remotely triggerable panic in DKIM verification. A message with two or more
DKIM-Signatureheaders whoseb=tags differed in length, one of them shorter than eight characters, panicked with an out-of-bounds read while computing theheader.bauthentication result. This was reachable on inbound mail throughmsg:dkim_verify(). The signature-count limit that caps verification work per message now also counts successfully parsed signatures, not only signatures that failed to parse. -
Fixed an integer underflow when re-encoding a MIME parameter (such as a
Content-Typeparameter) whose name was long enough that the fold framing exceeded the target line width. This panicked in debug builds and produced a corrupt fold width in release builds. #608 -
Fixed a panic when folding a header line that contained a single run of text longer than the wrap limit with multi-byte (non-ASCII) characters, such as an internationalized address or a long UTF-8 word passed to
string.wrap(). The over-long run was split byte by byte, which could cut a UTF-8 sequence in half and panic when the result was validated as UTF-8. It is now split on character boundaries. -
Rebuilding a message header now recognizes
Authentication-Resultsas a structured header, re-encoding it in canonical form rather than leaving it untouched as free text. -
Constructed messages, including those built by the HTTP injection API, now emit the
MIME-Versionheader with its uppercase spelling rather thanMime-Version. Both are valid per RFC 2045, but some spam filters such as rspamd score the mixed-case form. #564 -
Hardened display-name encoding when building messages: a CR or LF embedded in an address header display name (such as
From,To, etc.), is now rewritten to a space to prevent it from splitting the header and injected a spurious header. This is a robustness fix rather than a privilege escalation, as both the injection API and Lua policy already compose arbitrary message content and headers anyway. This is only applicable to code that directly or indirectly calls into the builder API. -
The HTTP injection API now encodes address headers correctly when the display name is non-ASCII. Given
Cc: 山田 <user@example.com>, it now emits the name as an encoded-word and leaves the address untouched (=?UTF-8?q?...?= <user@example.com>); previously it encoded the whole value, address included, producing an invalid header. #598 -
Fixed a process-terminating stack overflow when parsing deeply nested comments in structured message headers.
-
Deeply nested MIME content is now retained as an opaque part after 100 levels instead of overflowing the process stack. See the
MIME_NESTING_LIMIT_EXCEEDEDconformance flag. -
An improperly formed
idparameter in an XFER payload could cause kumod to panic. It now reports an error back to the injecting peer instead.