<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Rspamd Changelog</title>
    <link>https://docs.rspamd.com/changelog</link>
    <description>Release notes and changelog for Rspamd spam filtering system</description>
    <language>en</language>
    <lastBuildDate>Sat, 10 Oct 2026 21:42:32 GMT</lastBuildDate>
    <atom:link href="https://docs.rspamd.com/rss/changelog.xml" rel="self" type="application/rss+xml"/>
    <item>
      <title>Rspamd 4.2.2 - Patch Release with Multi-Currency Wallet Detection, MIME Email Alerts and a lower_utf8 Heap Overflow Fix</title>
      <link>https://docs.rspamd.com/changelog/4.2.2</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.2.2</guid>
      <pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.2.2 - Patch Release with Multi-Currency Wallet Detection, MIME Email Alerts and a lower_utf8 Heap Overflow Fix</h2><h3>Security</h3><ul><li>**Heap overflow in `lower_utf8` fixed**: The output buffer was sized to the input length, but U+023A (Ⱥ) and U+023E (Ⱦ) grow from 2 to 3 bytes when lowercased. ICU&apos;s `U8_APPEND` does not check the capacity for ASCII characters, so once the buffer was full, every ASCII byte after that point was written past its end. `lower_utf8` is applied to attachment file extensions by `lua_stat` and by the `lower_utf8` selector transform, so the input comes from the scanned message</li></ul><h3>Changed</h3><ul><li>**The line break before a MIME boundary is no longer part of the preceding part**: RFC 2046 section 5.1.1 assigns the CRLF before a boundary delimiter to the delimiter, not to the part it closes. Every child of a multipart kept that line break. As a result, a quoted-printable attachment ending in a soft line break, for example, decoded with an extra CRLF (163 bytes instead of 161). Parts now end where the RFC says they do. **The message digest and the digests of non-base64 parts change for multipart messages.** Data stored under the old digests will not match the same content after the upgrade, for example fuzzy hashes learned from non-base64 attachments. Base64 parts keep their digests</li></ul><h3>Added</h3><ul><li>**Cryptocurrency wallet detection for many currencies**: `rules/bitcoin.lua` has been replaced by `rules/crypto.lua`, which is built on a new `lua_crypto_addresses` library:</li><li>**Checksum-verified currencies**: Bitcoin and its forks (Base58Check, Bech32, Bech32m and CashAddr, which covers Bitcoin Cash), Litecoin, Dogecoin, Tron, XRP, Zcash transparent addresses, Cardano, Cosmos, Stellar and TON. Ethereum/EVM and Monero addresses are matched by format only.</li><li>**Symbols**: each currency has its own symbol: `BITCOIN_ADDR`, `LITECOIN_ADDR`, `DOGECOIN_ADDR`, `TRON_ADDR`, `XRP_ADDR`, `ZCASH_ADDR`, `CARDANO_ADDR`, `COSMOS_ADDR`, `STELLAR_ADDR`, `TON_ADDR`, `ETHEREUM_ADDR_MAYBE` and `MONERO_ADDR_MAYBE`. `CRYPTO_ADDR_CHECK` carries the combined options.</li><li>**Independent symbols**: these are callback symbols, not virtual children of `CRYPTO_ADDR_CHECK`. Settings that disable a virtual symbol also disable its parent, so with virtual children, quieting `ETHEREUM_ADDR_MAYBE` silently turned off `BITCOIN_ADDR` and scam detection, and enabling one currency emitted all of them. Each symbol now stands on its own. The message is still scanned only once per task, and all symbols and the selector share the result.</li><li>**Stricter validation**: a correct checksum only proves that a string was not corrupted, so impossible addresses are now rejected. This covers extra leading zero symbols in Base58Check, Cardano addresses that are not mainnet Shelley or have the wrong payload length for their type, Cosmos payloads of the wrong size, and CashAddr version bytes with reserved bits or mismatched hash sizes.</li><li>**Scam detection for every verified currency**: `LEAKED_PASSWORD_SCAM_RE` and the `LEAKED_PASSWORD_SCAM` composite now fire for any checksum-verified currency, not only Bitcoin ([#6294](https://github.com/rspamd/rspamd/pull/6294))</li><li>**Bounded wallet scanner that finds split addresses**: The scanner stays within fixed limits on hostile input. These are exposed as `lua_crypto_addresses.limits`:</li><li>a budget of checksum decodes per scan and per part;</li><li>a cap of 1024 addresses;</li><li>text searched in overlapping 64 KiB slices, so the match table stays bounded for any body size.</li><li>**`crypto_addresses()` selector**: Returns the wallet addresses found in a message, ready to be matched against a map or looked up in DNS. An optional first argument restricts the result to one currency. Passing `typed` as the second argument returns `currency:address` values. The selector computes the addresses itself, so it does not depend on `CRYPTO_ADDR_CHECK` ([#6294](https://github.com/rspamd/rspamd/pull/6294))</li><li>**metadata_exporter custom variables and selector expansion**: A new `custom_variables` block defines Lua functions that are evaluated for each message, like `custom_select` and `custom_format`. They can be referenced as `$name` or `${expr}` in `mail_from`, `mail_to`, `helo`, `channel`, `stream_key` and the `email_template`. An `${expr}` that matches no variable is evaluated as a selector expression. Only these routing fields are expanded, never connection, credential, timeout or boolean options. Message data can therefore decide who receives an alert, but not where a rule connects or how it authenticates. The expansion is also guarded against injection:</li><li>expanded addresses that do not parse are dropped instead of sent;</li><li>values substituted into headers are collapsed to one line, so CR/LF in a value cannot add a header such as `Bcc`;</li><li>placeholders are expanded in a single pass, so message text that looks like `${...}` is never compiled as a selector.</li><li>**Multipart email alerts from `email_parts`**: `email_alert` can now build a multipart message, so `email_template` no longer needs a hand-written boundary.</li><li>**Parts**: each `email_parts` entry takes either literal or templated `content`, or a raw `content_from_variables` body for binary data. It also takes a `content_type` and optional `filename`, `disposition` and `encoding`. `email_parts_type` selects the multipart subtype.</li><li>**Template body**: the template&apos;s own body becomes the first part and keeps the Content-Type and transfer encoding it declares.</li><li>**Transfer encodings**: these are chosen defensively. Content that is not safe as declared is sent as quoted-printable or base64.</li><li>**Filenames**: long or non-ASCII filenames are RFC 2231 encoded and split across continuation parameters.</li><li>**`auto_grouping`** (on by default): an inline plain text part and an inline HTML part are wrapped together in `multipart/alternative`, with the plain part first. If there are no other parts, the alternative becomes the whole message. More than one candidate of either kind is a configuration error. Set `auto_grouping = false` to keep a flat layout ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**Non-ASCII headers encoded in email alerts**: Headers rendered by `email_alert` are folded and RFC 2047 encoded where needed, so non-ASCII text in the template or in expanded values produces valid MIME. In address headers, only display names, group names and comments are encoded, and addresses are kept byte for byte. If a header would still carry 8-bit data, for example a non-ASCII address, the alert is stopped rather than sent with invalid headers. Headers that need no encoding are kept exactly as written. The `email_auto_encode_headers` option controls this and is on by default ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**metadata_exporter rules validated against a schema**: Rules and `email_parts` entries are checked against a schema when the configuration loads. Invalid rules are reported as configuration errors, so `rspamadm configtest` fails instead of the rule breaking at runtime. Examples are unknown keys, both or neither of `content` and `content_from_variables`, bad custom callbacks, and a literal `mail_from` that does not parse. Rules for `custom_push` backends may still carry keys of their own ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**EHLO and 8BITMIME in lua_smtp**: `lua_smtp`, which sends metadata_exporter&apos;s email alerts, now sends EHLO first and falls back to HELO if EHLO is rejected. It reads multi-line replies to the end before deciding. When the server advertises 8BITMIME, MAIL FROM carries `BODY=8BITMIME`. The keyword is matched exactly, so `X8BITMIME` does not count. If 8BITMIME is not advertised, RFC 6152 requires the client to convert the message to 7-bit, and that conversion now happens before MAIL FROM:</li><li>8-bit text parts are re-encoded as quoted-printable;</li><li>other parts are re-encoded as base64;</li><li>nested `message/*` entities are converted recursively.</li><li>**RFC 5322 folding for unstructured headers**: The existing folding function is designed for structured headers. It folds after `,` or `;` even when no whitespace follows, and it replaces the whitespace at the fold with a tab. Both change the text of a Subject or other free-text header once it is unfolded. The new `rspamd_header_value_fold_unstructured`, available in Lua as `util.fold_header_unstructured(name, value[, how])`, inserts line breaks only before existing whitespace. Unfolding therefore gives back the original value byte for byte. Existing callers are unchanged ([#6293](https://github.com/rspamd/rspamd/pull/6293))</li><li>**`smtp` newline normalisation mode**: `normalize_newlines(&quot;crlf&quot;)` turns a bare LF into CRLF but keeps a bare CR. SMTP allows CR and LF only as a CRLF pair. A bare CR left in DATA lets `&lt;CR&gt;.&lt;CR&gt;&lt;LF&gt;` slip past dot-stuffing on relays that treat it as the end of data. The new `smtp` mode turns every bare CR and every bare LF into CRLF ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li></ul><h3>Fixed</h3><ul><li>**No late HTTP error callback after keepalive is declined**: When `rspamd_http_context_push_keepalive()` declined to pool a connection, for example because the peer did not send `Connection: keep-alive`, it marked the connection as finished. The finish handlers then reset the connection unconditionally, which cleared that flag again. In addition, the socket read error branch called the error handler without checking the flag. Together, these let a connection whose callback context had already been freed receive a late error callback. The worker then crashed with SIGSEGV when a keepalive-enabled Lua HTTP client talked to a peer that closes the connection, such as an HTTP/1.0 server ([#6288](https://github.com/rspamd/rspamd/pull/6288))</li><li>**Tables pasted from Google Sheets no longer treated as hidden**: In table layout, width and height on a table, row or cell are only lower bounds, and the content decides the actual size. A zero width on a table, which Google Sheets produces, was passed down to the cells. Combined with the cells&apos; own `overflow:hidden`, it made all cell text invisible, so legitimate messages scored HIDDEN_TEXT and MANY_INVISIBLE_PARTS. Tables and their parts no longer inherit width and height from their parent, either during propagation or when sizes are resolved after a stylesheet merge. An explicit zero size on a cell still counts as clipping ([#6287](https://github.com/rspamd/rspamd/issues/6287))</li><li>**Visible link text terminated after normalisation**: The displayed-URL check trims and normalises the link text in place but never terminated the result. Whenever a zero width character was removed, `url:get_visible()` returned stale trailing bytes: `https://pay&lt;ZWSP&gt;pal.com` became `https://paypal.comcom`. This broke the phishing check for redirected links</li><li>**Invisible characters in hostnames flagged instead of truncating the URL**: Only U+200B, U+200C, U+200D, U+FEFF and U+00AD were accepted in a hostname. Browsers ignore other invisible characters, such as U+2060 WORD JOINER, variation selectors and tag characters, and open the real host. Rspamd instead cut the URL short in text (`http://exa&lt;WJ&gt;mple.com` gave the host `exa`) or dropped it from hrefs. Default-ignorable code points are now accepted in hostnames and stripped before nameprep, and the URL is flagged with `zw_spaces`. Bidi controls are the exception, because they delimit URLs in right-to-left text. Other edge cases:</li><li>Invisible characters after the hostname end the URL without a flag.</li><li>A variation selector after a non-ASCII character, as in an emoji, is not flagged.</li><li>Invisible bytes do not count toward the hostname length limit; the raw hostname is limited to 1012 bytes.</li><li>**U+FEFF stripped during Unicode normalisation**: The zero width set contained U+0FEF, an unassigned code point, instead of U+FEFF ZERO WIDTH NO-BREAK SPACE. A byte order mark inside a URL path, query, fragment or link text was therefore neither stripped nor reported as a zero width space</li><li>**URL_OBFUSCATED_TEXT reports only the obfuscated URL**: The rule normalised 64 bytes before and 256 bytes after each pattern match and reported the first domain it found anywhere in that window. Any address nearby in plain sight was reported as obfuscated, such as a quoted mailing list address, a signature link or the recipient&apos;s own domain. Over 30 days on one production node, every `html_entity` hit was such a domain. The symbol also fired once per quoted copy, so a single list thread stacked it far above any other evidence. The rule is now anchored to the matched URL:</li><li>The window starts at the host before the match, the URL must begin the normalised text, and the obfuscation must lie inside the extracted URL.</li><li>Prose such as &quot;the dot food co-op&quot; no longer counts as a spelled-out dot.</li><li>Spaced protocols such as `h t t p s : / /` are handled.</li><li>Complete zero width sequences are stripped rather than individual bytes.</li><li>The symbol is now one-shot ([#6291](https://github.com/rspamd/rspamd/pull/6291))</li><li>**Crypto scam composite gated on checksum-verified addresses only**: `ETHEREUM_ADDR_MAYBE` accepts any `0x` followed by 40 hex characters. A SHA-1 hash in a technical post or a quoted constant therefore opened the `LEAKED_PASSWORD_SCAM` composite (7.0). The other side of the composite is satisfied by a List-Unsubscribe header or the word &quot;password&quot;, so ordinary list mail went from 3.19 to 10.19. Now only checksum-verified currencies feed `LEAKED_PASSWORD_SCAM_RE` and the composite. A List-Unsubscribe header alone still gates Bitcoin, as it always did, but not the newly added currencies. The word &quot;wallet&quot; is no longer a scam signal for any currency, Bitcoin included, because every deposit, withdrawal or payment notice that names an address also says &quot;wallet&quot;. A withdrawal confirmation to a Tron or Bitcoin address used to score 9.20. &quot;password&quot; and the sextortion phrases remain signals ([#6294](https://github.com/rspamd/rspamd/pull/6294))</li><li>**The MIME header encoder emits valid RFC 2047 encoded-words**: `rspamd_mime_header_encode` produced output that decoders misread:</li><li>An encoded-word was glued to the ASCII part of its word: `Pr=?UTF-8?Q?=C3=BCfung?=` decodes as &quot;Pr üfung&quot;.</li><li>Adjacent encoded-words had no whitespace between them.</li><li>Structured mode encoded every ASCII special even in 7-bit values, so &quot;John via list &lt;a@b.c&gt;&quot;, as produced by DMARC From munging, lost its address.</li><li>**lua_smtp dot-stuffs DATA and accepts more server replies**: A body line consisting of a single `.` was sent unstuffed. The receiving MTA took it as the end of DATA, so any message containing such a line, for example a diff or a text divider, was silently truncated. Lines starting with `.` are now stuffed, including across chunk boundaries. A bare CR now becomes CRLF, so `&lt;CR&gt;.&lt;CR&gt;&lt;LF&gt;` cannot slip past dot-stuffing. RFC 5321 allows a reply line that is only the code (`250`); such a line no longer aborts the send. A multi-line `220-` greeting is now read to its last line before EHLO. Previously, every later reply was read one line behind ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**Deferred DNS retried for HTTP upstreams**: `http_upstreams_by_url` in `lua_util` created upstream lists without the configuration, so they had no upstream context. If the DNS lookup at startup failed, they stayed pending forever, because the deferred resolve timer only runs for upstreams owned by that context. The GPT plugin, AWS S3, the HTTP maps backend and the DKIM vault integration stayed broken until a restart. The function now takes an optional configuration argument, falling back to the global `rspamd_config`, and these callers pass it ([#6285](https://github.com/rspamd/rspamd/issues/6285))</li><li>**`callback_from_string` compiles whole-chunk callbacks**: The new `return_chunk` parameter compiles a string as it is, and the string must itself return a function, as in `local x = ...; return function() ... end`. Previously, every string was wrapped as a single expression. metadata_exporter&apos;s custom callbacks now compile this way, which fixes snippets with setup statements being wrapped incorrectly. Non-string input is rejected with an error instead of crashing. Syntax errors, runtime errors and non-function results are all reported as error strings ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**`task:get_symbol()` with a table of names**: When the requested symbols were present, the results were stored at a relative stack index that pointed at the symbol name rather than the result table. The call then failed with &quot;attempt to index a string value&quot;. It now uses the absolute index of the result table ([#6284](https://github.com/rspamd/rspamd/issues/6284))</li><li>**`parse_content_type` keeps `boundary` and `charset` as strings**: `util.parse_content_type` documents both as string fields. Both parameters also stayed in the generic attribute table, and its loop replaced the fields with a table of values, for example for an unquoted boundary ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**known_senders expires senders after `max_ttl`**: `max_ttl` was accepted but never applied, and zset scores recorded when a sender was first seen. In zset mode, the check now refreshes the score of a known sender, treats entries older than `max_ttl` as unknown and removes them on insertion. Bloom filters cannot expire entries, so `max_ttl` applies to zset mode only</li><li>**replies and known_senders honour `reply_sender_privacy`**: The obfuscation calls used `settings.sender_prefix`, so the `reply_sender_privacy*` options were ignored</li><li>**milter_headers skipping for `x-spamd-bar` and the `x-virus` failure branch**: The `x-spamd-bar` routine checked the skip setting of `x-rspamd-bar`, so `local_headers` and `authenticated_headers` never applied to it. The failure branch of `x-virus` tested the clean status instead of the fail status</li><li>**fuzzy_check reads `html_ignore_domains` at rule level**: `html_ignore_domains` was only read inside a rule&apos;s `checks` block. Set directly in the rule, as `html_weight` can be, it was ignored</li><li>**metadata_exporter always sends an integer `MAXLEN` to Redis streams**: The `MAXLEN` argument for `redis_stream` is now always formatted as an integer ([#6292](https://github.com/rspamd/rspamd/pull/6292))</li><li>**No warning for custom actions that have a score**: A custom action defined as an object was recognised only when it had `flags`. An action with only a `score` or `threshold` triggered &quot;unknown element in actions section&quot;</li><li>**Correct epoch labels in `rspamadm fuzzy_stat`**: The statistics arrays start at the 1.0 epoch, but the labels assumed they started at 0.6, so every row was labelled with the wrong version. The rows are now labelled `v1.0+`, `v1.7+` and `multi-flag`</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.2.1 - Patch Release with HTTP Memory Safety Fixes, Fuzzy Key Hardening and More Reliable Maps</title>
      <link>https://docs.rspamd.com/changelog/4.2.1</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.2.1</guid>
      <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.2.1 - Patch Release with HTTP Memory Safety Fixes, Fuzzy Key Hardening and More Reliable Maps</h2><h3>Security</h3><ul><li>**Chunked HTTP bodies no longer sized from what the peer announces**: With `Transfer-Encoding: chunked` the parser reports no length when the headers end, so the body storage was created on the first body callback and sized from the parser&apos;s `content_length`. For chunked input that field holds whatever is left of the current chunk, which is a number the peer picked with no received data behind it. A chunk size of `fffffffffffffff1` wrapped the allocation once the string header was added and overflowed the heap on the very first body byte. A merely large chunk size took the process down inside malloc. In both cases the allocation happened before any configured message limit was consulted. The announced length is now only a hint, bounded by `max_size` and by a cap of its own, and the limit is enforced over what has been received plus what is still announced, so an oversized body is refused at its first fragment instead of after it has been allocated for. `on_headers_complete` trusted Content-Length the same way, up to a `max_size` that nothing actually sets. The announced length is now bounded there too, and larger bodies grow geometrically rather than being allocated in one go ([#6268](https://github.com/rspamd/rspamd/pull/6268))</li><li>**Zero-copy HTTP reads kept inside the message body**: A zero-copy read lands in the message&apos;s own body storage. Its window was sized by the room left in that storage rather than by what the peer still had to send, so the bytes after the message were read into it as well: chunk framing, or a pipelined request. A completion handler that keeps the message but replaces its body then freed the storage the parser was still walking, which caused a heap use-after-free inside `http_parser_execute`. The proxy does exactly this when it decompresses a reply. Both arming sites now bound the window by the announced remainder, so a message can never complete with bytes left in its buffer, and parser callbacks return cleanly when the message they belong to has already been released ([#6268](https://github.com/rspamd/rspamd/pull/6268))</li><li>**fstring lengths that cannot be allocated refused**: An fstring is allocated as its payload plus the header, and that sum was never checked. A length within a header&apos;s size of `SIZE_MAX` wrapped it, so malloc was called with a tiny size or with zero while the string recorded the enormous capacity it had asked for. Every write through such a string then went out of bounds, starting with its own header. These lengths are now refused like any other allocation that cannot be satisfied, and every intermediate in the growth arithmetic (`len + needed_len`, `allocated * 3 / 2`) is clamped ([#6268](https://github.com/rspamd/rspamd/pull/6268))</li><li>**Cryptobox fails closed on low order public keys**: `crypto_scalarmult` refuses low order public keys. When it did, the shared secret was left uninitialised, then cached and used to decrypt fuzzy requests. `rspamd_cryptobox_nm` now reports the failure and fills the secret with random bytes, so a caller that ignores the result can neither read nor forge anything. The keypair cache never stores such a secret, and fuzzy storage rejects these requests before decrypting them. The keypair cache may now also be absent: fuzzy storage with `keypair_cache_size = 0` used to dereference a NULL cache</li><li>**HTTP messages framed two ways refused**: Transfer-Encoding takes precedence over Content-Length for framing, but the length had still been parsed and was still in the parser when rspamd sized the body storage from it. A message carrying both headers is also a request smuggling vector, which matters for the proxy. The combination is now rejected in either header order. Nothing in rspamd emits Transfer-Encoding itself: the proxy strips it, and ICAP does its own chunking over raw TCP ([#6268](https://github.com/rspamd/rspamd/pull/6268))</li><li>**HTTP lengths checked before they overflow**: The decimal Content-Length and the hexadecimal chunk size were both accumulated first and checked afterwards by comparing the result with the previous value. Unsigned wrapping does not reliably produce a smaller number, so some overflowing lengths passed: a 17-digit chunk size such as `12000000000000000` silently became `0x2000000000000000`. The check now runs before the multiplication, so the accumulator cannot wrap ([#6268](https://github.com/rspamd/rspamd/pull/6268))</li><li>**Fuzzy storage TCP backlog bounded and key expiry enforced for writes**: A TCP client that never read its replies could grow the reply queue without bound. A connection now stops being read while it has 1024 commands in flight or replies still unwritten, and written replies renew the idle timeout. Unauthenticated error replies (415, 500, 503) are metered by the source bucket, so a rate-limited source gets no reply at all. Key expiry is now enforced for every command except ping and stat, including writes and deletes, and is checked against the command timestamp rather than a clock refreshed at irregular moments</li></ul><h3>Fixed</h3><ul><li>**hiredis keeps the socket until async cleanup**: If `TCP_NODELAY` failed after an apparently complete async connect, the socket was closed, and the disconnect path then stopped libev watchers on the closed descriptor. Debug builds hit an assertion abort whenever Redis was down; connecting to a dead server on macOS was enough to trigger it. Closing is now left to freeing the context, and the pending socket error is reported instead of the setsockopt one</li><li>**Last trailer of a chunked message finished, encrypted inner bodies kept free of chunk framing**: The trailer section ends in `on_message_complete` rather than `on_headers_complete`, so the last trailer of a message was never finished: it was lost and leaked once per message. Inside an encrypted request the decrypted body fragments were treated as contiguous. As a result, a chunked inner body reported a range that included the chunk framing and missed part of the payload, and an inner message that stopped mid-framing was accepted because consuming every byte was taken for completion. The inner parser now stops when the first message completes, and the caller requires that point to be exactly the end of the plaintext, so an envelope carries exactly one message with nothing after it ([#6268](https://github.com/rspamd/rspamd/pull/6268))</li><li>**HTTP timeouts applied at the correct I/O stage**: The connect and TLS handshake timeouts are meant to cover only those stages, but their deadline carried over into the exchange that followed. TLS connections restored their read and write handlers with whatever timeout the previous watcher had, so a reply that took longer than the handshake timeout to arrive was cut off. Once the socket is connected and any handshake has finished, the connection now switches to the write deadline once, without extending it on later writes, and reads use the read timeout. A stalled handshake still keeps its own deadline, and a timeout after the handshake is reported as an I/O timeout rather than &quot;ssl connection timed out&quot; ([#6274](https://github.com/rspamd/rspamd/pull/6274))</li><li>**Large chunked HTTP maps no longer read with trailing garbage**: `http_map_finish()` maps the shared memory segment holding the reply and used the segment&apos;s size as the payload length. That segment is a growing receive buffer, at least as large as the body and usually larger, and after the body it holds the raw tail of the last socket read plus zero padding. With Content-Length the buffer happens to be sized exactly, and small chunked replies only carry zeroes that UCL tolerates, so the bug showed on large chunked maps. The garbage reached the map reader and was written into the on-disk map cache, which kept the map broken across restarts until the cache file was removed. The real body length is now used for the payload and for secretbox decryption, and a segment smaller than the body is rejected ([#6261](https://github.com/rspamd/rspamd/issues/6261))</li><li>**Failed map reloads keep the previous map**: Several paths could replace a working map with nothing or with partial data:</li><li>A failed Lua map reload freed the buffer and released the callback of the live callback object that the map still pointed to. Now only the data is dropped.</li><li>When the last backend failed, the consumer was not told and installed partial data in place of the map. The failure is now reported.</li><li>Truncated zstd frames are now refused instead of accepted, and cached map data without flags is checked for zstd the same way the fetcher does.</li><li>HTTP validators are restored when a response cannot be decoded or cached, so the next conditional request does not skip the update.</li><li>The controller&apos;s `/savemap` now writes the whole map; a short write used to replace the map with a truncated one.</li><li>**HTTP cdb maps skip the cache header**: An HTTP cdb map is read from its cache file, where the data follows a 4096-byte header, but the header was opened as the database. cdb maps are now opened at the payload offset. A file backend resets that offset, since one map may mix both kinds of source</li><li>**Shared memory mapping released on map errors**: Every error path taken after the segment was mapped left the mapping behind, including decryption, decompression and cache save failures, because `munmap()` was only reached on success</li><li>**Deduplicated fuzzy weights saturate, short Redis shingle digests rejected**: The sum of weights for deduplicated writes could overflow. It is now computed in 64 bits and clamped to the int32 range. A Redis shingle digest shorter than 64 bytes no longer counts as a match; it was being compared against the uninitialised bytes after its end</li><li>**Fuzzy Redis count scans work over Unix sockets**: The count scan introduced in 4.2.0 kept the selected Redis server only as its display string and passed that back as the request host. For a Unix socket that string is `unix:/path`, which the address parser does not accept, so every SCAN failed with &quot;cannot send SCAN request&quot; and the stored hash count was never refreshed. Requests now use the address object itself. When a pass resumes from a checkpoint, the stored address is parsed back with the `unix:` prefix stripped. If it is invalid, the scanner picks a read server as it would for a new pass ([#6276](https://github.com/rspamd/rspamd/issues/6276))</li><li>**DNS resolver TCP queue, reply handling and retransmits fixed**:</li><li>Queued TCP packets were not counted on enqueue, so the counter underflowed and kept the write watcher armed on an empty queue.</li><li>The last byte of a TCP packet was dropped. A packet is now written only with its length prefix and all of its payload.</li><li>Queued packets are tied to their requests, so a request that times out or is freed drops its unwritten packet. A channel queues at most 1024 packets, and the id search on a TCP channel is bounded.</li><li>A reply that is not truncated but carries malformed records now yields SERVFAIL instead of a partial NOERROR or NOREC. The minimum resource record header length is 10 bytes, not 12.</li><li>On retransmit, the old channel is kept until the new one is retained. Previously it could be freed and then used, or released twice when no server was available.</li><li>**lua_tcp stops after a fatal TLS read and bounds stop-pattern buffering**: After a fatal TLS read the error callback had already drained the handlers, yet processing continued and the read handler&apos;s assertion aborted the worker. It now stops there. A partial read closed from its own callback is no longer rearmed, and the destructor stops the watchers whatever state the descriptor is in. A stop pattern is now searched only in newly arrived data, the data buffered while waiting for it is capped at 16 MiB, and EOF is also reported when the remaining data is shorter than the pattern</li><li>**lua_http and lua_redis request and reply lifetimes**: lua_http read per-request tuning from a call frame that no longer existed once DNS resolution finished; it is now read when the request is made. Requests that fail before their session event is registered are freed, and the coroutine entry is released when the session is cancelled. lua_redis now copies text replies stored for coroutines or returned by the blocking call, because the replies themselves were freed before Lua read them</li><li>**Hyperscan-prefiltered regexps verified per input**: When hyperscan only prefilters a regexp, PCRE has to confirm the match. That confirmation was not repeated for each scanned input, so a hit in one input suppressed matches in later ones. Hit counts now saturate at 255 instead of wrapping, and only new PCRE hits are added; previous hits used to be counted again</li><li>**UTF-8 validated once per regexp search**: An incremental search revalidated every remaining suffix of the text. Validation now happens only on the search that starts at the beginning of the text</li><li>**SPF `a` and `mx` default prefixes per address family, macro digits applied**: Without a prefix length, `a` and `mx` must match the address itself in each family. With `a/24`, IPv6 was left at /0, so any IPv6 client matched the domain&apos;s AAAA records. A bare `a` made IPv6 a /64. Each family now gets a full-length match unless its own prefix length is given. The macro digit transformer was parsed but ignored, so every part was kept; it is now applied</li><li>**DKIM header table leak and `l=` with relaxed body canonicalisation**: The memory pool now owns the header list and table from the moment they are allocated, so a header list without From no longer leaks the table. With `l=` and relaxed body canonicalisation, the check now looks at the part of the body left unhashed, not at the unused part of the `l=` length</li><li>**IPv6 URL hosts bounded, mapped loopback treated as local**: A URL ending with an IPv6 host read the byte after the input and counted it in the raw length. `::ffff:127.0.0.0/104` is now local like `127.0.0.0/8`, so `forbid_local` catches mapped loopback addresses arriving in AAAA replies</li><li>**url_redirector keys by the raw URL, caps whole chains and resolves relative Location**:</li><li>The cache, the cycle guard and chain deduplication were keyed by the decoded URL. That merged URLs that differ only in encoded delimiters, which are different requests to the server. The requested raw URL is now used, and the cache key version is bumped so old entries are not reused.</li><li>A whole chain, cached hops included, is now capped by `max_chain_length` (16).</li><li>A relative Location header is resolved against the redirecting URL. Previously it ended the chain, or a URL found in its query string was taken as the target.</li><li>**RBL whitelists work for IPv6 addresses**: RBL options have the form `&lt;element&gt;:&lt;label&gt;`, optionally followed by `:&lt;dns reply&gt;`. The whitelist callback split them with a regexp that took the first two colon-separated fields, so an IPv6 address such as `2607:f8b0::...` was recorded as element `2607` of type `f8b0`. The lookup by the real address never matched, and the blacklist rules went on to query and score an address the whitelist had just hit. Fields are now taken from the right. The trailing field is the DNS reply when it is a dotted quad, which a check label never is. This went unnoticed because the default whitelists are either IPv4-only or not used for whitelisting at the tiers that cover large IPv6 senders. It showed with custom `is_whitelist` rules, such as the Abusix welcome list, on mail from Gmail over IPv6 ([#6264](https://github.com/rspamd/rspamd/issues/6264))</li><li>**Redis multimaps match IPv6 ULA prefixes**: Redis-backed IP multimaps only enumerated IPv6 masks down to /64, so the RFC 4193 `fc00::/7` ULA range could never match. The HMGET field list now extends to /7 ([#6272](https://github.com/rspamd/rspamd/pull/6272))</li><li>**Avast keeps the cached virus verdict for archives**: Avast replies with one line per scanned object, which for an archive means the infected inner files followed by a clean line for the container. Every line was cached under the same part digest, so the trailing clean line overwrote the verdict with OK, and identical copies passed unscanned for the whole `cache_expire`. Unique threats are now aggregated over the whole reply and yielded and cached once on the final status line. OK is cached only when nothing but clean lines was seen, and threats reported before a failed connection are still yielded but not cached ([#6280](https://github.com/rspamd/rspamd/issues/6280))</li><li>**rar, 7z and libarchive parsing hardened**:</li><li>rar4 parsing checks that the block type byte is present before reading it.</li><li>The 7z names property is bounded by its declared size before the external flag is read and the names are scanned.</li><li>Each 7z streams info section is accepted only once; a repeated substreams section allocated a new folder-sized array each time.</li><li>`archive.unpack` reports a header read error as truncation, while a warning still yields a usable entry, and it checks its deadline while reading a member as well.</li><li>In the same change, the descriptor of an inline `data:` image in HTML moved into the task pool, because the parsed image keeps a pointer to it.</li><li>**7z archives with an unpacked header list their files again**: The substreams sizes list holds every stream of a folder except the last, and the digests list covers streams rather than folders, with a folder defaulting to a single stream. Reading one size too many swallowed the digests marker, so archives with an unpacked header listed no files at all. Sizes and digests are now read per stream</li><li>**HTML link text bounded, JSON attachments no longer sniffed as HTML**: An `&lt;a&gt;` that is never closed spans the rest of the part. With N of them, each link&apos;s text holds the text of every link after it, and both the displayed-URL check and the CTA label copied and normalised that text once per link. A few megabytes of HTML with a few hundred unclosed links kept a worker in `process_message()` for tens of seconds and grew it by gigabytes before any symbol ran. Only the start of the text is ever used: a phishing URL is only accepted at offset 0, and the CTA label is checked for keywords, `!` and its length. Both now look at the first 4096 bytes, without splitting a UTF-8 character. JSON attachments were one way to trigger this. JSON escapes `&lt;/a&gt;` as `&lt;\/a&gt;`, and five links in the first 4 KB were enough for the text part heuristic to call the attachment HTML, which then also scored MIME_BAD_EXTENSION for its `.json` name. The markup patterns are now skipped for `application/json` and `application/*+json`; `application/octet-stream` is still sniffed ([#6271](https://github.com/rspamd/rspamd/pull/6271), [#6270](https://github.com/rspamd/rspamd/issues/6270))</li><li>**Nested HTML processing and structure exports bounded without losing phishing checks**: Cutting link text at 4096 bytes could let a long run of zero-width or transparent padding push the URL out of view. To prevent that, the first meaningful visible byte is now found while the HTML is assembled. Child results are carried up to their parents, so padding is scanned once before the per-link limit applies. CTA scoring traverses visible subtrees only once. Open tag ids, including hashed ids of custom tags, are indexed so unmatched closing tags are rejected without repeatedly walking the ancestors. MIME structure text previews are bounded per tag and per message, with truncation reported, and URL extras are stringified. The Lua UCL serialiser checks text userdata before reading its layout and uses `__tostring` for other supported userdata ([#6271](https://github.com/rspamd/rspamd/pull/6271))</li><li>**Quoted HTML attributes and entity replacements bounded**: Each `&gt;` inside a quoted attribute started a lookahead for the closing quote that rescanned the rest of the input, which is quadratic when there are many of them. The lookahead is now cached. An entity replacement can no longer outgrow the input it consumed; `&amp;nGt;` and similar entities used to overwrite text not yet read and stop decoding. Numeric references now saturate instead of overflowing an int</li><li>**CSS token count bounded, hex escapes no longer swallow the next byte**: The number of tokens is now bounded while the block tree is built; the existing limits only applied after the whole tree had been allocated. A hex escape consumed the byte after its terminating space, swallowing the next token or reading past the end of the view</li><li>**Adjacent encoded words decoded with their own charsets**: Each buffered RFC 2047 encoded word is now decoded with its own charset. Adjacent words in different charsets used to be decoded with the charset of the following word</li><li>**Binary msgpack strings own their value**: Without zero-copy the UCL parser copies every string, so the tree can outlive its input. Binary msgpack strings are not NUL terminated and are copied by the msgpack parser itself, but that copy was only stored in the trash slot while the value still pointed at the input. Reading a binary field after the input buffer was released was therefore a use-after-free. The value now points to the copy and is marked as allocated, a failed allocation ends the parse, and an empty string gets a literal instead of the input pointer</li><li>**msgpack maps ending in an empty value parse**: A zero-length value has no payload, so reading its type consumes the last byte of the document, and the parser&apos;s tail finishes the value. That tail inserted the value with no key. Arrays are unaffected, but a map dropped the key it had just read, and the document was rejected with &quot;cannot insert object with no key&quot;. So `{&quot;b&quot;: &quot;&quot;}` did not parse while `{&quot;b&quot;: &quot;&quot;, &quot;c&quot;: 1}` did. The key is now carried over when the tail finishes a map value</li><li>**Mailchimp exempt from SUBJ_EXCESS_QP and REPLYTO_EXCESS_QP**: Mailchimp&apos;s composer always Q-encodes the Subject and the Reply-To display name, even when they are plain ASCII. On Mailchimp mail the two rules therefore measured the platform rather than the sender, while adding 2.4 to every campaign. On one production host REPLYTO_EXCESS_QP fired 78 times in 30 days, all on Mailchimp mail, and the pair was the deciding factor in sending an authenticated newsletter from a known sender to junk. Both rules are now skipped when `X-Mailer: Mailchimp Mailer` is present. A spammer forging that header gains nothing they could not already get by not encoding the header at all ([#6265](https://github.com/rspamd/rspamd/pull/6265))</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.2.0 - Major Release with Public Suffix Lookup, a Single Symbol Scheduler, Office and SVG Content Extraction, and Bounded Parsing of Untrusted Input</title>
      <link>https://docs.rspamd.com/changelog/4.2.0</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.2.0</guid>
      <pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.2.0 - Major Release with Public Suffix Lookup, a Single Symbol Scheduler, Office and SVG Content Extraction, and Bounded Parsing of Untrusted Input</h2><h3>Changed</h3><ul><li>**One scheduler over the symbol dependency graph** (incompatible): The per-class schedulers — priority-serialised prefilters and postfilters, topologically ordered filters — are replaced by a single execution plan computed when the cache is initialised. Every executable item gets a stage (connfilters, prefilters, filters, postfilters, idempotent) and a level within that stage: declared prefilters keep their priority as the level, postfilters and idempotent symbols keep the inverted priority so a higher priority runs later, and filters keep priority as a soft ordering key. A dependency now inherits the earliest stage and level among its dependents, so an edge from an earlier stage *moves* its target rather than executing it by accident from the wrong stage — previously a prefilter depending on a filter pulled that filter, and everything it depended on, into the prefilter stage at runtime with no ordering guarantee at all. The runtime keeps a pending counter per bucket, reproducing the old priority serialisation without the &quot;events pending&quot; heuristic, and the same gate applies to eager dependency execution and to reverse dependencies, which could previously start a postfilter while the filters stage was still running. Item states moved to a single transition function with two new done-without-running states, `suppressed` (settings, `disable_all_symbols`, a pre-result — no cascade, can be re-enabled) and `skipped` (passthrough, score limit, passed stage — cascades to hard dependents); a symbol that already ran is no longer re-executed by `task:enable_symbol`, running items are not clobbered by disable calls, `process_all` pre-results allow further processing as documented, and the idempotent bit of the pre-result skip mask is honoured. Timeout accounting follows the plan: the longest dependency chain within a bucket, buckets summed within a stage, stages summed. The plan is visible through `rspamadm configdump -d`, the new `rspamadm configdump -e` execution-order dump and `rspamd_config:get_symbol_type()` ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**Host TLDs resolved through the public suffix lookup** (incompatible): `rspamd_url_find_tld` and the URL parse path no longer scan each host with a multipattern; they probe label suffixes right to left against a flat hash map, which is about seven times faster per host and involves no hyperscan scratch. Two behaviours change as a result: `!` exception rules are honoured, having previously been skipped outright by the URL scanner, and a host that is itself a public suffix resolves to the whole host instead of being attributed to some shorter suffix ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**Two-pass URL discovery in free text** (incompatible): Discovery of bare URLs in text now runs the small static-matcher multipattern, compiled synchronously in memory, together with a dot-anchored probe of the distinct final labels of the suffix list. Since a TLD candidate is only ever accepted at the end of a domain token, anchoring on the final label is equivalent to matching the full suffix, and candidates from both passes are merged in ascending match-end order, which the `fin` deduplication and the newline cursor of the processing callbacks rely on. The upshot is that the URL scanner no longer builds the largest hyperscan database in rspamd — 10.5k patterns, 1.3 s to compile and 6.5 MiB serialised — and no longer registers it in the pending compilation queue of `hs_helper` ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**multimap `top` filter returns the full public suffix** (incompatible): The filter used to return the last label of the eSLD, conflating the `uk` and `co.uk` namespaces; it now returns the actual public suffix through the new lookup, falling back to the old derivation when no suffix list is loaded or nothing matches. Maps used with this filter must list full suffixes — `com.au` rather than `au` — so review any `top`-filtered map before upgrading ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**RBL URL composition maps matched by suffix lookup** (incompatible): Composition maps used per-TLD buckets of regexps, optionally compiled into hyperscan tries, to pick which part of a domain to query. The same longest-suffix matching is now one custom `rspamd_tld_lookup` rule set with a little flag handling on top: exceptions keep the default eSLD, wildcard rules keep the whole host, and since compose wildcards match zero or more labels the bare parent is registered alongside each wildcard rule ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**Fuzzy Redis hash counts come from a periodic SCAN** (incompatible): The Redis backend maintained `&lt;prefix&gt;_count` inside the update script — incremented on every add including a re-learn of a digest already stored, decremented on every delete including deletes of digests that were not there, and never decremented when a hash expired — so the reported number of stored hashes only ever grew, and a value that went negative was displayed as an enormous one. Updates no longer touch the counter. Instead the first fuzzy worker walks the keyspace with a slow SCAN matching digest keys only (the prefix followed by 64 bytes) and publishes the result, which every worker keeps reading as before. The scan is pinned to one read server, preferring a replica when configured, paced by a duty cycle that backs off when Redis is slow, and checkpointed so a restarted worker resumes its pass; the lock and the last completion time live in Redis, so storages sharing one Redis run a single pass per interval. Defaults are a pass at most every 4 hours, SCAN COUNT 1000 and a 10% duty cycle, all configurable in the `count_scan` section of the fuzzy worker. Negative counters left behind by older versions are reported as 0 until the first pass completes ([#6259](https://github.com/rspamd/rspamd/pull/6259))</li><li>**DKIM alignment split out of the passing-signature score**: The credit a passing signature used to earn on its own is now shared between two symbols — R_DKIM_ALLOW goes from -0.2 to -0.1 and the new R_DKIM_ALIGNED takes the other -0.1. A signature aligned with the author is worth what a passing signature was worth before, while one that merely verifies for an unrelated domain now earns half of that. No DMARC verdict moves, and installations that tuned R_DKIM_ALLOW should revisit the pair together ([#6197](https://github.com/rspamd/rspamd/pull/6197))</li></ul><h3>Added</h3><ul><li>**SVG content and smuggling indicators**: SVG attachments render with a scripting engine in mail clients and browsers, which makes them a routine container for HTML smuggling and credential phishing. They are now parsed with the shared bounded XML scanner: visible text from `text`, `tspan`, `title`, `desc` and `foreignObject` HTML; hyperlinks and remote resources from `image`, `use`, `script`, `iframe` and CSS `url()`; `data:` URIs with their media types, decoding HTML and nested SVG payloads within a size budget; and the constructs a picture never needs — script elements, `on*` handlers, `javascript:` URLs, animation elements rewriting `href`, `foreignObject`, forms and password inputs, meta refresh, embedded documents — plus a keyword scan of script bodies for `atob`, `Blob`, `createObjectURL`, `location` and friends. An external DOCTYPE is accepted since most editors emit one; internal subsets are rejected. On the Lua side gzip-compressed SVGZ is supported, text and URLs are injected into the task, decoded HTML payloads are injected as HTML parts and nested SVG payloads are folded into the outer document, all under a per-task budget and timeout configured in `lua_content { svg { ... } }`. New symbols: SVG_CONTENT, SVG_SUSPICIOUS, SVG_SCRIPT, SVG_FOREIGN_OBJECT, SVG_DATA_URI, SVG_EXTERNAL_LINKS, SVG_EXTERNAL_RESOURCES, SVG_FORM and SVG_REDIRECT, with small default weights on the scripting and embedding indicators ([#6240](https://github.com/rspamd/rspamd/pull/6240))</li><li>**XLSX and PPTX content extraction with relationship-level facts**: The bounded OOXML pipeline that handled DOCX now covers spreadsheets and presentations. SpreadsheetML handlers read shared strings, worksheets and Excel 4.0 macrosheets — inline and formula strings, sheet hyperlinks, and URL literals inside formulas such as HYPERLINK and WEBSERVICE — while the workbook handler reports sheet visibility and auto-executing defined names (Auto_Open and company). A DrawingML handler is shared by slides, notes slides and sheet drawings, and Word drawings with `hlinkClick` now yield URLs too. The Lua side owns the configuration, the per-task budget and a per-format story map two relationship levels deep (workbook → worksheet → drawing, presentation → slide → notes); relationship-only parts such as Word settings and Excel external links are fetched for their relationships alone. The real format comes from the main part&apos;s content type, with the MIME hint recorded as `declared_format` when the two disagree, and story selection truncates on the part budget instead of marking a large workbook suspicious for having many sheets. New symbols XLSX_CONTENT, XLSX_EXTERNAL_LINKS, XLSX_SUSPICIOUS, PPTX_CONTENT, PPTX_EXTERNAL_LINKS, PPTX_SUSPICIOUS, OOXML_MACROS, OOXML_OLE_OBJECT, OOXML_REMOTE_TEMPLATE and OOXML_EXTERNAL_DATA ship with zero weight by default, so you can observe them before scoring them ([#6238](https://github.com/rspamd/rspamd/pull/6238))</li><li>**Bounded DOCX content extraction**: DOCX attachments are extracted through the same bounded pipeline, with the extraction core moved to C++, entity decoding shared with the other formats, and unprocessable attachments flagged rather than silently ignored ([#6211](https://github.com/rspamd/rspamd/pull/6211))</li><li>**HTML part injection from Lua**: `task:inject_part()` accepts `&quot;html&quot;` next to `&quot;text&quot;`. The injected part is marked as HTML and goes through the regular HTML parser, so its URLs, features and CTA links are collected exactly as for a real `text/html` part. Content extractors use this to feed decoded payloads — HTML smuggled inside an SVG, for instance — back into the scan ([#6240](https://github.com/rspamd/rspamd/pull/6240))</li><li>**Public suffix lookup component**: A new component performs longest-suffix matching of hostnames against the effective TLDs file with full public suffix list semantics — wildcard rules, exception rules and the ICANN/private section split — by probing host label suffixes right to left against a flat hash map, bounded by the deepest rule in the list, instead of scanning the host with the multipattern engine. Hosts are folded with the UTF-8 aware case folder so uppercase IDN hosts match Unicode rules, with an ASCII fast path since hosts and probed labels are overwhelmingly ASCII and the per-codepoint ICU call dominated the lookup cost ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**Suffix lookup exposed to Lua as `rspamd_tld_lookup`**: Custom suffix-like rule sets — exact, wildcard and exception rules with longest-match semantics — can be built through the C API and the new Lua module, which also queries the suffix list rspamd itself loaded. Modules that used to reimplement this with regexps get the same per-label hash matching the URL scanner uses ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**`url:get_public_suffix()` and the `get_public_suffix` selector transform**: The URL method returns the public suffix of the host together with the match flags, so a plugin can tell wildcard, exception and private-section suffixes apart from plain ICANN ones, and the selector transform makes the same value available to multimap, ratelimit, reputation and every other selector pipeline ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**Selector extractors declare the symbols they need**: An extractor may list the symbols it depends on — `asn` and `country` need ASN_CHECK, `symbol(NAME)` needs NAME — either statically or as a function of its arguments. `lua_selectors.get_dependencies()` collects them for a selector string and `lua_selectors.register_dependencies()` registers them for the symbol evaluating that selector, skipping composites and classifiers and warning about symbols belonging to a later stage. The ratelimit, multimap, rbl and generic reputation rules register the dependencies of their selectors, so a ratelimit bucket keyed by `symbol(DKIM_CHECK)` finally gets DKIM_CHECK executed before the ratelimit check rather than reading whatever happened to be there ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**Prefilters may depend on filters**: A prefilter can now declare a dependency on a normal symbol — a ratelimit keyed by the DKIM domain, for example. The symbol and its own dependencies are hoisted into the prefilter stage at the level of the depending prefilter: they wait for higher-priority prefilters, run alongside those of the same priority and complete before the lower levels and the remaining filters start, while keeping the passthrough and score-limit rules of filters. Hoists are logged at info level and shown by `rspamadm configdump -e`, so the cost of such a dependency is visible rather than implicit ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**CSS compound selectors and combinators**: Selectors used to be reduced to a single simple selector, so anything with a compound (`div.mainbox`) or a combinator (`div p`, `div &gt; p`, `h1 + p`, `h1 ~ p`) was either ignored or, in a grouped list, wrongly applied by its first part — and mail templates hide spacers and preheaders with exactly these rules, so hidden text detection missed them. The parser now builds a subject compound plus a right-to-left chain of combinator links and drops whole any selector it cannot evaluate (pseudo classes, attribute selectors, unknown elements, dangling combinators) so the rest of the list still applies. The style sheet indexes each selector by the most specific part of its subject and evaluates the remainder against the tag tree on lookup, and matching rules are applied by specificity with later source order winning ties, instead of the old fixed id/class/tag order ([#6252](https://github.com/rspamd/rspamd/pull/6252))</li><li>**R_DKIM_ALIGNED reported by the DKIM module**: Whether a signature authorises the author rather than merely some domain used to be decided in two places — the DKIM module holds the signing domain and knows whether the signature verified, while DMARC recomputed the comparison over DKIM_TRACE options with its own eSLD lookup. Two implementations of one rule can only drift, and nothing outside DMARC could ask the question at all. The DKIM module now compares each signing domain against the From domain and reports the best verdict it reached — `strict` for an exact match, `relaxed` when only the organisational domains agree — as the R_DKIM_ALIGNED symbol and in the task mempool. Signatures that only tempfailed are tracked separately, since a policy must not report a definitive failure while an aligned signature is still unresolved, and DMARC consumes these facts and drops both of its own comparisons, keeping the DKIM_TRACE walk only for the per-domain lists its reports need ([#6197](https://github.com/rspamd/rspamd/pull/6197))</li><li>**Mailbox identity and equivalent domain classes in lua_aliases**: A comparison-only view of an email address: `mailbox_identity()` returns a canonical key — lowercased, plus tags stripped, gmail dots removed, domain folded through equivalent domain classes — and `canonical_domain()` folds a domain alone. Equivalent domains are those delivering into the same mailbox namespace (googlemail.com and gmail.com), supplied by a kv map or an inline table through `init_equivalent_domains()`, with a builtin Google class as the fallback. The rewrite path deliberately keeps the transmitted domain, since the domains stay distinct for SPF, DKIM and DMARC: the identity is only ever for comparing addresses and must never be written back to a task ([#6228](https://github.com/rspamd/rspamd/pull/6228))</li><li>**Sampled fuzzy storage statistics from the count scan**: The count scan knew only how many digests exist. With `count_scan.stats_sample` (default 10) each SCAN batch runs inside a read-only Redis script that also reads every Nth digest found — chosen by two digest bytes, so the sample is stable across resumed passes — and returns aggregates: hash counts, average and maximum weight per flag, multi-flag and shingled hashes with their shingle slots, and creation age buckets. The scanner accumulates them, persists them with the checkpoint, scales them to the full count at the end of a pass and publishes a JSON document. Fuzzy workers read it on the stats timer and expose it as a `storage` object in the `/fuzzystat` control reply, and `rspamadm fuzzystat` prints it as a block; the wire STAT reply still carries only the count, so clients see nothing new. Cost is roughly 3 µs of Redis time per sampled digest within the existing duty cycle, and `stats_sample = 0` restores the plain SCAN ([#6260](https://github.com/rspamd/rspamd/pull/6260))</li><li>**Lazy per-source statistics for fuzzy keys**: Every storage key used to receive a 1024-entry per-source stats table preallocated at 128 buckets, which with thousands of keys costs several megabytes of idle memory per worker for tables that mostly hold one or two addresses — while the one key that really does see a large source population, the default key, churned its table on nearly every request and produced a breakdown nobody could use. Per-source tables are now created on the first request that uses a key, starting at four buckets and growing on demand. The cap is configurable through the worker option `max_ips_per_key` (still 1024 by default) and per key through a `max_ips` extension, where 0 disables tracking; the default key tracks nothing unless it has an explicit `max_ips`, since it is the one key whose source set a bounded table cannot represent. A table can still overflow its cap, so evicting insertions are counted per hour and once they exceed eight times the cap the table is dropped and the key flagged for the rest of its life. The stats output gains `ips_inserted` and `ips_overflow`, and `rspamadm fuzzystat` says when per-IP stats were disabled for a key instead of printing nothing ([#6248](https://github.com/rspamd/rspamd/pull/6248))</li><li>**`ucl.untrusted_parser()` in the Lua API**: Lua had no way to bound UCL parsing — `ucl.parser()` gives the libucl defaults, which only guard nesting depth, and every one of the roughly 88 call sites in tree used it bare. That is fine in config mode, but most of those parse JSON or msgpack arriving from somewhere we do not control. The new parser applies a depth of 64, a million elements, 64 MB of tree, 1 KB keys and 16 MB strings; the caps are absolute rather than derived from the input, since a Lua caller builds the parser before it has the text, and any of them can be overridden per call site with zero still meaning unlimited. `parser:set_limits()` and `parser:get_limits()` are exposed on any parser, with `set_limits` overlaying only the fields it is given so tightening one does not silently clear the rest, and an unknown key being an error rather than a quiet no-op. `ucl.parser()` is unchanged ([#6209](https://github.com/rspamd/rspamd/pull/6209))</li><li>**ClickHouse data-skipping indexes for point lookups (schema 12)**: The rspamd table is ordered by TS alone, so any lookup that is not a time range — by Message-ID, sender domain, client IP, URL eSLD, attachment digest or a subject word — reads every row of that column in the window. Measured on an installation handling 20M messages a day, a single Message-ID over 30 days meant 303M rows, 15.5 GB and 1.6 s, with the second run fast only thanks to the query-condition cache. Bloom-filter skip indexes at granularity 4 are added on MessageId, From, MimeFrom, IP, Urls.Tld and Attachments.Digest, plus a bloom filter on Subject, both in the CREATE TABLE schema and as an 11 → 12 migration. ADD INDEX is metadata-only: new parts carry the index and older parts pick it up as they merge or expire, while MATERIALIZE INDEX is left to the operator because it rereads the column as a background mutation ([#6241](https://github.com/rspamd/rspamd/pull/6241))</li><li>**metadata_exporter `redis_list` pusher**: Mirrors the existing `redis_stream` pusher but uses RPUSH, so exports can be consumed by clients that only read a plain list — logstash&apos;s redis input, for one, which cannot read a stream. Per-recipient routing and an optional `max_len` cap are supported; since RPUSH cannot cap the list itself, `max_len` needs a separate LTRIM whose scheduling result and callback error are both checked and logged at error level, because a persistently failing trim would otherwise silently defeat the advertised cap. A trim failure deliberately does not defer: the RPUSH has already succeeded, so the message is exported, and deferring would re-run the exporter and push a duplicate — a breached memory cap is recoverable, a duplicated export is not ([#6200](https://github.com/rspamd/rspamd/pull/6200))</li><li>**Pinning a lua_redis request to an upstream**: `lua_redis.request` accepts `attrs.upstream` (and optionally `attrs.host`) to send a request to a specific server rather than selecting one — needed to continue a SCAN cursor, which is only meaningful on the server that issued it ([#6259](https://github.com/rspamd/rspamd/pull/6259))</li><li>**WebUI: fuzzy storage liveness**: A new `fuzzy_check.ping_storage_all()` Lua API pings every configured server of a rule — the deduplicated union of the read and write server lists — using each upstream&apos;s current address in a dedicated UDP session per server, leaving upstream rotation, DNS side effects and aliveness bookkeeping untouched; dead and not-yet-resolved upstreams are probed too, with a missing address reporting a sync error and kicking a one-shot re-resolve. The GET-only `/plugins/fuzzy/status` controller route, which works with the read-only password, pings every server of every rule and replies once all expected results have landed, with a deadline flushing a partial reply should a callback ever be lost. In the Status tab the storage tooltip shows each server address with its liveness mark and check time, storages with failed servers carry an &quot;N/M down&quot; badge visible without hovering, and a Check button in the card header re-probes on demand so the state does not wait for a refresh cycle that may never come with auto-refresh off ([#6249](https://github.com/rspamd/rspamd/pull/6249))</li><li>**WebUI: enriched fuzzy storage table and unavailable storages** ([#6243](https://github.com/rspamd/rspamd/pull/6243))</li><li>**WebUI: Bayes learns balance bar and min_learns badge**: The Status tab shows how learns are balanced between ham and spam and whether the configured minimum has been reached ([#6239](https://github.com/rspamd/rspamd/pull/6239))</li><li>**WebUI: richer Status servers table**: Three-state health derived from `/healthy` and `/ready`, an expandable per-server details row, load rate and request latency columns, last-seen time for down servers, majority-based version and config_id drift detection, and a per-server Writable badge ([#6212](https://github.com/rspamd/rspamd/pull/6212))</li><li>**WebUI: informative All SERVERS cluster row**: The aggregate row is derived from cluster data rather than a mix of the first neighbour&apos;s values, averages and blanks — majority version, configuration and git ids with drift badges, youngest uptime, cluster-wide scan-time envelope, slowest-server latency, an up/total health counter and an expandable details row with summed traffic and memory. This also fixes the literal &quot;undefined&quot; version shown when the first neighbour was down or legacy ([#6229](https://github.com/rspamd/rspamd/pull/6229))</li><li>**WebUI: git build id in the cluster table**: The build-time git id is exposed as `git_id` in `/stat` and rendered in a dedicated column, hidden when no live server reports one, with a drift badge like version and configuration mismatches so dev builds of different commits can be told apart ([#6216](https://github.com/rspamd/rspamd/pull/6216))</li></ul><h3>Fixed</h3><ul><li>**Destroyed regexp map helpers dropped from the hyperscan queue**: The pending compilation queue kept raw pointers to regexp map helpers, relying on every reload to replace the queued pointer with the new helper. A reload that never queued a new version — an empty map, a map shrunk below the in-memory compilation threshold, or a read error — left the entry pointing at the helper that reload destroyed, and a later &quot;regexp map loaded&quot; notification for the old digest resolved the stale entry and read freed memory, crashing the worker. Every queue entry referring to a helper is now removed when that helper is destroyed ([#6263](https://github.com/rspamd/rspamd/issues/6263))</li><li>**Reread deferred for a map file that has just been truncated**: Editors that save in place truncate the file and write it afterwards, and the stat watcher can observe zero length in between, swapping an empty map in until the write is noticed — a window with no rules at all, and for regexp maps a queued version that never gets compiled. A file going from non-empty to empty now schedules its reread on the minimal timer instead of immediately, and a stat event for the finished write cancels the deferred check and rereads at once, so only a file that really has been emptied waits ([#6263](https://github.com/rspamd/rspamd/issues/6263))</li><li>**Cold passive workers converge on HTTP maps quickly**: Scanner workers never fetch HTTP maps themselves; they wait for the active worker to announce a downloaded map through the shared memory cache and poll it on their own timer. On a cold start, with no cached map files, each scanner checked the cache once before the controller had finished downloading and then slept through a full jittered poll interval — minutes — serving no map data, so every forked scanner converged independently and slowly, disagreeing with its siblings, while the controller reported all maps as loaded. Per-process data delivery is now tracked: a still-empty map is rechecked every 2–4 seconds on passive workers, the shm cache is read whenever it exists and the process has no data regardless of the last-modified comparison, and the on-disk cache file is used when the shm announcement is gone but the file has already been saved ([#6214](https://github.com/rspamd/rspamd/pull/6214))</li><li>**Symbol cache dependency cycles broken at init**: A cycle used to be merely reported and kept, so every walk over the graph had to cope with it — and the timeout traversal followed same-bucket dependencies with no path guard, overflowing the stack in `rspamd_symcache_get_max_timeout()`, which both configtest and worker startup call. Cycles are now broken right after dependencies are resolved: a depth-first walk drops the back edge of every cycle along with its reverse and reports it as an error naming both symbols, with the traversal keeping the current path as a safety net ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**Connfilter and prefilter dependencies on filter symbols rejected**: The stage sanity check was skipped whenever the dependency target was a filter, so a connfilter or prefilter could depend on one and the runtime then eagerly executed it — and its own dependencies — from the wrong stage instead of ordering anything. Such an edge is now rejected at cache init, so `rspamadm configtest` reports it with the stages of both symbols, and virtual symbols are checked against their parent&apos;s stage. Every rejection path also actually drops its edge now: `add_dependency()` pre-sets the dependency item, so rejected and self-dependency edges used to survive the &quot;remove empty deps&quot; pass and reach the runtime anyway ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**Late-registered symbols planned, stuck async counters no longer spin**: Symbols registered after cache init — multimap regexp rules loaded from a map, for instance — got no execution stage, and the resort then indexed the per-stage bucket table past its end. The plan is now recomputed on every resort and is idempotent: only symbols without a plan are seeded from their declared type and priority, so levels assigned at init stay put. Separately, an item whose async counter is never released stayed pending forever; the old scheduler moved on silently while the new one kept the bucket closed, and with no session events the task processing loop recursed until the stack overflowed. A bucket with pending items but no session events no longer blocks the next levels, items blocked on such dependencies are skipped with a warning naming them, and the underlying lua_http counter leak is fixed as well ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**`ignore_passthrough` symbols run after a pre-result**: `task:set_pre_result()` marked the classifier stages as processed to skip classification, and since task processing resumes from the highest processed stage that also jumped over the filters stage — so filters flagged `ignore_passthrough` never ran after a prefilter pre-result, contrary to the flag&apos;s documented meaning. The processed stages are left alone: the symbol cache already suppresses non-exempt symbols, and classification is skipped by task processing when the result carries a passthrough that stops further checks, with `least` and `process_all` results and disabled actions not counting ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li><li>**Custom apply keys survive a settings merge**: The merge built the settings object from a fixed list of known keys, silently discarding every other top-level key of a layer&apos;s `apply` block. Custom apply keys are an extension point — a settings block may carry arbitrary values that Lua rules read back through `task:get_settings()` — and with a single layer they survived, because that path returns the layer object as is, but as soon as a second layer appeared (a static block plus a layer pushed by the Redis settings handler, an external map, or any prefilter) they vanished, so a rule keyed off `apply.tenant` or `apply.mode` silently changed behaviour depending on how many layers matched. Unknown keys are now merged per key with the higher layer winning and the value taken whole, with `_merge_info` treated as known so a layer cannot clobber the merge metadata. The merged path also shares the single-layer action application code now, which fixes two further bugs: a freshly created action slot never had its flags initialised and could be skipped depending on what the uninitialised memory held, and `actions { foo = null }` for an action the task does not have created it with a zero threshold, turning a request to disable an action into one that enabled it on everything ([#6230](https://github.com/rspamd/rspamd/pull/6230))</li><li>**Actions re-enabled from higher settings layers** ([#6230](https://github.com/rspamd/rspamd/pull/6230))</li><li>**`ip_map` and `client_ip_map` actually match** in settings ([#6205](https://github.com/rspamd/rspamd/pull/6205))</li><li>**CSS selector matching bounded**: Descendant and subsequent-sibling links backtracked over every candidate, so deep nesting with a long chain revisited the same elements combinatorially — seconds of CPU for a 28-deep document. Where the rest of the chain is made of the same kind of link the nearest candidate is always valid, so that walk is now greedy and linear, and the mixed cases keep backtracking under a budget of compound evaluations. A document-wide matching budget is shared across selectors, elements and style blocks, charging compound parts, attribute scans and sibling traversal, and incomplete cascades are dropped when it is exhausted; class bucket lookups are deduplicated, compound sizes capped, specificity cached and the selector limit enforced before index insertion ([#6252](https://github.com/rspamd/rspamd/pull/6252))</li><li>**A repeated CSS selector is its own cascade entry, and only the winning rule is allocated**: Every selector scanned its bucket for a duplicate to merge into, which was quadratic in the number of rules and also dragged the selector&apos;s earlier declarations past the rules written in between, so `.a {font-size:0} .b {font-size:14px} .a {color:red}` hid a `.b .a` element. A repeated selector now forms a separate cascade entry. Lookup used to compile every matching rule into a pool block, including those the cascade then discarded, growing the pool by rules times elements; the winner is now the only pool allocation and the rest compile into a stack temporary, filling only what the winner leaves undefined with set semantics rather than parent-propagation ones, so a less specific `display:none` no longer overrides a more specific `display:block` ([#6252](https://github.com/rspamd/rspamd/pull/6252))</li><li>**A grouped selector that cannot be evaluated is dropped whole**: Ignoring an unevaluatable selector worked only when it was not part of a list — at EOF the state is an ignore one, so nothing was attached, but a comma attached whatever simple selector had been parsed before the parser gave up. `div.mainbox ul li.spacer, div.mainbox ol li.spacer` therefore registered a rule for the bare tag `div` and applied its declarations to every div in the document, with no class having to match anything. Grouped descendant rules are ubiquitous in mail templates, and the ones carrying `font-size: 0`, `opacity: 0`, `color: transparent` or `display: none` are exactly the spacer and mobile-only rules, so a legitimate message could have its entire body moved to the invisible buffer: one confirmation mail lost all 81 words of its text part, HIDDEN_TEXT scored 4.06 on 411 invisible characters, and with the word count at zero R_SUSPICIOUS_IMAGES reached its ceiling of 5.0 off a single logo ([#6252](https://github.com/rspamd/rspamd/pull/6252))</li><li>**Relative and shorthand font sizes no longer hide visible text**: Three defects made ordinary mail look hidden. The `font` shorthand walked every value and set the font size from each one that parsed as a dimension, so the last won and unitless numbers were accepted as lengths — in `font: 400 1rem/1.5 &lt;family&gt;` the line-height became the size, the block got a 1px font and everything inheriting from it compiled as invisible; webmail clients emit exactly that on `body`, so an ordinary reply&apos;s whole body moved to the invisible buffer, HIDDEN_TEXT fired at full weight on ham, and Bayes, the neural network and every text rule saw a nearly empty part. `vw` and `vh` were spelled `wv` and `wh` in the dimension map, so the two most common viewport units never matched and the bare number was read as pixels, turning `font-size: 2vw` into 2px. And a percent size introduced by a stylesheet block stayed negative and never met the visibility limits, because the block is merged after the parent has already propagated, while sizes above 128% were clamped to 100% by an int8 field and the fallback parent size was 10px where the rest of the parser and every browser use more. Unitless numbers are rejected for the shorthand, parsing stops at the first length, unknown units leave the size unset, the field is widened, sizes are resolved again once the stylesheet block is in place, and the fallback is 16px ([#6245](https://github.com/rspamd/rspamd/pull/6245))</li><li>**A unitless zero is accepted as the font shorthand size**: The shorthand parser rejects unitless numbers, since in that position they can only be the weight or the line-height — but zero is the exception: never a valid weight, a valid length in every rendering mode, and `font: 0 Arial` renders text invisible. A zero is let through so the hiding trick is still detected, while the first-length rule keeps a zero line-height (`16px/0`) from being taken as the size ([#6246](https://github.com/rspamd/rspamd/pull/6246))</li><li>**Transparent tag content read from the buffer it was written to**: `html_tag::get_content` picked the invisible buffer for any block that was not visible, but transparent text is written to the parsed buffer as spaces, so the offsets of a transparent tag were applied to the wrong buffer and the content came back empty — or as a slice of unrelated `display:none` text when the message contained some. The destination buffer is now recorded on the tag when its offsets are computed and read from there, and the length passed to Lua comes from the recorded offsets rather than from whatever survived in the buffer after a block margin trimmed the trailing spaces. R_WHITE_ON_WHITE now fires on white-on-white text whether or not the message also carries hidden text ([#6262](https://github.com/rspamd/rspamd/issues/6262))</li><li>**Layout padding ignored and substantial hidden content penalised** in the HTML hidden text checks ([#6234](https://github.com/rspamd/rspamd/pull/6234))</li><li>**chartable handles language diacritics correctly**, including non-Latin ones ([#6231](https://github.com/rspamd/rspamd/pull/6231))</li><li>**chartable Unicode spoof detection hardened** ([#6234](https://github.com/rspamd/rspamd/pull/6234))</li><li>**Nested SVG payloads charged to the shared payload budget**: `max_payloads` was enforced per document by the native extractor and per task only for injection, so a nested SVG brought its own full quota and the part specific ended up holding more payload content than the budget advertised. The remaining task budget is now passed to the extractor, a payload is kept only while budget remains with `payloads_truncated` recording the cut, and nested payloads are charged to the same counter before being folded into the outer document ([#6240](https://github.com/rspamd/rspamd/pull/6240))</li><li>**Injected HTML parts get the regular URL pipeline**: An injected part had no URLs array, so the HTML parser could not attach its links — `part:get_urls()` stayed empty and CTA and candidate-link processing was skipped — and bare URLs in the visible text of an injected HTML part were never extracted either, since only the plain text branch ran the text extractor. Injected parts now get their URLs array allocated and the strict text extraction runs after HTML processing, mirroring what a real `text/html` part receives ([#6240](https://github.com/rspamd/rspamd/pull/6240))</li><li>**OOXML relationship-only parts kept ahead of story truncation**: Story selection walked the relationship list in package order and stopped at the first entry that did not fit the part budget, so a package front-loading enough worksheets or headers pushed the relationship-only parts — Excel external links, Word settings with the attached template — past the truncation point and opened with `truncated=true` but without the OOXML_EXTERNAL_DATA and OOXML_REMOTE_TEMPLATE evidence. Relationship-only entries are now selected first within each level, since they cost a single part and carry the indicators, with content stories picked in package order from the remaining budget. `xlIntlMacrosheet` is also registered next to `xlMacrosheet` in the workbook story map, so international XLM macrosheets are parsed for text and formula URLs instead of merely being counted as macros ([#6238](https://github.com/rspamd/rspamd/pull/6238))</li><li>**OOXML processing resources bounded** and **DOCX content symbol scores registered** ([#6211](https://github.com/rspamd/rspamd/pull/6211))</li><li>**Customer fuzzy keys take precedence over IP bans**: The source policy is now applied after decryption, so a key with its own limits, expiry and permissions is honoured rather than being pre-empted by an address ban; telemetry for blocked requests is preserved before they are silently dropped, and IP restrictions are kept for the shared default key. Covered for static and dynamic bans over both UDP and TCP ([#6255](https://github.com/rspamd/rspamd/pull/6255))</li><li>**Fuzzy key expiry time fields initialised before `mktime`**: `strptime` leaves the time-of-day and DST fields untouched when parsing DD-MM-YYYY, so an expired key could pick up a far-future expiry from stack contents. The fields are initialised before the conversion and failed date parses are rejected ([#6255](https://github.com/rspamd/rspamd/pull/6255))</li><li>**Unkeyed aggregate fuzzy stats kept without per-IP tracking**: The unkeyed aggregate bucket was only updated when the session carried a per-source stat record, which used to stand in for &quot;the client had an address&quot;. With per-source tracking now optional — `max_ips_per_key = 0` or after an overflow — that record is NULL for every request and the aggregate checked/matched/added/deleted/errors counters froze. The bucket is now updated whenever the request is unkeyed and the bucket exists ([#6248](https://github.com/rspamd/rspamd/pull/6248))</li><li>**Fuzzy write servers pinged with the write keypair**: The status probe generated its PING with the read encryption keys for every server, so write servers of a rule with distinct read and write keys — and the only server of a write-only rule — were pinged with the wrong keypair and always reported as failing. The keypair selection now follows the server list direction, and the override applies only when the read and write lists are actually shared: a write-only rule may also define them as separate lists, which the parser aliases only when a single list is configured, and such read storages were being pinged with the write keypair, going unanswered and showing up as &quot;fail&quot; for healthy servers ([#6249](https://github.com/rspamd/rspamd/pull/6249))</li><li>**Empty fuzzy storages reported in `/stat`** ([#6243](https://github.com/rspamd/rspamd/pull/6243))</li><li>**DKIM alignment requires a single author**: Alignment answers whether a signature authorises the author, which presumes there is one. The check took the first MIME From address whenever the array was not empty, so a From field naming several mailboxes still produced R_DKIM_ALIGNED and the mempool verdict — and with a negative weight, the signal was cheaper to earn on a message with no single author than on a well-formed one. What the message itself carries is now counted: where addresses are flagged as originals exactly one of those must exist, otherwise the array must hold exactly one entry, which is the same precondition DMARC applies and which leaves a rewrite intact ([#6197](https://github.com/rspamd/rspamd/pull/6197))</li><li>**SPF returns permerror for a domain with multiple SPF records**: Only the first record starting with the version string was taken and the rest of the answer ignored, so a domain publishing two SPF records was evaluated as if it published one, where RFC 7208 4.5 requires permerror. An RRset carries no order, so which record won depended on the order the resolver happened to answer in, and the same message could pass at one receiver and permerror at the next. Records are now counted rather than broken out of on the first, with the duplicate branch reporting the record as handled so the caller does not fall back to `na`, which is checked before permerror and would have masked the error. Domains publishing one SPF record alongside unrelated TXT records — vendor verification tokens and the like — are unaffected ([#6196](https://github.com/rspamd/rspamd/pull/6196))</li><li>**SPF version section matched as RFC 7208 4.5 defines it**: The version literal is case-insensitive and terminated by a space or the end of the record, and the bytewise prefix compare honoured neither — so a duplicate spelled `V=SPF1` escaped the one-record limit, while `v=spf10`, which the RFC discards, counted as an SPF record and could permerror a domain publishing a single valid one beside it. The comparison also no longer reads past a short TXT string: rdns exposes TXT data as a bare NUL-terminated pointer with no length, and the previous helper read the first four bytes of both operands before comparing any of them, which overran a legal one-character or empty TXT string ([#6196](https://github.com/rspamd/rspamd/pull/6196))</li><li>**forged_recipients compares addresses by mailbox identity**: Envelope and header addresses were compared as raw strings, so a message addressed to johndoe@googlemail.com with RCPT TO johndoe@gmail.com, or From j.o.h.n@gmail.com with MAIL FROM john@gmail.com, was reported as FORGED_RECIPIENTS or FORGED_SENDER even though both sides name one and the same mailbox — and dot variants within one domain only passed on the recipient side thanks to the &quot;same domain, other user&quot; fallback, with the sender check having no such leniency. Both sides now match by `lua_aliases.mailbox_identity`: equivalent domains are folded, gmail dots and plus tags stripped, and the authenticated user and Delivered-To checks use the same key, while the symbol options still show the wire addresses and the addresses on the task are untouched. Equivalent domain classes come from a new `rspamd/equivalent_domains.inc` map covering Google, Proton, Apple and Yandex, fetched from maps.rspamd.com with the usual local.d/maps.d hook and a shipped fallback snapshot, configured through the `equivalent_domains` option ([#6228](https://github.com/rspamd/rspamd/pull/6228), [#6227](https://github.com/rspamd/rspamd/issues/6227))</li><li>**Greylist uses the lowest passthrough priority**: The greylist pre-result now sets priority 0, so any other pre-result — force_actions, a score-based reject — always wins the tie regardless of execution order among postfilters ([#6220](https://github.com/rspamd/rspamd/pull/6220))</li><li>**Milter no longer inserts a bogus space after the header colon**: The milter protocol passes header names and values separately, and unless SMFIP_HDR_LEADSPC is negotiated the MTA eats exactly one space after the colon on the way in and adds one back when the filter modifies headers. Rspamd rebuilt every header as `name: value` regardless, so a header folded right at the colon (`Message-ID:\r\n &lt;id&gt;`) gained a space that had never been there and `simple` DKIM canonicalisation started to fail. SMFIP_HDR_LEADSPC is now negotiated whenever the MTA offers it, making header values travel verbatim in both directions and covering `Header:value` with no space at all, with the leading space supplied by rspamd on add, change and insert actions — empty values left unpadded, since that is how a header removal is expressed. For MTAs that do not offer the flag, the space is still added unless the value starts with CR/LF, the one folded case still distinguishable from a space the MTA ate ([#6194](https://github.com/rspamd/rspamd/issues/6194))</li><li>**url_redirector honours `redirectors_only`**: `map:get_key()` returns false rather than nil on a miss for glob, regexp and set maps, so the gate written as `get_key(...) ~= nil` was always true and every redirect target was followed regardless of the configured host map. A truthiness-based check is now applied consistently at the three sites that can issue an HTTP request for a hop — the live 30x follow decision, the mid-walk cache-miss bridge and the cached nested bridge — closing map-churn windows where a cached chain ends on a host delisted after being cached. An unrelated bug on the same nested bridge is fixed too: the seen table was keyed by the raw cached string while the walk keys by the URL&apos;s string form, so the cycle guard could false-fire and silently abort the extension for most URLs. A debug line is now logged before every request and for each host map lookup, giving a single grep anchor for whether a hop was really fetched ([#6198](https://github.com/rspamd/rspamd/pull/6198))</li><li>**url_suspect html_entities pattern restricted to ASCII**: The pattern looks for two numeric HTML entities within 20 characters, on the premise that clustered numeric entities mean someone is hiding a URL — which only holds for ASCII, where the technique spells out the characters of the URL itself. Ordinary prose in many languages entity-encodes accented letters instead, and those cluster naturally: `v&amp;#230;r s&amp;#248;d`, `Gr&amp;#252;&amp;#223;e aus M&amp;#252;nchen`, `r&amp;#233;serv&amp;#233; aux clients` all matched, so URL_OBFUSCATED_TEXT fired on routine non-English newsletters. Latin-1 letters live at 192–255, well clear of the ASCII range the technique uses, so restricting the pattern below 128 keeps the detection and drops the false positives; multi-byte entities were already outside the pattern and are unaffected ([#6206](https://github.com/rspamd/rspamd/pull/6206))</li><li>**url_suspect derives the public suffix through the lookup**: Stripping one label from the eSLD mangles hosts that are themselves a public suffix; the suffix lookup is asked directly, with the old derivation kept only as a fallback when no suffix list is loaded ([#6213](https://github.com/rspamd/rspamd/pull/6213))</li><li>**lua_maps keys url-list maps by their effective type**: `map_add_from_ucl()` caches maps so modules pointed at the same feed share one object, but a map given as a list of URLs was keyed by the digest of the list alone, so the type requested by the first caller won and every later caller silently received a map of a foreign type. url_redirector asks for a glob map of `redirector_hosts_map` while the multimap `redirector` rule asks for a hash map of the same URLs; multimap registers first, so url_redirector ran on a hash map and no glob pattern in the feed could ever match. The type is now part of the cache key, and a type prefix inside the list (`glob;http://...`) is stripped before the lookup so the key always carries the effective type rather than the requested one ([#6226](https://github.com/rspamd/rspamd/pull/6226))</li><li>**WebUI stat refresh cycles ordered by start, not completion**: The refresh cycle id was assigned in the `/stat` success callback, so overlapping refreshes were ordered by completion — an older request finishing after a newer one received the larger id and re-rendered Credentials, the Status table and the chart with its stale captured parameters, such as the selected server. The id is now assigned and captured before the query, so the later-started cycle always supersedes the earlier one and obsolete cycles drop their state entirely ([#6212](https://github.com/rspamd/rspamd/pull/6212))</li><li>**WebUI stale-rate threshold raised above the largest auto-refresh preset**: At the 30-minute and 1-hour presets every sample span exceeded the previous 15-minute limit, so the Load column stayed &quot;-&quot; forever ([#6212](https://github.com/rspamd/rspamd/pull/6212))</li><li>**`rspamadm fuzzy_ping` help fixed**: The `-n` option&apos;s description was wrong ([#6237](https://github.com/rspamd/rspamd/pull/6237))</li></ul><h3>Security</h3><ul><li>**DNS reply parser hardened against malformed packets**: The parser consumes fully attacker-controlled bytes, yet it decoded names, resource records and TCP frames with unchecked or wrapping arithmetic. Name decoding is reworked around a single bounds-checked iterator: question comparison and label parsing bound every access to the packet, cap compression-pointer hops and total name length, and reject truncated or reserved labels instead of reading out of bounds. Encoded names embedded in RDATA are bound to their RDLENGTH, with compression pointers still allowed to target the whole packet, and PTR, NS, CNAME, MX, SRV and SOA verify exact RDATA consumption so a short or long RDLENGTH cannot shift the parsing of the records that follow. A records require exactly four bytes, every RR is length-validated and resynchronised to its RDATA end, TXT chunk accounting uses `size_t` rather than an 8-bit counter that wrapped after 255 segments, partially parsed allocations are freed on every error path and every allocation is checked. TCP reads drain iteratively under a per-event packet budget instead of recursing, and the read counter is widened so a maximal 65535-byte DNS-over-TCP frame no longer overflows a 16-bit field</li><li>**UCL container nesting bounded and trees freed iteratively**: Freeing a UCL tree recursed once per nesting level, so a deeply nested document could exhaust the stack while being cleaned up, long after the parser had returned successfully — a 20k-level document crashed reliably on free. Destruction now walks an explicit worklist threaded through the dying objects&apos; next pointers, which is allocation free and uses constant stack. Nesting itself was unbounded for brace containers, since the maximum was only ever consulted by the msgpack and csexp parsers; container depth is now tracked separately from the dotted-key level and checked before anything is allocated, so the 1024 default applies to every parse type. `ucl_parser_set_limits()` is added for callers handling untrusted input — max depth, nodes, allocation, key length and string length, with zero meaning unlimited and only the depth limit on by default — and both the UCL and msgpack parsers charge every element they create, including the array wrapper a duplicate key builds. Several latent defects are fixed along the way: an allocation-failure path that left a value object with a stale union pointer, a sibling chain loop that released a sibling&apos;s children before checking its refcount so a shared object could have its children freed underneath it, an unchecked allocation and two ignored append results, a NULL container unconditionally reported as a syntax error and masking the real cause, and a msgpack container unwind by tail recursion that made parse depth a stack depth problem of its own ([#6207](https://github.com/rspamd/rspamd/pull/6207))</li><li>**UCL parsing of untrusted network input bounded**: Request bodies were parsed with the safe flags but without any structural budget, so a body well within `max_message` could still describe a tree orders of magnitude larger than its wire size — 50 MB of `[1,1,1,...]` builds gigabytes of objects. An untrusted parser now applies a depth of 64, a million elements, 1 KB keys, 16 MB strings and a tree cap of `min(96 × input + 256 KB, 64 MB)`, the factor coming from the cost of an element against the densest JSON that can describe one, so it sits above anything legitimate while the absolute cap bounds a large body. It is used for checkv3 metadata in both JSON and msgpack form, for both controller body parsers and for both proxy parsers ([#6207](https://github.com/rspamd/rspamd/pull/6207))</li><li>**Untrusted UCL parser used for external input in Lua**: Call sites parsing input from outside the local configuration were moved to `ucl.untrusted_parser()` so a hostile reply cannot turn into an unbounded object tree — most directly the HTTP request headers parsed by http_headers and settings, and otherwise replies from network services: the LLM providers and gpt, the six lua_scanners backends, bimi, contextal, the updates and openphish feeds, the external neural service, the anonymiser in lua_mime and the vault lookup in lua_dkim_tools. Settings is split rather than converted wholesale — the request header and external map parsers are untrusted, the settings map and Redis ones stay on the ordinary parser — and clickhouse, elastic, lua_cache, history_redis, the neural model paths and everything in config mode or rspamadm are deliberately left alone, since they handle data we produce ourselves and can legitimately be large enough to reach the caps ([#6209](https://github.com/rspamd/rspamd/pull/6209))</li><li>**lua_compress decompression output and memory bounded**: inflate tested its size limit only when the output buffer filled with input still pending, so a stream ending in `Z_STREAM_END` returned whatever it had produced, and a buffer that did fill doubled past the limit rather than being clamped — a caller asking for 512 Kb could be handed 1 Mb, having allocated twice what it asked for. A single ceiling is now kept, allocated one byte above it so a full buffer proves the data does not fit, and checked after the loop as well as during it. zstd took the decompressed size from the frame header and passed it straight to `g_malloc`: that value is attacker controlled, a few dozen bytes suffice to claim terabytes, and `g_malloc` aborts rather than returning NULL, so a worker decompressing an untrusted body died where it stood. The header is now an allocation hint capped against the size of the input, with the existing growth loop finding the real size. The inflate loop also resumes from where inflate stopped rather than rewinding the output pointer and overwriting what had already been produced</li><li>**PDF resource amplification bounded**: A hostile PDF could make the parser and the text extractor do far more work, and hold far more memory, than the size of its input suggested, because the existing limits counted things rather than bytes. CMap mappings were capped by entry count only — one bfchar destination could carry an arbitrarily long string, a bfrange could copy such a string once per expanded code, and the builder appended a whole mapping per character code with no ceiling — so bounded input said nothing about output; the bytes one mapping may hold and the total a CMap may store are now capped, and the builder has an output ceiling it stops at instead of trusting the content stream. Range lookup walked backwards over the table once per character whenever the nearest candidate had the wrong code width; ranges are grouped by width with a running maximum so a search can tell at once whether an overlap could still answer. The object limit counted only non-tiny objects and never saw those unpacked from an object stream, so a hard total is added, the deadline is checked while unpacking, and the marker occurrences collected before the timer used to start are budgeted. Text extraction had no deadline of its own and re-ran the filter chain for every reference to a shared stream, so decodes are memoised, the parsed cmap is cached on the stream rather than per font, and the text is budgeted. The trailer limit compared the trailer&apos;s offset against a byte count, so a large file with a short trailer was skipped whole — taking `/Encrypt` detection with it — while a small file with an enormous trailer passed; the trailing bytes are compared instead. `max_extraction_size` becomes 1 Mb, which is the ceiling streams have really been decoded under for years now that the binding enforces it exactly, and `timeout_processing` is carried across to the output object so a deadline tripping late actually reaches the caller and PDF_TIMEOUT can fire at all</li></ul><h3>Improved</h3><ul><li>**Bounded XML scanner shared across extractors**: The strict XML scanner moved out of the OOXML module so other attachment formats can reuse it. It is templated on its handler, which supplies its own namespace policy, so each format keeps its namespace vocabulary local, while entity decoding mode, DOCTYPE acceptance and a declared-encoding fallback for non-UTF-8 input become scanner limits set by the caller — OOXML keeps its previous strict behaviour ([#6240](https://github.com/rspamd/rspamd/pull/6240))</li><li>**LRU hash constructor with an initial size**: The existing constructor always preallocated 128 buckets, a sensible warm start for the handful of long-lived caches it serves but a waste for tables created per object that usually stay tiny. A sized constructor takes the initial bucket count, with zero allocating nothing until the first insertion ([#6248](https://github.com/rspamd/rspamd/pull/6248))</li><li>**Symbol cache levels, hoisting and hard dependencies documented**: How priorities act as levels for prefilters and postfilters, that a prefilter may depend on a normal symbol and thereby hoist it, the `hard` flag of `register_dependency`, and the configdump option that shows the execution plan ([#6225](https://github.com/rspamd/rspamd/pull/6225))</li></ul>]]></description>
      <category>major</category>
    </item>
    <item>
      <title>Rspamd 4.1.5 - Patch Release Gating File and Shared-Memory Message Sources, with Fuzzy Sender Facts and Font-Aware PDF Text Extraction</title>
      <link>https://docs.rspamd.com/changelog/4.1.5</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.1.5</guid>
      <pubDate>Fri, 14 Aug 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.1.5 - Patch Release Gating File and Shared-Memory Message Sources, with Fuzzy Sender Facts and Font-Aware PDF Text Extraction</h2><h3>Added</h3><ul><li>**Fuzzy sender facts shared with storages**: Rules that share extensions with their storage now attach a sender-facts extension to check commands sent over encrypted connections: a single 32-bit word carrying the SPF, DKIM and DMARC results, the PTR state — including the generic/dynamic hostname verdict that hfilter now publishes for other modules to reuse instead of duplicating its pattern set — a bucketed recipient count, and whether the sending client used TLS. Authentication results separate &quot;this address is the source&quot; from &quot;this address relayed or forged&quot;, which is exactly the distinction an IP reputation model built from fuzzy telemetry has been missing. Every field describes the sender and never the recipient or the local policy: recipient counts are bucketed rather than exact, and the client&apos;s own verdict, action and score are deliberately absent, since they would create a feedback loop with the reputation list. The values come from the checks themselves rather than from symbol names, so the SPF and DMARC modules now publish their policy results in the task mempool, and `FUZZY_CALLBACK` gains ordering dependencies so the results exist by the time commands are built ([#6179](https://github.com/rspamd/rspamd/pull/6179))</li><li>**Font-aware PDF simple font text decoding**: A string in a PDF content stream holds character codes, not characters — byte 0xe9 is Oslash in StandardEncoding, e-acute in WinAnsiEncoding and Egrave in MacRomanEncoding — so without the /Encoding of the font that drew it the text cannot be decoded, and running charset detection over raw codes is guesswork. A new libmime unit owns the three Annex D base encodings and per-font /Differences overrides, plus a builder that turns a page worth of text runs into a single UTF-8 buffer with no per-run allocation and unescapes literal and hex strings in one pass instead of a chain of Lua gsubs. The extractor now keeps the font selected by each Tf operator, resolves every font of a page to an encoding, and replays the whole page through one decoder; quality filtering is scored per page, where the statistics mean something, rather than per text operator ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**PDF /ToUnicode CMaps and composite fonts**: A composite (Type0) font addresses glyphs by index, so its character codes mean nothing on their own and no encoding table can recover the text — the only thing that can is the /ToUnicode CMap the producer embedded, which is now parsed: codespace ranges give the code widths, bfchar maps single codes and bfrange maps runs, with UTF-16BE destinations that may expand to several characters or a surrogate pair. Every table is capped, because a CMap comes from the message. A font&apos;s CMap takes precedence over /Encoding — its codes are glyph indices, so an encoding table would only invent letters — codes the CMap does not map produce nothing rather than leaking a glyph index into the text, and the UTF-16 heuristic now applies only when no CMap is authoritative, since composite fonts emit exactly the zero-interleaved byte pattern it looks for and it used to hijack the very strings a CMap is meant to decode ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**Structured fuzzy results and message size in the Elastic schema**: The elastic plugin replaces the flat `fuzzy_hashes` list with per-match objects from `task:get_fuzzy_results()` — rule, symbol, found and queried hashes, type, probability, flag and exactness — with a `fuzzy_nested` option to toggle the nested type, and adds the message size to the schema mapping so its type is declared rather than inferred per index ([#6154](https://github.com/rspamd/rspamd/pull/6154), [#6136](https://github.com/rspamd/rspamd/issues/6136))</li></ul><h3>Changed</h3><ul><li>**GPT condition callback returns only a boolean** (incompatible): The `condition` option was previously expected to return both the decision and the content for the LLM request, a contract that was non-obvious and only discoverable through debugging. The callback now returns only true/false plus an optional reason, and request content generation has moved into `gpt_check` itself, making custom conditions much simpler to write. Deployments whose condition returned request content must update it to return the boolean decision alone ([#6159](https://github.com/rspamd/rspamd/pull/6159), [#5857](https://github.com/rspamd/rspamd/issues/5857))</li><li>**Explicit `allow_file_and_shm_inputs` defaults shipped**: The normal, controller and proxy worker configurations now set `allow_file_and_shm_inputs = true` explicitly, with a comment explaining what the option permits, that it applies to TCP listeners only (unix sockets always allow these inputs), and that a listener with it enabled must not be exposed to untrusted networks — authentication and encryption do not authorize the capability. No shipped default value changes in this release, so existing deployments keep working; the default becomes false in the next major release, so operators relying on file or shared-memory inputs over TCP should set the option deliberately now. Commented `max_connections` examples are also added to the controller and proxy ([#6162](https://github.com/rspamd/rspamd/pull/6162))</li></ul><h3>Fixed</h3><ul><li>**Per-key fuzzy `forbidden_ids` replaces the default set**: The v2 reply path checked the global `default_forbidden_ids` first and suppressed matching flags unconditionally, letting a per-key set only add further restrictions. That inverted the semantics: a key carrying its own `forbidden_ids` — including an explicitly empty one, which means &quot;nothing is forbidden for this key&quot; — is supposed to replace the global default, which exists only for keys with no ACL of their own. In production this meant a storage-wide `forbidden_ids = [8]` suppressed flag 8 for every client, including keys whose own list was empty, so that feed was not served at all. The per-key set alone is now checked whenever the session is encrypted and the key has one, with the global list as the fallback</li><li>**Per-key ACL applied to delayed v1 replies**: The v1 reply path gated the per-key check on a positive match probability, but the DELAY branch zeroes the probability while leaving the flag intact, so a delayed reply fell through to the global default and a key silently lost its own ACL — the same inversion as in the v2 path, limited to encrypted delayed replies. The probability check is dropped; outside DELAY nothing changes, since a zero probability means a miss with no flag to filter</li><li>**Unknown fuzzy wire extensions are skipped, not fatal**: The extensions TLV was not self-describing — an entry&apos;s length was implied by its type — so a storage could not skip a type it did not know and rejected the whole command, meaning the client got no fuzzy answer at all rather than merely losing the extra field, and every new extension type would have broken every storage predating it. Type bytes from 0x80 up are now always followed by a length byte and can be skipped when unknown, the sender-facts extension is reserved in that range, and the parser was consolidated so a malformed packet from the network can no longer reach an assertion that aborts the daemon ([#6179](https://github.com/rspamd/rspamd/pull/6179))</li><li>**No fuzzy extensions for own senders or plain-text rules**: The sender address extension was shipped whenever a rule did not set `no_share`, so deployments running fuzzy checks on outbound or authenticated submission traffic sent their own users&apos; IP addresses to public fuzzy servers. Extensions are now omitted entirely for authenticated tasks and sources in local networks — such addresses are noise in a reputation model and exactly the values that had to be filtered downstream anyway — and, as the code already claimed but did not enforce, for rules that talk to their storage in plain text, where the data would travel in the clear ([#6179](https://github.com/rspamd/rspamd/pull/6179))</li><li>**static_embed word extraction bounded**: The static_embed neural provider embedded every word of a message, so a multi-megabyte text part became a million-entry table plus a mean-pool over every token — about 200 ms per megabyte inside a synchronous rule, observed as multi-second event-loop stalls with &quot;slow synchronous rule&quot; warnings — and the subject handling compounded it by prepending words one at a time, shifting the whole table per word. Word extraction now honours `max_words` as a total cap that the provider passes through (the knob `fasttext_embed` already had), with subject words seeding the table first and body words appending up to the cap. The cap changes long-text vectors, so training and inference must agree on it ([#6187](https://github.com/rspamd/rspamd/pull/6187))</li><li>**Neural fusion survives a missing provider**: The fusion loop iterated the collected feature vectors with `ipairs`, which stops at the first hole in the table — one failed or unregistered provider in an early slot silently discarded every later provider&apos;s collected block, and downstream fusion consumers went dark with no diagnostic at all. The loop now covers the whole provider slot range including the trailing metatokens slot, so a missing provider yields a partial fused vector that dimension checks can actually see and report ([#6186](https://github.com/rspamd/rspamd/pull/6186))</li><li>**Phantom slow-rule warnings from the symcache**: A rule&apos;s start time was stamped only on profiled tasks, but the elapsed time was measured whenever the task was profiled or the rule had ever been flagged slow. Once a rule had been flagged slow once, every subsequent non-profiled task measured it against a start of zero — the task start — so any DNS-bound or deadline-hitting scan logged a multi-second &quot;slow synchronous rule&quot; against a callback that actually ran in sub-millisecond time; one production scanner saw around 1200 phantom 5–8 s warnings per day. The stamp is now taken under the same condition as the measurement ([#6188](https://github.com/rspamd/rspamd/pull/6188))</li><li>**HFILTER_HELO_IP_A compares HELO addresses against the connecting IP**: The symbol is documented as &quot;Helo A IP != hostname IP&quot;, but the comparison sat in a branch reached only when both the A and AAAA lookups had failed — where the resolved-address list is necessarily empty — so it fired if and only if the HELO host resolved to nothing, a condition already covered by HFILTER_HELO_NORES_A_OR_MX, and never on the mismatch it describes. Because the two lookups complete in arbitrary order, the fix joins on an explicit completion counter and judges once both callbacks have returned, keeping the MX fallback on the both-failed path and ignoring transient lookup failures rather than treating them as a mismatch. Functional coverage is added for a plugin that previously had none ([#6176](https://github.com/rspamd/rspamd/pull/6176), [#6165](https://github.com/rspamd/rspamd/issues/6165))</li><li>**Schemeless email hrefs no longer fire URL_USER_PASSWORD**: When guessing the missing scheme of an HTML href, only a leading `@` selected the `mailto:` prefix, so `&lt;a href=&quot;user.name@example.com&quot;&gt;` — where the first non-alphanumeric character is the dot in the local part — kept the `http://` prefix and produced `http://user.name@example.com` with a userinfo component, firing URL_USER_PASSWORD (2.0 points) on legitimate messages; nodemailer emits exactly such quote-attribution links. Characters legal both in a hostname and in an email local part (`.`, `-`, `_`, `+`) are now treated as ambiguous and scanning continues until a genuinely structural character decides, so `javascript:`/`data:` hrefs, relative paths and bare hostnames all keep their previous handling</li><li>**`get_urls_filtered()` no longer drops flagless URLs**: When called without an include list, the function sets its mask to &quot;all flags&quot;, but the include test was a plain bitwise and against that mask — which a URL carrying no flags at all still fails, and URLs extracted from HTML parts carry no flags. The no-argument form, which asks for everything, silently returned nothing for HTML-only messages. The include filter now applies only when a list was actually given: `rspamadm mime urls` shows HTML URLs again, and URI_COUNT_ODD no longer runs its parity check on an undercount for exactly the multipart/alternative messages it targets ([#6182](https://github.com/rspamd/rspamd/pull/6182), [#5891](https://github.com/rspamd/rspamd/issues/5891))</li><li>**MIME rewriters unwind every boundary level**: `add_text_footer()`, `remove_attachments()` and `multipattern_text_replace()` closed a fixed number of boundaries when the part walk returned from a nested part to a shallower one, rather than however many levels were actually left behind. As soon as a message nested deeper than the hardcoded count, the boundary stack desynchronised: closing delimiters landed in the wrong places, turning attachments into an unreachable epilogue in the footer case and leaking literal `--boundary--` text into part bodies in the others — reproducible through `rspamadm mime strip` on a triply-nested message. All three now unwind to the boundary being re-entered, which also subsumes an earlier multipart/related special case, with unit tests over flat and one- to three-level nesting that fail on the unpatched code ([#6173](https://github.com/rspamd/rspamd/pull/6173), [#6178](https://github.com/rspamd/rspamd/pull/6178), [#6170](https://github.com/rspamd/rspamd/issues/6170))</li><li>**TJ positioning gaps kept out of the PDF character code stream**: The numbers in a TJ array are positioning adjustments, not character codes, but a large negative one was turned into a space and concatenated with the strings around it. For a simple font that only made an ASCII space arrive by the wrong route; for a composite font it inserted one byte into a stream of two-byte codes, so every code after the gap was read across a boundary. The pieces of a text operator now travel separately and the gap is appended structurally, where it cannot disturb the framing ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**Inline PDF /Resources dictionaries honoured**: Only the indirect form of a page&apos;s /Resources was unwrapped; a /Resources written out as an inline dictionary — the common case — was replaced by an empty table, so everything a page declares, fonts above all, was invisible to extraction ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**Font-blind ligature substitution removed**: A fixed rewrite turned seven character codes into ligatures regardless of the font in force, though a character code has no meaning without one: two of the codes are fi/fl only in StandardEncoding, two only in MacRomanEncoding, and three in neither anywhere, while in WinAnsiEncoding — the most common encoding of all — every one of the seven is an ordinary letter. German text lost each ß to an &quot;fl&quot; and each registered sign became &quot;fi&quot; before charset detection ever saw the bytes. Ligatures now come back correctly through the font-aware decoder&apos;s per-encoding slots ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**CMap codes carry their byte width**: Mappings were stored under the numeric code alone, so a one-byte `&lt;41&gt;` and a two-byte `&lt;0041&gt;` collided even though a CMap may declare both — the byte count is what places a code in a codespace, which the input framing already honoured but the tables then ignored, so a one-byte mapping could answer for a two-byte code or be overwritten by one. Both singles and ranges are now keyed by width as well ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**CMaps inheriting from another CMap are refused**: A `usecmap` reference was treated as a marker and thrown away, so a CMap built as overrides on top of a base returned only the overrides — every code the base was meant to answer for silently vanished, and the surviving ones made the result look like real text. The base is a CMap resource that is not in the file and cannot be resolved, so such a CMap is now refused and the part left undecoded, where the computed-part guard can see it for what it is ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**Computed text parts kept raw and CED charset names resolved through the alias table**: PDF extraction injects synthetic text parts whose bytes may be valid in no charset at all; running charset re-detection over them produced non-IANA names such as &quot;BIG5-CP950&quot; that ICU rejects. Computed and extracted text parts are now kept raw, so undecodable glyph data stays out of tokenisation and language detection, while CED-detected charsets for regular parts are routed through the alias table (mapping &quot;big5-cp950&quot; to &quot;big5&quot;) so legitimate Big5 content still converts ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**EUC-TW misnomer aliases restricted to CED output**: Folding CED&apos;s invented &quot;CNS&quot; and &quot;EUC&quot; labels into euc-tw in the global substitution table also resolved the charset a message declares — and a message declaring `charset=euc` is far more likely to carry sloppy EUC-JP or EUC-KR than EUC-TW, so data that used to be left raw decoded cleanly and said something else. Both labels now resolve only where CED output is turned into a charset name, and nowhere else ([#6175](https://github.com/rspamd/rspamd/pull/6175))</li><li>**Hyperscan cache usable with a Redis backend**: The TTL-refreshing GETEX is classified by Redis as a write command, but the cache issued it without marking it as one, so with a split master/replica setup every single load failed with &quot;READONLY You can&apos;t write against a read only replica&quot; and all maps and multipatterns silently ran on the PCRE/ACISM fallback — easy to miss, because the EXISTS check used to skip recompilation is a genuine read that replicas serve happily, keeping startup fast while no worker ever received a database. GETEX is now used only when reads and writes go to the same servers; otherwise reads hit the replicas and a separate EXPIRE against the master refreshes the TTL. The fix also handles nil replies properly via a published `rspamd_redis.null` sentinel with `lua_redis.is_null()` (previously a missing key crashed inside the error-formatting code), sanitises non-positive TTLs that would have deleted the very database just read, reports why a hot-swap did not happen instead of a misleading file-not-found warning, loads databases asynchronously on worker startup, and compiles regexp maps read after the fork in the process that read them — previously a large pattern map served over HTTP without a cached copy stayed on PCRE for good, because its compilation queue entry lived in a process that never compiled it ([#6161](https://github.com/rspamd/rspamd/pull/6161))</li><li>**Installed hyperscan databases are not fetched twice**: A map enters the compilation queue via its read callback and is announced by a notification once the database exists, and both paths loaded it, so every map update cost double the traffic — megabytes for a map of a few thousand patterns. The load is now skipped when the helper already has a database; this cannot keep a stale one, since a helper&apos;s patterns never change after creation and every load verifies the content digest</li><li>**ARC_ALLOW_TRUSTED scored at most once**: The symbol could be inserted multiple times for a single message, multiplying its score; it is now added at most once ([#6152](https://github.com/rspamd/rspamd/pull/6152))</li><li>**History reset clears every row**: Two separate bugs left rows behind. The legacy in-memory ring buffer zeroed only the rows up to the current position and never reset the cursor, so once the buffer had wrapped, the tail kept completed entries that the read path still displayed; all rows are now cleared and the cursor reset. The Redis backend used `LTRIM &lt;key&gt; 0 0`, which retains list index 0 — the newest entry, since rows are prepended — so the most recent row always survived a reset; the list is now deleted outright, with a functional test covering the whole flow ([#6185](https://github.com/rspamd/rspamd/pull/6185), [#2910](https://github.com/rspamd/rspamd/issues/2910))</li><li>**WebUI searches symbol option values again**: The Tabulator global search built its haystack from symbol name and description only, dropping the option values rendered in the symbols cell — a 4.1.4 regression that broke searching for values such as `asn:205640`. Options are included in the haystack again, restoring the old FooTable behaviour for both the history and scan tables ([#6169](https://github.com/rspamd/rspamd/pull/6169), [#6168](https://github.com/rspamd/rspamd/issues/6168))</li><li>**Logger format string and argument mismatches**: Eight logger calls across lualib and the Lua plugins supplied a different number of arguments than their format string consumed. Most only produced extra-argument noise, but three misreported or lost information: the clustering plugin&apos;s Redis success message printed a literal &quot;unknown error&quot;, the neural module printed a rule name where a value belonged and dropped the value itself, and lua_stat dropped the actual Redis error from a metadata conversion message ([#6177](https://github.com/rspamd/rspamd/pull/6177), [#6174](https://github.com/rspamd/rspamd/issues/6174))</li></ul><h3>Security</h3><ul><li>**File, Path and Shm message sources gated by transport**: The `File`/`Path`/`Shm`/`Shm-Offset`/`Shm-Length` headers, their v3 metadata equivalents and the proxy&apos;s `File` query argument make rspamd open a path or map a shared memory object that the client names, and they were accepted on any transport — so any client able to reach a scanner, controller or proxy TCP port could have an arbitrary file readable by the rspamd user parsed and its content reported back. A new per-worker `allow_file_and_shm_inputs` boolean now gates the capability, derived solely from the transport the connection was accepted on: unix sockets always allow these inputs, since access is already controlled by the socket&apos;s filesystem permissions, while nothing the client sends takes part in the decision and no other credential — controller password, enable_password, secure_ip match, encrypted connection — unlocks it. Gating is applied before the named object is opened, mapped or statted, and two bypasses are closed: query arguments that the proxy turned into trusted upstream headers, and the fuzzy controller endpoints that build their own task. The inputs themselves are hardened — paths validated before any syscall, files snapshot with bounded reads instead of parsing a shared mapping the caller can truncate mid-parse, shared memory mapped as a page-aligned window rather than the whole object — the proxy strips client-supplied Shm headers as reserved hop-by-hop headers, the scanner&apos;s `max_tasks` now counts accepted body-pending connections, and the controller and proxy gain `max_connections`/`max_connections_per_source` admission limits enforced before any expensive allocation. `encrypted_only` also no longer exempts peers via a locality test that matched IPv6 link-local and site-local addresses, which had let any host on the segment skip the encryption requirement. The option defaults to true in this release with a prominent startup warning per TCP listener, and becomes false in the next major release ([#6162](https://github.com/rspamd/rspamd/pull/6162))</li><li>**Controller authentication failures rate-limited per source**: Verifying a controller password runs a deliberately expensive key-derivation function (~25 ms for the catena hash that `rspamadm pw` emits by default) synchronously in the controller&apos;s event loop, and the password cache keys on the plaintext that succeeded — so a stream of distinct wrong passwords fell through to the KDF every time, and roughly 40 attempts per second were enough to saturate a single-process controller and stall every other request it serves. Failed attempts now feed a per-source leaky bucket checked before any password work, so a source over budget gets a 429 in well under a millisecond; a successful login clears the bucket, trusted sources (secure_ip, unix sockets) are never throttled, and throttled requests add no further penalty, so a shared source cannot be pushed into a permanent lockout. Configurable via `max_auth_failures` (default 10, 0 disables) and `auth_failure_window` (default 60 s); note the key is the real TCP peer, so behind a trusted reverse proxy all clients share one bucket. The redundant second KDF run when `password` and `enable_password` are configured to the identical hash is also skipped, halving the cost of every wrong password ([#6162](https://github.com/rspamd/rspamd/pull/6162))</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.1.4 - Patch Release Fixing a Controller Authentication Fail-Open, Regexp Engine Resource Bounds, and jemalloc Startup Segfaults</title>
      <link>https://docs.rspamd.com/changelog/4.1.4</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.1.4</guid>
      <pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.1.4 - Patch Release Fixing a Controller Authentication Fail-Open, Regexp Engine Resource Bounds, and jemalloc Startup Segfaults</h2><h3>Added</h3><ul><li>**Read-only access to the Selectors tab**: The `list_extractors`, `list_transforms` and `check_selector` endpoints are now open to read-only users, matching the policy already used for maps — they only return static catalogs or parse a selector without executing it. `check_message` remains privileged, since it runs a user-supplied selector against a user-supplied message ([#6157](https://github.com/rspamd/rspamd/pull/6157))</li><li>**Errors history for read-only users**: The `/errors` endpoint and its WebUI table were gated behind the privileged password. Any authenticated user can now view them: the endpoint returns only Rspamd&apos;s internal operational error log (timestamp, pid, level, module, message) — no message content, PII or secrets — while read-only users already see strictly more sensitive data in the History tab. Authentication is still required and the endpoint mutates nothing ([#6156](https://github.com/rspamd/rspamd/pull/6156))</li></ul><h3>Changed</h3><ul><li>**One shared jemalloc instance per process** (incompatible): The build system searched for `jemalloc_pic` before `jemalloc`, so on Debian it preferred the static `libjemalloc_pic.a` over the shared library and linked it into the common library list used by nine shared objects. Each of them ended up with a complete private allocator holding its own arenas and extent map, so memory obtained through one copy could not be released through another — and jemalloc-enabled packages segfaulted at startup. CMake now searches for the shared library only, fails the configure step when just a static archive is available (except in fully static builds), and attaches jemalloc per target: `librspamd-server` — the only code calling `mallctl()` — plus each executable, pinned with `--no-as-needed` so the linker keeps the otherwise-unreferenced dependency and libc cannot win the `malloc()` lookup. The `malloc_conf` tuning moves into the server library, where it actually takes effect with a shared jemalloc (an executable exports nothing without `-rdynamic`, so it had been silently lost) and now applies to every rspamd binary rather than just the daemon. The jemalloc configuration is also built and tested in CI now, mirroring the shipped Debian/Ubuntu amd64 packages, which had never been covered before ([#6155](https://github.com/rspamd/rspamd/pull/6155), [#6153](https://github.com/rspamd/rspamd/issues/6153))</li></ul><h3>Fixed</h3><ul><li>**PCRE2 match data leaked on invalid UTF input**: `rspamd_regexp_search()` allocates the PCRE2 match data before the JIT UTF-8 validation guard, but the early return on invalid input skipped the free at the function tail, leaking one match-data block (sized to the capture count) per rejected input. The guard is entered by any ordinary regexp — it tests whether a separate raw pattern was compiled, not whether raw matching was actually selected — and the regexp cache deliberately routes raw matching of UTF-marked classes through PCRE, so a message with a non-UTF-8 text part reached the leak through the normal scan pipeline, once per such regexp per part</li><li>**Heap used by a single regexp match bounded**: PCRE2 match contexts set only the backtracking and depth limits, leaving the heap limit at the build-time default of 20 GB — effectively no limit. The interpreter grows its backtracking frame vector as frame size times depth, so one non-JIT match on a 60 KB subject with a 2 KB frame could allocate around 485 MB before the depth limit intervened, and far more with a larger frame; this path is taken whenever JIT is unavailable or refused for a pattern. An explicit 64 MiB heap limit is now set, preferring `pcre2_set_depth_limit` over the deprecated recursion-limit call where available. The PCRE1 path had no explicit limits at all — they live in the `pcre_extra` block, and `pcre_study` legitimately returns NULL when there is nothing to optimise — so a block is now allocated when needed and both match and recursion limits are set, with a much lower depth since PCRE1 backtracks on the C stack. Two PCRE1 drive-bys: study data is freed unconditionally rather than only in JIT builds, and the raw pattern is now studied for the raw matcher instead of handing it study data from a different compiled pattern</li><li>**re:matchn() no longer loops forever on empty matches**: An incremental search resumes from the previous match end, so a zero-width match leaves the cursor where it was. `re:search()`, `re:split()` and the regexp cache loop all guard against this, but `re:matchn()` did not — with unlimited max matches, a pattern that can match the empty string spun forever and hung the worker. This was reachable from configuration: the spamassassin plugin passes unlimited for any `multiple` rule without `maxhits`, so one SA rule with an empty-matchable pattern wedged the process. The same cursor-progress guard is now applied</li><li>**Bounded regexp patterns read past their end when generating ids**: `rspamd_regexp_new_len()` parses within the given pointer and length, but id generation then called `strlen()` on the same pointer. Callers passing a slice of a larger buffer — the composites expression parser and the symcache delayed symbols — read past the end of the pattern, a heap-buffer-overflow under ASAN whenever the slice is not NUL-terminated. The identity was wrong even when the read stayed in bounds: the same regexp got different ids as a slice and as a standalone string, while two different slices sharing a start address got the same id and collided in the regexp cache, which compares ids only. The length is now passed down to id generation, and three error paths that printed the raw pointer are bounded as well. Ids produced by `rspamd_regexp_new()` and the regexp cache are unchanged</li><li>**Regexp data limit propagated to named scopes**: `regexp.max_size` was applied to the default scope only, so regexps registered in a named scope — multimap `regexp_rules`, for instance — were matched against unbounded input while the default scope was capped at 1 MiB. Named scope caches are now created inheriting the default scope limit, and setting the limit updates every scope registered so far, which covers both orderings: Lua scopes registered before the regexp module is configured, and plugins registering scopes during filters init</li><li>**Lua URL filter consulted at most twice per user field**: Once the user field of a URL exceeded `max_email_user`, the parser consulted the Lua URL filter on every subsequent byte, and each call re-scanned the whole prefix — control characters, UTF-8 validation, `@` counting, custom filters. The 512-byte user limit in Lua never applied here because the mid-parse fragment contains no `@` yet, so this ran up to the 16 KB threshold: quadratic scanning plus thousands of C-to-Lua transitions, turning a single 16 KB URL into ~0.6 s of CPU. The filter is now consulted at most twice per user field — once when the limit is first exceeded, and once when the field ends at `:` or `@` so custom filters still see the complete userinfo. Parsing results are unchanged; the same URL now takes about a millisecond</li><li>**Words retained for a message bounded by a message-wide budget**: The UTF tokenizer marked decayed words as skipped but still added them to the words vector, unlike the raw tokenizer which drops them — so the decay bounded nothing. Every word of a text part was retained at 72 bytes each and then normalized and stemmed with several pool allocations per word: a single 5 MB text part of 500k words cost 34 MiB of word vectors plus 27 MiB of pool allocations where the decay is meant to keep around 1200 words. The 200 ms tokenization guard did not help either, since it was only enabled for parts above 1 MiB, so a message of many sub-megabyte parts could accumulate huge word vectors within the 50 MiB message limit. Decayed words are now dropped as the raw tokenizer does; a message-wide budget (100k words, 1 MiB of text) is shared by all text parts and the meta words, with parts that hit it flagged as truncated and the message logged once; the time checks also trigger on the number of produced words; and skipped words are no longer normalized or stemmed. Note that decayed words of UTF parts are no longer visible to word regexp classes and part similarity hashes, which matches the long-standing raw tokenizer behaviour</li><li>**CSS tokeniser stack overflow on sequential comments**: `next_token()` called itself after each comment, so an input made of many comments in a row consumed stack proportional to their count. The parser&apos;s own recursion limit does not cover this path, and the self-call is only elided when the compiler applies the tail call — a debug or ASAN build keeps a real call and overflows the stack. The tokenisation loop is now iterative, continuing from the offset past each comment, with doctest coverage for leading, trailing, interleaved, nested and unterminated comments plus a 100k-comment stress case</li><li>**actrie startup segfault with jemalloc**: `acism_create()` allocated its buffers with GLib but released them with libc `free()`, which only works while both resolve to the same allocator. With the duplicated per-object jemalloc copies described above, `free()` inside the actrie library ran one jemalloc copy&apos;s deallocator on a pointer owned by whichever allocator GLib had bound to, segfaulting inside `rspamd_url_init()` during startup. The whole lifecycle now stays inside GLib, so the pair resolves to one allocator no matter how symbol interposition falls out ([#6153](https://github.com/rspamd/rspamd/issues/6153))</li><li>**rspamadm resolves SRV-based upstreams**: rspamadm never configured the upstreams library, so the upstreams context had neither an event loop nor a resolver. Plain host names still worked, as they resolve synchronously during config parsing, but the SRV form `servers = &quot;service=fuzzy+example.com&quot;` creates a placeholder that is only selectable after asynchronous resolution — which was never scheduled. Every fuzzy storage configured that way looked empty: `rspamadm fuzzy_ping -l` printed no servers and pinging failed with &quot;no fuzzy storage upstream available&quot;. The library is now configured right after the resolver is created, before any command loads its configuration, and since one-shot commands issue their first request before the resolve timer fires, a new `lua_fuzzy.wait_for_storages()` — used by `fuzzy_ping` and `fuzzy_hash` — waits for the selected rules to have servers, bounded by the request timeout; an explicit `-s`/`--server` override skips the wait as it bypasses the configured upstreams. A ping that cannot start now also reports the rule name instead of nil</li></ul><h3>Security</h3><ul><li>**Controller fails closed on a malformed password hash**: `rspamd_is_encrypted_password()` validated only the `$&lt;id&gt;` prefix of a configured password, not that a salt and a key follow it. A hash such as `$1$` therefore passed the check, reached the verification with both components absent, skipped the comparison block entirely and returned its initial value of TRUE — any supplied password authenticated, and was then cached as the valid one. The result is now initialised to FALSE and set only after a completed KDF comparison, and the malformed hash is logged instead of silently accepted. The component extraction also indexed three bytes into the string without checking its length, reading past the terminator for short inputs and making the fail-open depend on adjacent heap bytes; it now takes the length and bails out when a component is missing. The startup sanity check re-ran the same prefix-only test, so a malformed password also passed configtest silently — it now decodes the salt and the key and validates their lengths against the pbkdf matching the hash id, which additionally fixes a hardcoded reference to the first pbkdf in the list</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.1.3 - Hardening Release with DKIM/SPF Correctness Fixes, Parser Resource Bounds, Fuzzy Hash Observability, and a jQuery-Free WebUI</title>
      <link>https://docs.rspamd.com/changelog/4.1.3</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.1.3</guid>
      <pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.1.3 - Hardening Release with DKIM/SPF Correctness Fixes, Parser Resource Bounds, Fuzzy Hash Observability, and a jQuery-Free WebUI</h2><h3>Added</h3><ul><li>**Structured fuzzy match results and a diagnostics API**: Every fuzzy match now produces a structured record — rule, symbol, upstream, stored and queried digests, match type, probability, score, flag, value and hash timestamp — kept in the `fuzzy_matches` mempool variable and exposed to Lua via `task:get_fuzzy_results()`. All queried hashes, misses included, are recorded in `fuzzy_checked`. The symbol option for non-exact matches now carries both hash prefixes (`flag:found:prob:type:queried`), so a match is diagnosable from a scan report alone. The storage sets a flag bit in the first reserved byte of the reply when the digest field holds the digest actually resolved by the backend, and the client propagates it as a `confirmed` field, removing the guesswork about echoed queries when talking to legacy storages. A new `fuzzy_check.check(task, cb, rule, timeout[, hashes][, server])` Lua API queries a storage either with the hashes generated from the task message (shingles included) or with an explicit list of hex digests, reporting per-reply results including misses and error codes</li><li>**Configurable fuzzy probability weight curve**: The score multiplier for non-exact matches used to be `sqrt(prob)`, a concave curve anchored at zero. Since the storage never returns a probability below the shingle match threshold of 0.5, the whole reachable range collapsed into roughly (0.73 .. 1.0], so a marginal 17/32 shingle overlap scored almost as high as an exact match — combined with high-weight deny lists this turned weak matches into instant rejects. The curve is now `((prob - prob_bias) / (1 - prob_bias)) ^ prob_power`, anchored at the match threshold (`prob_bias` 0.5). With the default `prob_power` of 1.0 the multiplier is simply the fraction of the way from the threshold to an exact match (19/32 → 0.19, 24/32 → 0.50, 28/32 → 0.75); raise it per rule for harsher discounting of weak matches. The old zero-anchored curve is not preserved. Applies to text and HTML hashes; small images keep their existing normalized curve</li><li>**Persistent shingle sets in the redis fuzzy backend**: Shingle key suffixes are now stored as the `S` field of the digest hash on every ADD, making each digest self-describing and closing two long-standing blind spots. Digest-only deletions (for example periodic deletions driven by a hash list) previously left the digest&apos;s shingle slots orphaned; they are now reconstructed from the persisted set and removed when still owned by the deleted digest. REFRESH used to touch the shingle slots of the message that happened to trigger it — a different message by definition — and now refreshes the slots the digest still owns and reclaims the vacant ones, so a hash that keeps matching keeps its full shingle anchor instead of decaying. The sqlite backend needs no changes, since its shingles table already cascades on delete</li><li>**Per-hash storage introspection**: `rspamadm control fuzzyhash &lt;128 hex digest&gt;` asks all fuzzy workers for storage-side diagnostics of a specific hash: flag slots and values, creation time, remaining TTL and — using the newly persisted shingle set — slot ownership (owned/foreign/vacant out of total). Ownership directly answers the central false-positive triage question: can this hash still produce fuzzy matches, or has its shingle anchor decayed? The redis backend gathers everything in a single server-side script round trip; sqlite queries the digests and shingles tables synchronously</li><li>**Unkeyed client statistics**: Traffic from clients without a key (for instance those permitted by `allow_update`) was invisible in the key statistics, which made storage writers untraceable. Such clients are now tracked under a dedicated `unkeyed` pseudo-key with the same aggregate and per-IP counters as regular keys</li><li>**rspamadm fuzzy_hash command**: Computes the fuzzy hashes of a message per configured rule, matching the scanner tokenization bit for bit, optionally checks them against the storage with shingles included (`-C`), and queries storages for explicit hex digests (`-H`) — printing found and queried digests, probability, flag, timestamp and whether the storage confirmed the matched digest</li><li>**ClickHouse fuzzy match export**: New `Fuzzy.Hash`/`Fuzzy.Queried`/`Fuzzy.Rule`/`Fuzzy.Prob`/`Fuzzy.Flag` nested columns (schema version 11) filled from the structured fuzzy results, so false-positive investigations can start from the scanner-side journal instead of storage forensics</li><li>**Annotated X-Rspamd-Fuzzy header**: `milter_headers` now builds the header from the structured fuzzy results, annotating every matched hash with the rule, flag, probability, the queried hash for non-exact matches and the storage timestamp. It falls back to the legacy `fuzzy_hashes` pool variable when no structured results exist</li><li>**lua_http forbid_local**: `rspamd_http.request()` used to connect to whatever the URL or its DNS resolution yielded, loopback, link-local and RFC1918 destinations included. A new `forbid_local` option is enforced at the single connection chokepoint — after keepalive, upstream, numeric and DNS address selection — using the address class check plus the configurable `local_addrs` radix. It is enabled by default in `url_redirector`, where message URLs and their redirect targets are attacker-controlled and hop limits alone do not stop a crafted redirect from probing cloud metadata endpoints or internal services; operators resolving internal redirectors can set `forbid_local = false`</li><li>**ARF report enrichment**: Microsoft&apos;s JMRP feedback loop ships a well-formed feedback report whose `message/feedback-report` block only carries Feedback-Type, User-Agent and Version — Source-IP, Arrival-Date, Original-Mail-From and Original-Rcpt-To live only in the embedded original message. `lua_feedback_parsers` now recovers those fields from the original headers, filling only what the report itself left empty and recording provenance in `result.derived` so callers can tell reported data from inferred data: source IP from X-Originating-IP, then `client-ip=` of Received-SPF or Authentication-Results, then the first public IP while walking the Received chain; arrival date from the topmost Received timestamp, else Date; original mail from from Return-Path, else `smtp.mailfrom`; original recipient from Delivered-To, X-Delivered-To or envelope-to; and reported domain falling back to the envelope-from domain. `Received-Date` is also accepted as an Arrival-Date alias in the report block</li></ul><h3>Changed</h3><ul><li>**WebUI drops jQuery entirely** (incompatible): Every WebUI module — the AJAX layer, common helpers, the leaf modules (graph, selectors, history, symbols), config, libft, stats, upload and the main module — is migrated to native DOM and `XMLHttpRequest`, and the vendored jQuery build is deleted. The rewritten `query`/`queryServer` preserve jQuery&apos;s semantics: neighbours fan-out and aggregation, per-server error dispatch, `statusCode`, timeout, GET data appended as a query string, the default Content-Type for POST bodies, NProgress download progress and jqXHR-compatible callback arguments. Bootstrap 5 moves from the jQuery bridge to the vanilla constructor API, the `jquery.stickytabs` plugin becomes an inline implementation using the BS5 Tab API, and D3Evolution and D3Pie are updated to versions that dropped their own jQuery dependency. Drive-by: the `modalDialog` backdrop attribute is renamed to its Bootstrap 5 name `data-bs-backdrop`, so the static-backdrop behaviour is actually applied ([#6124](https://github.com/rspamd/rspamd/pull/6124))</li><li>**Font Awesome replaced by a local SVG sprite** (incompatible): The &quot;SVG with JS&quot; framework — roughly 900 KB of JavaScript plus its CSS — is removed in favour of a generated 35-glyph sprite. An initial sweep plus a MutationObserver replaces `&lt;i class=&quot;fas fa-name&quot;&gt;` with an inline `&lt;svg&gt;&lt;use href=&quot;img/icons.svg#fa-name&quot;/&gt;&lt;/svg&gt;`, carrying over non-class attributes and modifier classes such as `fa-spin`. Each icon keeps its natural aspect ratio, and CI verifies that every icon referenced in the markup is listed in the manifest and that the generated sprite is not stale ([#6145](https://github.com/rspamd/rspamd/pull/6145))</li><li>**HAVE_ED25519 scoped to OpenSSL key parsing** (incompatible): All Ed25519 crypto in the DKIM path is libsodium, which is a mandatory dependency — verification, signing, key sizes and the public/private match never touch OpenSSL. `HAVE_ED25519` nevertheless probed OpenSSL for `EVP_PKEY_ED25519` and gated that libsodium code, so an OpenSSL build without Ed25519 refused to verify ed25519 signatures even though libsodium was fully capable of it. The probe is kept at the single site that genuinely needs it — unwrapping a PEM/DER private key for signing — and dropped from the nine libsodium-only sites</li></ul><h3>Fixed</h3><ul><li>**DKIM public key size and modulus width**: `rspamd_dkim_make_key()` took the `p=` tag of a DNS TXT record, checked only that it held at least three bytes, then allocated two buffers of that size and handed the decoded result to OpenSSL. Since rdns falls back to TCP on a truncated reply, a zone can serve roughly 64k of key material per selector, and the LRU key cache is capped by entry count rather than by bytes — so a hostile zone could pin hundreds of megabytes with one message per selector. `p=` is now capped at 4096 base64 characters before any allocation (a 4096-bit RSA key needs 736), and keys wider than 8192 bits are rejected once parsed, since verification cost grows with the modulus and RFC 8301 only requires verifiers to handle up to 4096 bits. Only verification is affected; signing keys take a different path</li><li>**DKIM empty-line skipping read before the body slice**: The backwards scan for trailing empty lines guarded its lookbehind, but the else arm of that guard then read one byte before the start of the slice anyway — reachable from any body consisting only of line endings. The byte in question is the last byte of the header/body separator, so it stayed inside the message allocation, but the canonicalised result depended on data outside the input. The probe is replaced by assigning the empty-body sentinel the function already uses; a differential run over every body up to four bytes drawn from CR, LF, space, tab and a letter gives identical results for both canonicalisation types across all 1560 cases, so no body hash changes</li><li>**DKIM max_sigs applied before work, and every signature counted**: The per-message signature limit was tested at the bottom of the loop body, after the DNS request for the current signature had already been started, and it incremented before comparing with `&gt;`, so the default of five permitted six. Worse, the three `continue` paths above it — parse failure, `trusted_only` skip and a failed key lookup — jumped over the increment, so signatures taking those paths never counted at all and the real bound was `max_mime_headers` (100000). The check now runs at the top of the loop, before any work is done for the header, and counts every signature header examined. This shifts the meaning of `max_sigs` from &quot;signatures whose key we looked up&quot; to &quot;signature headers examined&quot;: five junk signatures ahead of a valid one now push the valid one out, which was already reachable with cheap parseable decoys</li><li>**DKIM h= header list bounded**: `rspamd_dkim_parse_hdrlist_common()` counted every token in `h=`, preallocated that many pointers and then allocated a name string and a header record per non-empty token, so a size-limited message expanded into roughly 32 bytes of allocation per two input bytes. The two loops used different predicates, so an `h=` made only of colons reached the full preallocation while creating no items — the cheapest form of the amplification. The item count also drove the header selection loop, where each entry costs a hash lookup plus a duplicate walk. The list is now capped at 1000 items, checked before any allocation; the shipped `default_sign_headers` list has 27 entries. Both parsing directions share this function, so signing is bounded too</li><li>**SPF returns permerror when a DNS limit is hit**: Exceeding `max_dns_requests` or `max_dns_nesting` only left the offending element unparsed, so the record was still evaluated with whatever fitted in the budget and usually ended up as a definitive fail via its trailing `-all`. RFC 7208 §4.6.4 requires permerror instead, and rightly so: the record has not been evaluated to the end, hence its verdict is unknown rather than negative. Records carrying any flag are not put into the LRU cache, so a permerror is not stored for the TTL of the record</li><li>**SPF include/redirect nesting limit enforced**: `rec-&gt;nested` was checked but never incremented, so `max_dns_nesting` had no effect at all and an include chain was bounded only by the DNS request counter — which was itself compared with `&gt;` while being incremented after the check, so 31 DNS elements were evaluated with a limit of 30. The depth is now stored per resolved element (resolution is asynchronous, so there is no call stack reflecting position in the tree) and checked when an include or redirect is about to be followed, and the request counter uses `&gt;=` so exactly `max_dns_requests` elements are evaluated. Flattening a record recurses over the same chain and is bounded by a hard nesting limit that applies even when the configurable limits are relaxed or disabled</li><li>**SPF address lookups from mx/ptr expansion bounded**: Names returned by mx and ptr replies were expanded to A/AAAA requests while checking a DNS counter that never changed, and SPF uses the forced resolver API that bypasses `dns_max_requests`, so a single large RRset produced an unbounded number of queries. Both RFC 7208 §4.6.4 limits are now enforced — 10 address lookups per mx/ptr element — alongside a new per-record budget for all expansions, configurable as `max_dns_expansions` (100 by default). Exceeding them for mx, or running out of the record-wide budget, yields permerror for the whole record instead of silently dropping the element (which used to surface as `R_SPF_FAIL` via `-all`); extra ptr names are ignored as the RFC requires</li><li>**SPF exists attached to the wrong element**: `parse_spf_exists` picked the current record&apos;s last resolved element, which only holds while nothing else has appended an element of its own — an `include` earlier in the same record does exactly that, so from then on every `exists` in that record was attached to the include&apos;s element. The element is now passed down as the other mechanisms already do. Since the domain spec of an `exists` is expanded before this point, the visible effect so far was limited to a wrong domain in the diagnostics of an unresolvable `exists`</li><li>**Fuzzy admission checked before parsing UDP commands**: Every datagram used to be parsed, and decrypted when encrypted, before any admission check ran — the blocklist and the rate limit were only consulted well after a session allocation, an ECDH, a MAC verification and the Lua pre-handlers. The blocklist is now checked in the UDP read loop before allocating a session, so a blocked peer costs one radix lookup; a failed key lookup or MAC check is logged at debug rather than error level, since both are reachable by anyone who can send a datagram and an error line per packet turned a spoofed-source flood into a log volume attack. The per-source rate limit is extended from FUZZY_CHECK to PING and STAT, which are answered unauthenticated and were previously unmetered, and rate-limited PING/STAT are dropped rather than answered. The ratelimit bucket allocation is also skipped when rate or burst are unset, which previously allocated an LRU entry per masked source that could never limit anything. `blocked_requests`, `decrypt_errors` and `ratelimited_requests` are reported in fuzzystat so the drops that are now silent stay observable</li><li>**Fuzzy sqlite backend id stack overread**: The backend id was built with `%xs`, which treats its argument as a NUL-terminated string and calls `strlen` on it — but the hash is a raw 64-byte digest with no terminator, so building the id read past the end of the stack buffer on every sqlite backend open. An explicit length is now passed</li><li>**Fuzzy error replies name the rule and the server**: 403 (rate limit), 503 (access denied) and 415 (encryption required) replies used to insert their symbols silently, so with several rules configured there was no way to tell from the logs which rule and which server produced the error. Both the UDP and TCP reply paths now log the rule name and the upstream</li><li>**Nested comment depth in the ragel header parsers**: The Content-Disposition and SMTP date grammars parse RFC 5322 nested comments via `fcall`, which pushes an entry onto a heap-allocated state stack per nesting level. The stack had no depth limit, so a single header allocated memory proportional to its own length — roughly 12 bytes per input byte, with unbalanced opening parens as the worst case — and bounded only by `max_message`, a crafted header could push a worker to around 530 Mb of transient allocation, where a realloc failure hit an assertion and aborted the process. Nesting is now capped at 8 levels and the parser drops into the error state once it is exceeded, so it stops after a fixed number of bytes rather than consuming the whole header; realloc failure now fails the parse and keeps the old allocation</li><li>**MIME parser, MIME headers and archive metadata resource bounds**: All three now bound the resources a single message can make them allocate</li><li>**Content-Type parameters per header, and RFC 2231 ordering**: A single Content-Type or Content-Disposition field can carry an unbounded number of parameters, each materialising a pool object plus a hash or list entry, so a message bounded at `max_message` could amplify into millions of allocations from one folded header. The existing header-count cap limits header fields, not parameters within a field. Parameters are now capped at 1024 per header, counted rather than deduplicated so a same-name flood is bounded too; Content-Type flags the truncation as broken. Also fixed: `rspamd_cmp_pieces` subtracted two unsigned RFC 2231 continuation ids and truncated to `int32_t`, reversing the reconstruction order when the ids differ by more than `INT32_MAX`</li><li>**Per-part newline metadata bounded, budget made task-global**: Newline normalization recorded every newline as a pointer array entry plus a mempool exception object and a GList node, then sorted the whole exceptions list, so a newline-dense body turned a few MiB of input into hundreds of MiB of heap. Recorded newline positions are capped at 100k per part — text normalization and line counters are unaffected — with a part flag on truncation exposed as `newlines_truncated` in `textpart:get_stats()`, and the budget is now task-global rather than per-part</li><li>**Plain text parts linked to their HTML alternative**: The alternative part search compared the raw `IS_TEXT_PART_HTML` flag value (0 or 4) against the boolean `want_html` argument (0 or 1), so a text/plain part never matched its text/html sibling and only the html-to-plain direction of `alt_text_part` was ever populated</li><li>**Alt-part linking and fasttext langdet cost bounded**: Alternative text part linking used one subtree scan per text part, so a crafted multipart/alternative with thousands of sibling text parts made linking quadratic — around 100M sibling visits at the MIME part cap. All searches of a task now share a single visit budget, and the remaining parts are left unlinked with a warning once it is exhausted. Fasttext language detection fed up to 1M words into the model with no bound on word length or resulting token count; since the UTF tokenizer intentionally does not enforce `max_word_len`, a body made of huge unspaced words expanded into three or four subword ngram ids per character. Overlong words are now skipped, the total token count is capped, and the shim refuses OOV subword expansion for words longer than any real dictionary entry</li><li>**HTML DOM recursion eliminated**: `html_append_tag_content`, `traverse_block_tags` and `html_debug_structure` are converted from native-stack recursion to explicit heap stacks, and `html_tag::depth` is maintained incrementally instead of walking the parent chain per tag. With `max_tags` at 8192 a message can legally nest around 8190 elements, which overflowed 512KB worker-thread stacks; a regression test parses 8190 nested divs</li><li>**HTML attributes bounded per tag and per task**, and **synthetic tags capped** with a fix to the tag balance loop</li><li>**HTML image style dimension parsing**: The parser found a digit but called `rspamd_strtoul` from the start of the substring, ignored its return value and assigned the result regardless. For substrings longer than 22 characters `rspamd_strtoul` returns before writing the value, leaving image height and width set from an uninitialized variable; shorter CSS parsed to zero because the substring began with a colon or whitespace. The digit run is now parsed from the located digit with a bounded length, and assigned only on success</li><li>**Quadratic Content-ID image linking**: Linking images to their parts no longer scales quadratically with the number of parts</li><li>**7zip folder count bounded and parser progress guaranteed**: The folder-count limit was applied only in the internal-folders branch, so the external branch preserved an unchecked 64-bit count; the later `kCodersUnPackSize` loop&apos;s error branch then logged once per folder without consuming input, allowing a non-progressing CPU and log loop that the synchronous parser cannot be interrupted from by the task timeout. The limit now applies immediately after reading the folder count, external-folder metadata is rejected rather than accepted with inconsistent downstream state, the error branch returns early so every loop iteration advances or bails out, and the folder tables move from stack allocation to the task mempool</li><li>**max_urls enforced at the central insertion boundary**: All URL sources — HTML links, query URLs, images, displayed URLs, subject URLs and Lua-injected URLs — now respect the configured `max_urls` limit rather than just plain-text callbacks, and the text pre-checks are exact rather than off by one. A duplicate part-url add for newly-seen text URLs is also removed</li><li>**url_suspect dead branch and mailto URLs**: Both arms of the `length_thresholds.suspicious` branch inserted the very same `URL_USER_PASSWORD` finding, so the threshold never affected anything; the branch and its now-unused setting are removed. mailto URLs are also rejected explicitly in the same check, since every email address carries a local part and the presence of a user is meaningless there</li><li>**Task zstd decompression bounded by max_message**: The legacy compressed-message path allocated the output buffer straight from the frame-advertised decompressed size and doubled it without any ceiling, so a zstd bomb within the HTTP size limit could balloon memory far beyond `max_message`; the decompression-error return also leaked the output buffer, which was registered with the task pool only on success. Frames advertising more than `max_message` are now rejected upfront, the growth loop enforces the limit, and the buffer is freed on all error returns. The same upfront clamp is applied to the v3 multipart path, which now also honours `max_message == 0` as unlimited, matching the HTTP layer convention</li><li>**One bounded zstd decompression helper across HTTP, proxy and maps**: The streaming decompression loop was copy-pasted in seven places with inconsistent bounding. The proxy had no output ceiling at all — on request and response bodies and on the v3 multipart result and body parts, a zstd bomb from a client or backend could balloon memory without limit. The task and v3 protocol paths were bounded but enforced the limit only when the buffer filled, letting output overshoot `max_message` up to twofold, and never validated the final length; the map shm-cache, file-cache and static paths grew their buffers with no ceiling. A single `rspamd_zstd_decompress_bounded` now validates the frame-advertised size before any allocation, caps buffer growth strictly at the limit, drains pending decoder output and frees the buffer on every error path. All seven sites are converted: `max_message` bounds the task, protocol v3 and proxy paths, `max_map_size` the map cache and static paths</li><li>**HTTP request bodies bounded on the controller, proxy and control sockets**: The normal worker caps HTTP bodies at `max_message`, but the controller router, the proxy client connection and the main control socket never set a maximum — a declared Content-Length caused an unbounded preallocation and chunked input could grow until the connection ended, all before routing or authentication. The HTTP router gains a `max_size` knob applied to every accepted connection, with `max_message` set as the cap on all three; oversized requests are rejected with 413 on the controller and a closed connection on the proxy</li><li>**HTTP read deadline enforced when data is pending at timer expiry**: The read timer is a one-shot timer. When it expired while data was already waiting in the socket, the handler drained and parsed that data, and if the message remained incomplete the connection continued with only the I/O watcher active and no deadline at all. A 408 is now reported instead, unless the drained bytes completed the message</li><li>**Remote HTTP map sizes bounded, compressed and decompressed**: The map client connections never set a maximum body size, so a map server could feed an arbitrarily large body that is fully retained in shared memory, and the zstd path allocated the output buffer from the frame-advertised size and then doubled it without limit. A new `max_map_size` option (256 Mb by default, 0 disables) is applied as the HTTP body limit on both map client connections, frames advertising a larger decompressed size are rejected upfront, and the same ceiling is enforced in the streaming growth loop. It can be overridden per map with `max_size` in the `maps { }` block, like the existing timeout and keepalive knobs</li><li>**Map signature file mapped as a file**: `rspamd_map_check_file_sig()` receives a filesystem path and maps the `.pub` counterpart as a file, but reached for the `.sig` one via the shared memory helper — which on any platform with POSIX shared memory means `shm_open(&quot;/path/to/map.sig&quot;)`, rejected outright by the flat shmem namespace, so verification of a signed file-backed map could never succeed there</li><li>**Credentials redacted from map error logs**: Map URIs may carry HTTP basic-auth userinfo, which was logged verbatim in critical messages that feed the `/errors` ring buffer exposed via the controller, leaking credentials to anyone reading it. Error logs now use a redacted copy with the userinfo replaced; the real URI is kept intact, since it serves as the configuration key and carries the userinfo needed for the Authorization header ([#6146](https://github.com/rspamd/rspamd/pull/6146))</li><li>**Shared memory mapping helpers hardened**: `rspamd_shmem_xmap()` mapped whatever the name resolved to, so a fifo, a device or a directory ended up in `mmap(2)` with a meaningless size, and the size output was left untouched on failure while its file counterpart has always reported a defined value. Non-regular and empty segments are now rejected explicitly, the 32-bit address space overflow is guarded, the failing errno is kept intact across `close(2)`, and the size output has the same meaning as in the file variant. `rspamd_shmem_mkstemp()` also retried `shm_open(2)` forever on EEXIST, so the loop is now bounded and a permanently colliding namespace cannot spin a worker</li><li>**lua_http responses bounded by default**: Lua HTTP client responses were unlimited unless a plugin explicitly passed `max_size`, and a negative `max_size` was converted straight to an unsigned type, silently producing an effectively unlimited cap. A new `max_lua_http_response` option (256 Mb by default, 0 disables) is applied whenever a request does not specify `max_size`; an explicit `max_size` of 0 still disables the limit per request, and negative values are rejected with a logged error instead of wrapping</li><li>**lua_http errors delivered to coroutine callers**: DNS-stage failures — resolve error, no records, connection refused — went through a path that unconditionally invoked the callback reference, but coroutine-style requests have no callback, so the error was swallowed and the yielded thread never resumed. The thread is now resumed with `(err, nil)`, mirroring the connection error path. Synchronous failures (unparseable URL, blocked session, immediate connection or DNS-send failure) returned a bare `false`, which coroutine callers read as `err = false, response = nil` — a crash on `response.code` for every caller checking `if not err`; those now return `(err, nil)` as well</li><li>**lua_tcp double free on connection release**: A Lua callback calling `close()` sets a sticky finished flag observed by more than one release site, dropping the connection ownership reference twice and freeing the connection data while the handler was still running. The ownership drop is now guarded by a one-shot flag so the first site to observe termination wins and the rest become no-ops</li><li>**PDF text line breaks**: The Td/TD text positioning operators never produced a newline — the grammar did not capture the operator name, so the handler compared a numeric operand against the string `Td` (never true) and additionally read the wrong operand. Consecutive text lines were therefore concatenated, so for example &quot;…June 1, 2026.&quot; followed by &quot;You may obtain…&quot; became &quot;2026.You&quot;, which the URL parser mis-detected as `http://2026.you` and raised false positives. The operator is now captured and a newline rendered when the vertical displacement is non-zero</li><li>**Duplicate module sections warned about**: `rspamd_config_get_module_opt()` returns the rule or option object from the first section only when a module section is duplicated at the top level — for example by a stray file in `modules.d` — so the remaining sections are silently ignored and their rules are never loaded. An explicit warning is now emitted per configured C module, so configtest and the startup log surface the problem instead of silently dropping rules. Lua modules are unaffected, since they flatten the whole duplicate chain</li><li>**WebUI: malformed /stat response routes to the login dialog**: The connect probe swallowed JSON parse errors, so a 2xx `/stat` with a non-JSON body — truncated by a proxy, a captive-portal page, corruption — loaded an empty, half-loaded main UI with a &quot;Cannot get server status&quot; alert and no login path. The body is now parsed explicitly and any failure shows the connect dialog, matching the pre-migration behaviour. The unreachable 304 success branch inherited from jQuery&apos;s default success range is also removed, since the controller sends `Cache-Control: no-store` and never emits Last-Modified or ETag for these endpoints</li><li>**WebUI: smooth scroll restored on the Scan tab**: The jQuery removal replaced the animated scroll with `window.scrollTo({behavior:&quot;smooth&quot;})`, which honours `prefers-reduced-motion` and is interruptible by Tabulator&apos;s reflows, so the scroll frequently did not happen. A requestAnimationFrame-based implementation (400 ms ease-in-out, matching jQuery&apos;s default) is used for both Scan scrolls, preserving the previous behaviour</li><li>**WebUI: refresh spinner no longer restarts mid fan-out**: The active-request counter was decremented at the top of the completion handler, before the success callback, so during an &quot;All SERVERS&quot; query the count dipped to zero between the neighbours probe and the per-neighbour requests, re-firing the start hook and restarting the spinner&apos;s CSS animation. The decrement moves to the end, after the success and complete callbacks</li><li>**WebUI: jQuery-removal regressions**: The optional `fuzzy_hashes` key is guarded in the fuzzy storage stats, which threw on every Status refresh when `fuzzy_check` was disabled or a neighbour was down; the hide and show slide animations are cancelled once the final state is committed, so a pinned height no longer makes a later re-show animate 0→0; a single element exposing a numeric `length` (such as `&lt;select&gt;` or `&lt;form&gt;`) is treated as one node rather than a collection; a null neighbours body is tolerated; the active-tab lookup is null-guarded in two spots; and a dead upload parameter is dropped</li></ul><h3>Security</h3><ul><li>**DKIM bh= length not bounded before the body hash comparison (out-of-bounds read)**: The bh= length validation covered RSA and ECDSA but omitted ed25519, so an ed25519 signature carried a fully attacker-controlled body hash length — `parse_bodyhash()` sizes the buffer from the base64 input and stores whatever length decoding yields. That length was then passed to `memcmp()` against buffers that are always `EVP_MAX_MD_SIZE`, reading out of bounds once bh= decodes to more than 64 bytes. The comparison runs before the key type and signature algorithm compatibility checks, so a mismatched key record does not prevent it; an attacker only needs a parseable DKIM key at a domain and selector under their control, and since the overread scales with the header size it can walk off the stack rather than merely tripping a sanitizer. Ed25519 is added to the parse-time length check, and the comparison site is additionally guarded by an algorithm-agnostic length equality test so a future algorithm cannot reopen the same hole by missing a parse-time case</li><li>**Fuzzy TCP session use-after-free on aborted connections**: Every terminal condition on a fuzzy TCP connection — EOF, read error, invalid frame length and the timeout — simply released the session, which is not enough: each in-flight command retains the session too, so with commands outstanding the refcount stayed nonzero, the destructor did not run and both watchers remained armed. EOF is permanently readable, so the next poll re-entered the I/O handler and released the owner reference a second time, freeing the session while the command callbacks still pointed at it; the timeout had the same defect, being a repeating timer. A single guarded termination path now stops the watchers, closes and poisons the descriptor and drops the owner reference exactly once, and late replies for a closed session are discarded instead of resurrecting I/O on a dead connection. A fatal write error, previously conflated with a retryable short write, left the write watcher armed on a permanently writable error condition and spun forever; such a session is now closed. Reproduced under ASAN by aborting a connection while its backend lookup was still pending</li><li>**Shared memory segment bounds not validated (protocol)**: `Shm-Offset` and `Shm-Length` were each checked against the segment size on their own but never together, so an offset near the end combined with a full length passed both checks and produced a body running past the mapping — SIGBUS on a page-multiple segment, adjacent memory in the scan result otherwise. Both protocol loaders carried their own copy of the code and hence of the bug. On platforms without POSIX shared memory the segment name is an ordinary path, and `open(2)` on a name that happens to be a fifo blocked the whole worker until somebody opened the writing end. Both versions now share one validated helper that opens non-blocking and rejects empty, over-long and control-character names, non-numeric or overflowing offsets and lengths, non-regular and empty segments, offset plus length beyond the segment, and payloads over `max_message`. An offset without a length now means &quot;up to the end of the segment&quot; rather than &quot;the whole segment&quot;, and the v3 metadata path rejects negative values and no longer leaks an fstring plus a token per request</li><li>**Shared HTTP body storage lifecycle (double free, descriptor leak, closing an unrelated socket)**: The proxy cleared the shared-memory flag by hand before installing a new body, so the following `set_body()` cleaned up the storage as if it were an fstring and freed the refcounted shared storage the union aliases, while the segment descriptor and its mapping leaked. A new helper releases the shared storage while the flags still describe it. The storage cleanup also skipped a segment sitting on descriptor 0, leaking both the descriptor and the mapping whenever the kernel handed us that one — and releasing it uncovered why that looked harmless: a message can carry the shared flag before a segment exists for it, since a body-less copy inherits the flag from its origin, and the zero then read as a valid descriptor and closed whatever occupied slot 0, which in a proxy worker is the accepted client socket. The descriptor is now initialised to -1 so it is only ever valid for a real segment. Also: the name is reset after release so the next cleanup cannot release it twice, an `fstat(2)` hidden inside an assertion is dropped, non-regular and empty descriptors are rejected, the length overflow in body growth is guarded, and a copied message&apos;s body is validated to fit the segment it is mapped from</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.1.2 - Patch Release with a Critical MIME Recursion DoS Fix, Sender-Identity Preservation for DMARC/SPF, Composite Lua Conditions, and the WebUI Tabulator Migration</title>
      <link>https://docs.rspamd.com/changelog/4.1.2</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.1.2</guid>
      <pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.1.2 - Patch Release with a Critical MIME Recursion DoS Fix, Sender-Identity Preservation for DMARC/SPF, Composite Lua Conditions, and the WebUI Tabulator Migration</h2><h3>Added</h3><ul><li>**Composite conditions and explicit dependencies**: Composite atoms can now be gated with synchronous Lua functions, by analogy with the `re_conditions` mechanism of mime expressions: `rspamd_config:add_composite()` accepts a full definition table with a `conditions` map from symbol name to `function(task, symbol)`, where `symbol` arrives in the `task:get_symbol()` layout. A condition may return true/false or a number used as the atom weight; it is ANDed with the atom&apos;s option filters, and a failed condition is treated exactly like a missing symbol, so removal policies are not applied. Since a condition can consult symbols invisible to the expression itself, an optional `depends_on` list feeds the first/second composite pass placement as if those symbols were expression atoms — letting a condition safely consult, for example, a postfilter symbol ([#6126](https://github.com/rspamd/rspamd/pull/6126))</li><li>**Settings apply policy and strict explicit_enable gating**: Symbols carrying the `explicit_enable` flag now genuinely require explicit enabling. Previously they were executed whenever *any* settings were applied to a task, regardless of content; now they run only when listed in `symbols_enabled` of the applied settings or force-enabled via `task:enable_symbol()`. To combine the default-allow behaviour with selectively unlocked symbols, the `apply` block gains a policy option: with `policy = &quot;implicit_allow&quot;`, `symbols_enabled` no longer switches the settings into whitelist mode — all symbols stay enabled, the listed ones are merely unlocked, and `symbols_disabled` still subtracts. The policy is honoured for raw settings passed via the `Settings` header as well, and symbols listed in `symbols_enabled` are now always tracked as force-enabled, which previously missed the map-driven settings path ([#6144](https://github.com/rspamd/rspamd/pull/6144))</li><li>**static_embed neural provider**: A Model2Vec-style static token-embedding provider, the cheap multilingual successor to `fasttext_embed`. Words from rspamd&apos;s regular tokenization pipeline are re-tokenized into WordPiece subword tokens (ICU-based Bert normalization, Bert pre-tokenizer, greedy WordPiece) and embedded by mean-pooling rows of a precomputed float32 matrix, mmap-ed and shared between workers — no neural forward pass, no FFI, and no new dependencies. Model loading is strict and fail-fast: unsupported normalizer/pre-tokenizer/model types, non-mean pooling, a non-float32 matrix, or a vocab/matrix size mismatch fail the load instead of degrading silently. The subword tokenizer stays internal to the vectorizer, so Bayes tokens and fuzzy hashes are untouched, and a `get_token_vectors()` method exposes the per-token embedding sequence for offline consumers such as external trainers ([#6118](https://github.com/rspamd/rspamd/pull/6118))</li><li>**Neural forced-learn fast path and training freeze**: Two new training controls. `train.forced_learn_minimal_scan` (default on for symbol-independent rules) installs a high-priority prefilter that disables every non-neural symbol on an ANN-Train scan, so a training vector is built without issuing RBL/DNS lookups, fuzzy checks, Bayes classification, or ClickHouse work — with the stored vector and profile key byte-for-byte identical to the live full-scan path, and an automatic fallback to a full scan if any applicable rule needs symbols. `train.frozen` stops automatic training and auto-storing of live vectors, so a frozen model&apos;s training pools cannot drift, while inference keeps serving the current ANN; an explicit ANN-Train request still stores and retrains on demand. A supporting `task:disable_all_symbols([skip_mask])` Lua binding is also added</li><li>**External rspamadm command discovery**: `rspamadm` now discovers Lua sub-commands beyond the built-in directory — from the `rspamadm.d` drop-in directory under the configuration root (consistent with `local.d`), and from every directory listed in the colon-separated `RSPAMADM_COMMAND_PATH` environment variable. Duplicate command names are skipped with first-wins ordering, so built-in commands can never be shadowed, and externally loaded modules run with the same globals and Lua path as built-in ones. Third-party or premium packages can now ship `rspamadm` commands without writing into the rspamd-owned lualib tree</li><li>**rspamadm password from the environment**: The controller password can be supplied via the environment instead of an interactive TTY prompt, so automation (for example, a continuous-deployment pipeline verifying a clean state) can drive password-protected `rspamadm` operations non-interactively ([#6114](https://github.com/rspamd/rspamd/pull/6114))</li><li>**Symcache flags in configdump**: `rspamadm configdump --symbol-details` now reports a `flags` array per symbol (`fine`, `empty`, `explicit_disable`, `explicit_enable`, `ignore_passthrough`, `nostat`, `idempotent`, `mime`, `trivial`, `skip`, `composite`, `ghost`, `coro`), making it possible to check — for instance — whether a symbol requires explicit enabling under the new settings policy; structural types remain in the `type` field</li></ul><h3>Changed</h3><ul><li>**&apos;orig&apos; address flavour returns wire addresses only** (incompatible): `task:get_from({&apos;mime&apos;, &apos;orig&apos;})` previously returned both the preserved original addresses *and* the rewritten ones appended by `task:set_from`, making the flavour unusable for consumers that need the message as it arrived — a duplicate-From check would misfire on the combined list, and Lua could not tell the entries apart. Now, when originals were preserved by a rewrite, only the original addresses are returned; without a rewrite the behaviour is unchanged. The `aliased` and `original` email-address flags are exposed to Lua, and the flavour is reachable from selectors as `from(&apos;mime&apos;, &apos;orig&apos;)`. This rework underpins the DMARC/SPF sender-identity fixes below ([#6141](https://github.com/rspamd/rspamd/pull/6141))</li><li>**WebUI tables migrated from FooTable to Tabulator** (incompatible): Every WebUI table — errors log, throughput summary, symbols, history, and scan results — now uses Tabulator, and the vendored FooTable library is removed. FooTable is unmaintained (last release 2017) and jQuery-bound, while Tabulator is vanilla JS and survives the planned jQuery removal. The migration brings per-column header filters on all tables, a global boolean search box for history/scan with the familiar query language (whitespace = AND, `OR`, `-` to exclude, quoted exact phrases) that combines with the column filters, persisted column visibility, and theming mapped to Bootstrap CSS variables so light and dark modes flip automatically ([#6120](https://github.com/rspamd/rspamd/pull/6120))</li></ul><h3>Fixed</h3><ul><li>**Alias rewriting no longer touches From domains**: The From address is the sender&apos;s identity — SPF, DKIM, and DMARC all key on its domain — yet a virtual alias mapping to another domain could rewrite it, breaking authentication of perfectly legitimate mail. Service rules and alias resolution may now only touch the local part of the From address; any rewrite that would change the domain is discarded. Recipient rewriting is unaffected, since recipients are interpreted by the receiving host and no authentication protocol keys on them. A spurious `task:set_from` call is also fixed: a rewrite of any earlier address (such as an envelope recipient) previously caused the unmodified MIME From to be marked as aliased and duplicated ([#6137](https://github.com/rspamd/rspamd/issues/6137))</li><li>**googlemail.com is never rewritten to gmail.com**: These are DNS-distinct domains with independent SPF and DMARC records (gmail.com publishes `p=none` while googlemail.com publishes `p=quarantine`), so substituting one for the other broke DKIM alignment, changed the effective DMARC policy, and misattributed DMARC reports. The gmail-style user-part canonicalization (dots and plus tags) is kept for googlemail.com addresses, but the domain is preserved; the equivalence remains available in `lua_util.remove_email_aliases` for dedup-only consumers such as email-hash RBL lookups ([#6137](https://github.com/rspamd/rspamd/issues/6137))</li><li>**DMARC evaluates the message as transmitted**: DMARC must align identifiers and look up the policy for the RFC5322.From domain as it appears in the message, and check SPF alignment against the RFC5321.MailFrom as transmitted. The module now uses the &apos;orig&apos; address flavour, so rewrites done via `task:set_from` (for example by the aliases module) no longer affect alignment, policy lookup, or rua report attribution ([#6137](https://github.com/rspamd/rspamd/issues/6137))</li><li>**SPF checks the original envelope sender**: SPF evaluates the RFC5321.MailFrom as it was transmitted, including local-part macros, so the SPF record and credentials are now resolved from the original envelope sender even when the envelope from has been rewritten ([#6137](https://github.com/rspamd/rspamd/issues/6137))</li><li>**Forged recipients compares addresses at the same altitude**: The rule compared the rewritten envelope view against the wire MIME headers, so a recipient alias mapping to another domain — legal for recipients — could make a perfectly matching To header look forged. Both the envelope and the headers are now compared as they were seen in the message, restoring what the rule checked before alias rewriting was applied to tasks ([#6137](https://github.com/rspamd/rspamd/issues/6137))</li><li>**Semicolon-separated address lists**: A semicolon is not an RFC 5322 address-list separator (it only terminates a group construct), but Outlook-style `;`-separated recipient lists are pervasive in real mail and most MTAs split them. Previously such a list was parsed as a single malformed address whose local part swallowed the semicolon and the next address, breaking recipient counts and producing false invalid-address signals. `;` is now treated exactly like `,`, mirroring the comma behaviour in every edge case: semicolons inside quoted strings are preserved, `undisclosed-recipients:;` yields no addresses, and a group&apos;s trailing `;` no longer leaks into the last member&apos;s domain ([#6143](https://github.com/rspamd/rspamd/pull/6143), [#6139](https://github.com/rspamd/rspamd/issues/6139))</li><li>**Fuzzy hashing skips injected parts**: Parts injected into the task by computation, rather than present in the original message, are now excluded from fuzzy hashing on both learn and check, so fuzzy storage operates only on genuine message content</li><li>**Phishing cross-TLD brand false positives**: Displaying `brand.co.uk` over a `brand.com` link no longer fires PHISHING at full weight. Registrable labels are now compared instead of stripping only the last dot component (the old code handled only single-label suffixes, so `brand.co.uk` vs `brand.com` was Levenshtein-compared as full strings); the DMARC-authenticated-sender exclusion now normalises the From domain to its eSLD so it actually matches when DMARC reports a subdomain; and `strict_domains` is checked regardless of the computed weight, so displaying a strictly protected domain over a same-label, different-suffix target still fires</li><li>**Glob map patterns anchored to the whole subject**: Glob map entries were compiled into unanchored regexps with substring-search semantics, so a `t.co` entry matched `walmart.com` and `*.bit.ly` matched `foo.bit.ly.evil.com`. Translated patterns are now wrapped in `^(?:...)$` at map load time: bare names match exactly, wildcards match only what they say. This affects all glob maps — multimap `glob`/`glob_multi`, url_redirector `redirector_hosts_map`, dkim_signing/arc signing and key tables, mx_check exclusions, and the rbl glob returncodes matcher. Maps that relied on the accidental substring behaviour must now use explicit wildcards ([#6125](https://github.com/rspamd/rspamd/issues/6125))</li><li>**Selectors must parse to the end of input**: The selector grammar was not anchored, so lpeg matched the longest valid prefix and silently dropped the rest — `from(&quot;smtp&quot;):domain:lower` was accepted and evaluated as `from(&quot;smtp&quot;):domain`, and trailing garbage after a selector or a `;` list element was ignored across every consumer, including the WebUI&apos;s selector checker. Parsing now requires the whole input to be consumed and reports the position and the unparsed tail otherwise. Selectors that previously loaded thanks to the silent truncation now fail configuration load with a pointed error; they were never evaluating as written, so failing loudly is the correct behaviour ([#6127](https://github.com/rspamd/rspamd/pull/6127))</li><li>**Selector method calls on missing methods no longer crash the scan**: A selector like `time:digest` — method syntax used where a transform was meant — aborted the whole selector at scan time with an uncaught &quot;attempt to call a nil value&quot; error. The method lookup is now guarded: the selector yields no value and logs an error that hints at the `.name` transform syntax when the name matches a known transform; valid method calls such as `:lower` are unaffected</li><li>**secretbox out-of-bounds nonce read**: Both `lua_cryptobox` secretbox encrypt and decrypt built a zero-padded 24-byte nonce but then passed the original short nonce pointer to libsodium, reading up to 23 bytes past the buffer and producing ciphertexts that depended on adjacent memory contents. The padded nonce is now passed, so a short nonce produces the same ciphertext as its explicitly zero-padded form, and the two forms decrypt each other ([#6121](https://github.com/rspamd/rspamd/issues/6121))</li><li>**lua_kann load and save**: `rspamd_kann.load({filename = ...})` always failed with &quot;missing filename&quot; because the options table was read from the wrong argument index (a copy-paste from `save`, where argument 1 is `self`), and `save({filename = ...})` returned the filename string instead of `true` because a trailing pop removed the boolean result it had just pushed. Both forms now work, with a regression test covering the file round-trip</li><li>**Duplicate GPT request with synchronous context_augment**: When a `context_augment` function invoked its callback synchronously, the pending-fetches counter dropped to zero inside the callback and triggered the request; control then returned to the main flow, where a now-redundant guard fired a second request — this time without the context snippet. The duplicate path is removed ([#6142](https://github.com/rspamd/rspamd/pull/6142))</li><li>**ICAP HTTP header insertion**: Fixed incorrect insertion of an HTTP header when the ICAP module assembles the request headers in `get_http_headers` ([#6117](https://github.com/rspamd/rspamd/pull/6117))</li><li>**dmarc_report batch throttling**: Report sending no longer calls a blocking sleep from an async callback when throttling batches, which stalled the event loop instead of pacing the sends ([#6132](https://github.com/rspamd/rspamd/pull/6132))</li><li>**Lua 5.5 compatibility**: Lua 5.5 makes `for`-loop control variables read-only, turning assignments to them into compile-time errors. The most visible casualty was the bundled lupa Jinja template engine, which failed to load with &quot;attempt to assign to const variable &apos;word&apos;&quot; and made rspamd unable to start when configs use Jinja templating. All remaining offenders across the tree — found by parse-checking with luac 5.5 — now rebind the value to a local instead ([#6130](https://github.com/rspamd/rspamd/pull/6130))</li></ul><h3>Security</h3><ul><li>**message/rfc822 recursion depth (remote DoS)**: A chain of bare `Content-Type: message/rfc822` wrappers recursed through the MIME parser&apos;s message branch without ever tripping the `max_nested` (64) limit: unlike the multipart branch, the message branch never incremented the nesting counter actually carried into the recursion, so the entry guard never fired. Recursion was bounded only by message size — about 34 bytes per level — so a deeply nested message could exhaust the worker stack (a remote, unauthenticated denial of service via HTTP submission or SMTP/milter delivery), and the per-level boundary pre-scan made even non-crashing depths quadratic in CPU. The message branch now mirrors the multipart accounting, capping recursion at `max_nested`, which also caps the number of preprocess passes and eliminates the quadratic-CPU vector; a regression test feeds a 500-level chain and asserts the parsed part count stays capped. Reported by @gronke</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.1.1 - Patch Release with checkv3 Content Negotiation, Neural Extensibility, CSS Hidden-Text Detection, and 4.1.0 Regression Fixes</title>
      <link>https://docs.rspamd.com/changelog/4.1.1</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.1.1</guid>
      <pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.1.1 - Patch Release with checkv3 Content Negotiation, Neural Extensibility, CSS Hidden-Text Detection, and 4.1.0 Regression Fixes</h2><h3>Added</h3><ul><li>**/checkv3 content negotiation**: The `/checkv3` reply now honours the client&apos;s `Accept` and `Accept-Encoding` headers at the single `reply_v3` chokepoint shared by the scan worker, `rspamd_proxy`, and the controller. `application/json` / `application/msgpack` yield a plain v2-style single-body reply, `message/rfc822` a `multipart/mixed` envelope, and `multipart/form-data` (also the default for an absent or wildcard `Accept`) a true form-data envelope; an `Accept` listing only unsupported types returns `406 Not Acceptable`. Compression is negotiated separately — `zstd` when offered, identity otherwise — and `Vary: Accept, Accept-Encoding` is always advertised. `rspamc` now requests `multipart/form-data` and accepts any `multipart/` subtype ([#6083](https://github.com/rspamd/rspamd/pull/6083))</li><li>**CSS hidden-text detection**: The invisible-text detector learns several more CSS tricks used to dilute visible spam with hidden ham text — off-screen positioning (`position:absolute|fixed` with a large negative `left`/`top`), image-replacement `text-indent`, `clip` / `clip-path` collapsing the element to zero area (`rect(0,0,0,0)`, `inset(100%)`, `circle(0)`), `visibility:collapse`, and tiny font sizes (≤ 3px). These are now modelled as a hidden display so the effect correctly propagates to descendants, replacing a fragile substring heuristic that only bumped a feature counter and never actually hid the text</li><li>**Zip-bomb extraction limits**: `lua_archive` (`unpack` / `unzip` / `untar`) gains an optional `opts` table that caps extraction while reading, so a tiny hostile archive can no longer expand to gigabytes and OOM the worker: `max_output` (total uncompressed bytes), `max_file_size` (per-member cap, truncated at the cap), `max_files` (member count), and `max_ratio` (per-member uncompressed/compressed ratio). The ratio uses real compressed bytes consumed, checked per chunk above a 64 KiB floor, so a bomb is stopped early even with no size cap set. All limits are opt-in (0/absent = unlimited), preserving the previous behaviour, and a second boolean return flags truncation so a capped extraction is never mistaken for a complete one</li><li>**Byte-distribution statistics for rspamd_text**: New methods on the `rspamd_text` class expose ENT-style byte statistics for content analysis — `entropy()`, `byte_mean()`, `byte_deviation(mean)`, `serial_correlation()`, and `monte_carlo_pi()`. Each takes an optional 1-based `(start, len)` range (consistent with the rest of the slicing API), runs O(n) over a single shared histogram pass, and produces deterministic, bit-reproducible results ([#6106](https://github.com/rspamd/rspamd/pull/6106))</li><li>**zlib/YARA-compatible CRC-32**: Standard CRC-32 (poly `0xEDB88320`) is now available as `rspamd_text:crc32([start[, len]])`, `rspamd_util.crc32(input[, start, len])`, and a streaming `rspamd_cryptobox_hash.create_specific(&quot;crc32&quot;)`, bit-exact with `zlib crc32()` and YARA&apos;s `hash.crc32` (e.g. `&quot;123456789&quot;` → `0xCBF43926`). The text/util forms return a Lua integer over a zero-copy 1-based slice with no buffer copy</li><li>**Explicit multipattern SOM flag**: `RSPAMD_MULTIPATTERN_SOM` (`rspamd_trie.flags.som`) explicitly requests start-of-match offsets, overriding the incompatible single-match flag. The offset convention is now documented — 1-based pattern id, 0-based byte offsets, start inclusive and end exclusive (so `end - start` is the match length) — and the regex fallback used when hyperscan is unavailable now reports the true start/end from the PCRE search instead of a bogus `end - strlen(pattern)` for variable-length matches ([#6105](https://github.com/rspamd/rspamd/pull/6105))</li><li>**Pluggable neural registries**: The neural plugin becomes an extension point so third-party (including closed-source) modules can add feature providers and network topologies without patching the core. `register_architecture(name, builder)` / `get_architecture(name)` join the existing `register_provider`; the built-in `symbol`, `embedding`, and `conv1d` architectures are now registered through it, `create_ann()` dispatches on `rule.architecture` and falls back to the historical auto-selection when it is unset (so existing configs are unaffected), and an unknown architecture now fails loudly with a hint that the providing module may not be loaded</li><li>**fasttext_embed sequence output**: The `fasttext_embed` provider gains `output_mode = &quot;sequence&quot;`, emitting the first `max_words` word vectors flattened word-major and zero-padded to `max_words * channels`, so custom architectures can learn their own pooling instead of receiving a pre-pooled vector. An optional `word_selection = &quot;sif&quot;` fills the sequence with the most distinctive (highest SIF weight) words from anywhere in the message rather than the leading ones; the default stays `prefix`</li><li>**Multi-head attention pooling**: A new `attn_pool` KANN operator performs multi-head dot-product attention pooling over a zero-padded sequence of word vectors with learned query vectors, masking all-zero padding positions out of the softmax. Exposed as `rspamd_kann.layer.attn_pool(node, n_words[, n_heads])`, it converges on a word-order-invariant needle-in-haystack task that a flat dense net cannot solve, and the operator is appended to preserve model serialization compatibility</li><li>**KANN slice and concat transforms**: `rspamd_kann.transform.slice(node, axis, start, end)` (0-based, end exclusive) and `rspamd_kann.transform.concat(axis, node1, node2, ...)` expose the already-serialized `kad_slice` / `kad_concat_array` graph operators to Lua, making split/bypass/merge topologies — e.g. routing parts of a fused input vector through different sub-networks for attention pooling with late fusion — buildable from Lua</li></ul><h3>Fixed</h3><ul><li>**DNS resolver startup crash (4.1.0 regression)**: A nameserver that failed to resolve at config time was turned into a zero-address pending-resolve upstream and then dereferenced as NULL in `rspamd_dns_server_init`, crashing the worker with SIGSEGV at startup. Resolver nameservers are consumed synchronously and can never be promoted asynchronously, so they are no longer deferred, with a NULL guard added as defense in depth ([#6096](https://github.com/rspamd/rspamd/issues/6096))</li><li>**Milter QUARANTINE with custom reply (3.10.0 regression)**: The QUARANTINE command was emitted only inside the branch that synthesises a default quarantine reason, so a caller-supplied SMTP message (e.g. `task:set_pre_result(&apos;quarantine&apos;, &apos;reason&apos;)`) suppressed the command entirely and the message was accepted instead of quarantined. The supplied message now becomes the quarantine reason, matching the reject and tempfail branches; this affects both `METRIC_ACTION_QUARANTINE` and reject converted via `quarantine_on_reject` ([#6088](https://github.com/rspamd/rspamd/issues/6088))</li><li>**Coroutine thread-pool safety**: Async Lua libraries (DNS, Redis, TCP, HTTP, util) capture the running coroutine at a yield point and resume it later from a C completion callback, with nothing previously guaranteeing the resumed entry was still the captured one. Each pooled thread now carries an explicit lifecycle and generation counter, and the entries are refcounted, so a double-fired event, a completion racing task teardown, or an entry recycled into another task either resumes the correct live coroutine or becomes a logged no-op — instead of dereferencing freed memory ([#6104](https://github.com/rspamd/rspamd/pull/6104))</li><li>**lua_worker subprocess deadlock**: A function run via `worker:spawn_process` that returned `nil` (or any non-string value) logged an error but wrote nothing to the result pipe, so the parent waited forever for a reply while the child blocked on the post-reply ack — deadlocking both processes and anything serialised behind them, notably the neural training lock. Invalid return values are now reported to the parent as a regular error reply, so `on_complete` fires and the caller can recover</li><li>**Neural training on dense embeddings**: Training on dense provider features (`fasttext_embed`, `text_hash`) with the historical `learning_rate = 0.01` could drive the net into tanh saturation and silently save a degenerate single-class model that classified every message the same way until the next retrain — roughly one weight-init in three on a real corpus. Dense-feature rules now use the funnel (embedding) architecture, whose layernorm fixes the conditioning; the learning-rate default is resolved by input type (0.001 for dense embeddings, 0.01 for symbol vectors, an explicit config value still wins); and a quality gate rejects a constant/single-class model so training retries on the next cycle with a different init</li><li>**Stranded trained ANNs**: A trained ANN could become unreachable to workers — `NEURAL_SPAM` / `NEURAL_HAM` stopped firing while the controller logged a version mismatch forever — because a stale, empty, high-version profile entry shadowed the live lower-version blob and, with no TTL, never expired. New versions are now seeded monotonically from the profile actually trained from, selection falls back to the next compatible profile with a live blob (with a throttled warning) when the chosen one is missing, and profile/blob entries get a TTL refreshed each cycle so an actively used ANN never expires out from under its entry ([#6089](https://github.com/rspamd/rspamd/pull/6089))</li><li>**mx_check loopback classification**: A domain whose MX resolves only to loopback is hosted on the scanning host itself (typically the host&apos;s own FQDN mapped to `127.0.0.1` in `/etc/hosts`), but it scored `MX_BOGON_ONLY` (+8.0) — the strongest &quot;not spam infrastructure&quot; signal treated as the strongest spam signal, penalising fully DMARC-aligned self-hosted mail. `127.0.0.0/8` and `::1/128` move from the bogon set to the local set, so such mail now emits `MX_LOCAL_ONLY` (3.0) ([#6101](https://github.com/rspamd/rspamd/issues/6101))</li><li>**Monitored random DNS prefixes**: `random_monitored` RBL checks built random labels from an alphabet including `-` and `_`, producing names like `_Q8...0-` or `-7d0...` that are not valid DNS labels (RFC 952/1123) and that authoritative DNSBL servers such as spfbl.net reject with SERVFAIL. The alphabet is restricted to alphanumerics, which always forms a valid label regardless of position while keeping ample entropy ([#6103](https://github.com/rspamd/rspamd/issues/6103))</li><li>**v3 multipart boundary case**: RFC 2046 boundaries are case-sensitive, but the v3 HTTP multipart callers used the boundary lowercased for MIME-client quirks, so requests with uppercase boundary characters failed to parse. The case-preserving `orig_boundary` is now used in `protocol.c`, `rspamd_proxy.c`, and `rspamdclient.c`</li><li>**/checkv3 on the controller**: The controller&apos;s HTTP path router never registered `/checkv3`, so a plain `rspamc --protocol-v3` — which defaults to the controller port 11334 on localhost — returned 404. The endpoint is now registered and routed to the existing scan handler, branching on `CMD_CHECK_V3` in both directions to mirror the proxy, with an auth posture matching `/check` and `/checkv2` ([#6083](https://github.com/rspamd/rspamd/pull/6083))</li><li>**Symcache timeout inflation**: A `saved_priority` initialised to `-1` split the first item of each pre/postfilter/idempotent phase off from its priority group and counted it individually, inflating the computed maximum symbols-cache timeout. It is now seeded from the first item&apos;s priority so items at the same priority group correctly ([#6094](https://github.com/rspamd/rspamd/pull/6094), [#6092](https://github.com/rspamd/rspamd/issues/6092))</li><li>**Query-embedded URLs with &apos;=&apos;**: A bare embedded URL is its own query parameter and can itself contain `=` (e.g. base64 padding in the path), but the query-embedded scan treated everything before the first `=` as a parameter key and discarded such URLs. The prefix is now treated as a key only when it has no URL structure characters (`:` or `/`); otherwise the whole parameter is scanned</li><li>**URLs with multiple &apos;@&apos; and backslashes**: URLs containing several `@` signs and backslashes were multiplied into many spurious URLs. Long legitimate conversations can accumulate such links, so the spurious expansion could drop mail and, with autotrain enabled, train the system badly; those URLs are no longer multiplied ([#6065](https://github.com/rspamd/rspamd/pull/6065))</li><li>**More CSS hiding via overflow/opacity/max-***: Phishing that dilutes visible content with hidden ham text using `max-width:0;max-height:0;overflow:hidden`, `opacity:0`, or `height:0` was missed — `max-*` and `overflow` were unparsed, `opacity` was parsed but discarded, and a copy-paste bug applied `height:0` to the block width. These properties are now parsed, a zero-dimension-with-`overflow:hidden`, low-opacity, or hidden-ancestor block is treated as invisible and propagated to descendants, and a child display value can no longer resurrect content hidden by an ancestor</li><li>**OpenSSL 4.0 build**: OpenSSL 4.0 made `ASN1_STRING` (and thus `ASN1_OCTET_STRING`) opaque, so direct access to its length/data fields no longer compiled; `ASN1_STRING_length()` / `ASN1_STRING_get0_data()` (available since OpenSSL 1.1.0 and LibreSSL 2.7) are used instead, and the legacy OpenSSL init calls are moved under the pre-1.1.0 guard to fix no-deprecated builds ([#6087](https://github.com/rspamd/rspamd/issues/6087))</li><li>**doctest 2.5.0 build**: `mime_string`&apos;s iterator is intrinsically const, so it is marked as such and the `const_iterator` reference is dropped, fixing a compile error where doctest 2.5.0 receives a const reference and cannot use a mutable iterator ([#6072](https://github.com/rspamd/rspamd/pull/6072))</li><li>**Bundled simdutf headers**: The bundled simdutf headers are now prioritised over system ones to avoid version skew ([#6082](https://github.com/rspamd/rspamd/pull/6082))</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.1.0 - Major Release with Load-Aware Upstreams, MX Check Rework, Dynamic Composites, and Hardening</title>
      <link>https://docs.rspamd.com/changelog/4.1.0</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.1.0</guid>
      <pubDate>Fri, 05 Jun 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.1.0 - Major Release with Load-Aware Upstreams, MX Check Rework, Dynamic Composites, and Hardening</h2><h3>Changed</h3><ul><li>**MX check rework**: `mx_check` replaces its single domain-keyed cache with a three-layer Redis design (`d:&lt;domain&gt;` / `m:&lt;mxhost&gt;` / `i:&lt;ip&gt;` under `&lt;key_prefix&gt;:`), so two domains sharing an MX host (every G-Suite/M365 tenant, every ESP customer) reuse the m- and i-layer entries and hit cache with zero new DNS or TCP work. Outcome symbols are finer-grained and `MX_NONE` now replaces the old `MX_NXDOMAIN`/`MX_MISSING`; the probe is split into clean connect-only and full SMTP-banner shapes with multi-line greeting support. **Operators should review their `mx_check` scores and any rules referencing the old symbol names** ([#6055](https://github.com/rspamd/rspamd/pull/6055), [#6032](https://github.com/rspamd/rspamd/issues/6032))</li><li>**Fuzzy SRV discovery by default**: The stock `rspamd.com` fuzzy rule now uses `service=fuzzy+rspamd.com` SRV-based discovery instead of a hardcoded round-robin host list, so backends and ports are managed entirely in DNS. The legacy `fuzzy1`/`fuzzy2` hostnames keep resolving to every live backend, so installs that pinned the old string are unaffected</li></ul><h3>Added</h3><ul><li>**Custom metadata for /checkv3**: A `headers` sub-object in the multipart metadata part is injected into the task request headers (so `task:get_request_header()` works for custom fields), and the full metadata object is exposed to Lua via `task:get_metadata()` / `task:get_metadata_field(key)`. Both paths travel in the multipart body, free of the 80KB HTTP header limit that v2 hits; `rspamc` gains a repeatable `--metadata-header KEY=VALUE` option ([#6074](https://github.com/rspamd/rspamd/pull/6074))</li><li>**Dynamic composites**: A reserved `dynamic` key in the `composites { ... }` block attaches a hot-reloadable map of composites (file, URL list, or signed map) using the same vocabulary as static composites. Reloads register new names with the symcache, materialise removed names as stubs, and swap an atomic generation so in-flight tasks keep their snapshot ([#6064](https://github.com/rspamd/rspamd/pull/6064))</li><li>**Bulk and regexp symbol lookups**: `task:has_symbol()` / `task:get_symbol()` accept a `{S1, ..., Sn}` table form, and new `task:has_symbol_regexp(re)` / `task:get_symbol_regexp(re)` match fired symbol names against an `rspamd_regexp` userdata</li><li>**Phase-specific TCP timeouts**: `rspamd_tcp.new` gains `connect_timeout` / `read_timeout` / `write_timeout` for independent per-phase budgets and an `on_error(err, conn)` callback that fires once for pre-connect failures (DNS, socket, connect refused/timeout, SSL handshake). Legacy single-`timeout` callers keep their existing deducted-budget contract unchanged ([#6034](https://github.com/rspamd/rspamd/pull/6034))</li><li>**Stalled-scan diagnostics**: On task timeout the log now includes a grouped summary of still-pending async events (DNS / Redis / Lua HTTP / fuzzy / TCP) annotated with the stalling rule and operation, plus the list of symbols that started but never finished — replacing the opaque &quot;forced processing&quot; line with an immediate picture of what hung the scan ([#5990](https://github.com/rspamd/rspamd/pull/5990))</li><li>**Load-aware upstream selection**: Upstreams now support Power of Two Choices selection, a per-upstream latency EWMA with configurable half-life, and linear slow start on revive to avoid thundering-herd flap loops. `RSPAMD_UPSTREAM_RANDOM` callers are transparently upgraded to P2C ([#6013](https://github.com/rspamd/rspamd/pull/6013))</li><li>**Per-target SRV upstreams**: Each SRV reply target now materialises its own `struct upstream` with its own error budget, weight, latency EWMA, and address list, so SRV weights are honoured and a single failing target no longer drains the whole cluster&apos;s budget ([#6030](https://github.com/rspamd/rspamd/pull/6030))</li><li>**Deferred DNS resolution**: A transient DNS failure at config time no longer drops an upstream and cascades into module init failures. Unresolvable hostnames are kept pending and retried by the async lazy-resolve machinery (with exponential backoff), so the daemon starts and recovers automatically without a restart ([#6008](https://github.com/rspamd/rspamd/pull/6008), [#6000](https://github.com/rspamd/rspamd/issues/6000))</li><li>**MX check classification and trust maps**: `mx_check` partitions resolved MX-target IPs into PUBLIC / LOCAL / BOGON (only PUBLIC is probed), adds `bad_mxs` (glob on hostnames) and `bad_ips` (radix on IPs) punishment maps with optional per-entry weight multipliers, per-source checks across envelope / reply-to / MIME-from, and `check_authorized` / `check_local` run-scope toggles ([#6039](https://github.com/rspamd/rspamd/pull/6039), [#6032](https://github.com/rspamd/rspamd/issues/6032))</li><li>**External-service anchor symbols**: Every external service is scheduled under a stable `&lt;RULE&gt;_CHECK` callback symbol so it is a predictable dependency target regardless of how the scan-result symbols are named, generalising the existing `VADE_CHECK` / `CLOUDMARK_CHECK` pattern</li><li>**Chain-aware URL redirector cache**: `url_redirector` gains a per-hop Redis cache with intermediate-hop injection, so shared intermediate links are reused across chains and resolved hops are made visible to downstream modules; the walk self-heals on partial cache misses and surfaces previously-silent lock/stale conditions ([#6014](https://github.com/rspamd/rspamd/pull/6014))</li><li>**Stealth fingerprint profiles**: `url_redirector` replaces its flat `default_ua` list with five coherent browser profiles (Chrome, Edge, Firefox, Safari), each bundling a User-Agent with the exact header set, values, and order that browser sends (including `sec-ch-ua` client hints where appropriate). One profile is picked per task and reused across every hop ([#6053](https://github.com/rspamd/rspamd/pull/6053))</li><li>**Glob redirector hosts**: `redirector_hosts_map` now accepts glob patterns (e.g. `*.bit.ly`, `*.t.co`); bare hostnames still match exactly, so existing maps are unaffected ([#6056](https://github.com/rspamd/rspamd/pull/6056))</li><li>**GET for selected redirector URLs**: `url_redirector` can issue GET (rather than HEAD) for an operator-defined list of URLs matched by regexp ([#6043](https://github.com/rspamd/rspamd/pull/6043))</li><li>**Insertion-ordered HTTP headers**: An opt-in `RSPAMD_HTTP_FLAG_ORDERED_HEADERS` emits headers in insertion order instead of hash order; `lua_http` accepts a list form (`{{&apos;name&apos;,&apos;value&apos;}, ...}`) to preserve order, used by the redirector stealth profiles</li><li>**Richer Elasticsearch logging**: The `elastic` module now logs Reply-To user/domain, received IPs, per-URL and CTA URL metadata via a new `collect_urls` block, and the forcing module name from `task:has_pre_result()` ([#6018](https://github.com/rspamd/rspamd/pull/6018))</li><li>**ClickHouse column presets**: Named `extra_columns` presets, including an initial outbound preset, can be selected without hand-rolling the schema ([#5983](https://github.com/rspamd/rspamd/pull/5983))</li><li>**New selectors**: `fuzzy_digest`, `fuzzy_shingles`, `authenticated`, and `received_count` ([#5981](https://github.com/rspamd/rspamd/pull/5981))</li><li>**HTML5 tag definitions**: 32 modern HTML5 tags (sectioning, media, text-level, interactive, forms, web components), notably `video`/`audio`/`source`/`track`/`picture`/`svg`, so their URLs and structure are visible to the parser; new tag IDs are appended so existing IDs stay stable</li><li>**DMARC report throttling**: `rspamadm dmarc_report` gains `-w`/`--batch-wait` to pause between batches (and stagger report generation), avoiding overload of the SMTP server and a weak resolver ([#5985](https://github.com/rspamd/rspamd/pull/5985))</li><li>**autolearnstats sorting**: `--sort-by &lt;col&gt;` and `--group` options for the `rspamadm autolearnstats` table output ([#6050](https://github.com/rspamd/rspamd/pull/6050))</li><li>**Feedback report parsers**: New `lua_feedback_parsers` lualib for parsing DSN (delivery status) and ARF (abuse) reports ([#5982](https://github.com/rspamd/rspamd/pull/5982))</li><li>**Structured custom-Lua loader**: `lua_extras` provides a two-phase loader for custom selectors, maps, and regexps under `lua.local.d/{maps,selectors,regexps}/`, resolving cross-kind dependencies (maps → selectors → regexps) so a selector can consume a map registered by an earlier kind ([#6020](https://github.com/rspamd/rspamd/pull/6020))</li><li>**eXpurgate scanner**: New `lua_scanners` engine for the eXpurgate anti-spam service ([#5755](https://github.com/rspamd/rspamd/pull/5755))</li><li>**Per-worker memory dumps**: New `rspamadm control memstat` command reports per-worker RSS, per-callsite mempool counters, Lua heap usage, and structured per-arena jemalloc stats, with `--short`, `--sort`, and per-section toggle flags ([#6016](https://github.com/rspamd/rspamd/pull/6016))</li><li>**Container-friendly baseline config**: The baseline pidfile and logging type/filename are now env-overridable (`RSPAMD_PIDFILE`, `RSPAMD_LOG_TYPE`, `RSPAMD_LOG_FILE`); an empty pidfile disables it (useful as PID 1), and `RSPAMD_LOG_TYPE=console` logs to stdout. Stock installs render the previous defaults bit-for-bit ([#6067](https://github.com/rspamd/rspamd/pull/6067))</li><li>**Auto-loaded fasttext model**: When no `fasttext_model` is configured, the shipped `$SHAREDIR/languages/fasttext_model.ftz` is loaded if present, so images bundling the model can drop the explicit override; stock installs without the file behave exactly as before ([#6067](https://github.com/rspamd/rspamd/pull/6067))</li><li>**Fixed-point float formatting**: `fpconv` gains `%.Nf` fixed-point formatting with correct rounding and carry handling ([#6061](https://github.com/rspamd/rspamd/pull/6061))</li></ul><h3>Fixed</h3><ul><li>**Composite visibility from postfilters**: Composites depending only on ordinary filter-stage rules were wrongly deferred to the post-composites stage because nearly every virtual/callback symbol carries `NOSTAT`; a composite is now deferred only when a dependency actually resolves to the postfilter stage, restoring `task:get_groups()`/`get_symbols()` visibility from postfilters</li><li>**Ratelimit multi-bucket tracking**: A selector rule with several buckets (e.g. `200/1h` plus `30/1m`) keyed every bucket on the selector value alone, so only the last bucket was tracked and the other limits were silently ignored; each bucket now gets a distinct Redis key ([#6076](https://github.com/rspamd/rspamd/pull/6076), [#6059](https://github.com/rspamd/rspamd/issues/6059))</li><li>**Verbatim href preserved**: `url:get_raw()` returned the partially percent-decoded scratch buffer for HTML URLs; `url-&gt;raw` now points at a mempool-owned copy of the verbatim trimmed href ([#5986](https://github.com/rspamd/rspamd/issues/5986))</li><li>**Mailto canonicalisation**: A bare email in text/HTML and the same address inside an explicit `mailto:` URL were extracted as two separate emails; both injection sites now canonicalise to the slash-less `mailto:` form (RFC 6068) so dedup collapses them</li><li>**Long userinfo URLs preserved**: A blanket length REJECT silently dropped URLs whose userinfo exceeded 2KB — exactly the `https://legit.com   @evil.com/...` userinfo-obfuscation phishing pattern; the cap is raised to 16KiB and the URL is flagged obscured as soon as userinfo crosses 64 bytes</li><li>**Naked-domain false positives**: `url_suspect` now requires a TLD of at least 3 chars for `word_dot` naked-domain matches, so prose like &quot;pale blue dot so insignificant&quot; no longer normalises to `blue.so`; explicit-protocol patterns still match two-char TLDs</li><li>**Deterministic ARC header order**: `lua_mime.modify_headers` honoured its `order` list but serialised in hash order; ARC sets are now emitted in the conventional ARC-Seal / ARC-Message-Signature / ARC-Authentication-Results layout, which some validators (e.g. O365) require ([#6052](https://github.com/rspamd/rspamd/pull/6052), [#6045](https://github.com/rspamd/rspamd/issues/6045))</li><li>**DKIM permfail mapping**: An invalid DKIM record now maps to `dkim=permerror` in `Authentication-Results` instead of falling through to `dkim=none` ([#6028](https://github.com/rspamd/rspamd/pull/6028), [#5957](https://github.com/rspamd/rspamd/issues/5957))</li><li>**EAI Message-ID handling**: The `INVALID_MSGID` rule now honours `mime_utf8`, and the `enable_mime_utf8` option spelling is registered as an alias so it actually takes effect, fixing false positives on valid SMTPUTF8 Message-IDs (RFC 6532) ([#6011](https://github.com/rspamd/rspamd/pull/6011), [#6007](https://github.com/rspamd/rspamd/issues/6007))</li><li>**Fuzzy storage peer pipe**: Partial peer-pipe writes were retried from byte 0, shoving garbage into the pipe; writes now resume at the sent offset, and pending requests are drained on worker shutdown instead of leaking</li><li>**Fuzzy dynamic-ban leak**: Re-applying an already-present ban prefix orphaned a freshly allocated ban struct in the long-lived mempool on every refresh; the prefix is now looked up first and mutated in place on a hit</li><li>**Fuzzy persistent-TCP leak**: Per-frame `cmd_session` state (extensions buffer, key refs) leaked on every frame after the first on a persistent TCP connection; per-command state caching is dropped so each frame starts clean ([#6001](https://github.com/rspamd/rspamd/issues/6001))</li><li>**Allowed fuzzy clients on TCP**: Allowed clients are no longer blocked on the TCP path ([#5992](https://github.com/rspamd/rspamd/pull/5992))</li><li>**Neural ANN survival**: A trained ANN is now preserved across symbol-list drift and symcache-driven profile rotation; the profile digest is stabilised under `disable_symbols_input` (keyed on the providers config rather than the unrelated symbol catalogue), and training is retargeted to the newest profile so inference no longer goes dark for weeks until a fresh model trains ([#6041](https://github.com/rspamd/rspamd/pull/6041))</li><li>**Inline /checkv3 settings**: Inline `metadata.settings` on `/checkv3` were stashed directly on the task and skipped the apply pipeline; they now run through the same `settings.lua` apply path as v2, so action thresholds, symbols, subject, variables, and header edits take effect ([#5999](https://github.com/rspamd/rspamd/issues/5999))</li><li>**/checkv3 request headers**: Arbitrary client HTTP headers are now registered so `task:get_request_header()` works under v3, restoring v2 behaviour ([#5998](https://github.com/rspamd/rspamd/issues/5998))</li><li>**Token-bucket recovery**: A flapping upstream&apos;s token bucket drained monotonically toward zero and never recovered; lazy time-based refill restores tokens over wall time so a recovered upstream re-enters selection</li><li>**get_random termination**: `rspamd_upstream_get_random` looped forever when the only alive candidate matched the `except` argument; the empty and single-survivor cases are now front-gated</li><li>**Lua TCP leak**: A connection that read without writing leaked; the leak is closed ([#6048](https://github.com/rspamd/rspamd/pull/6048))</li><li>**Redis master under Sentinel**: Standalone `rspamadm` tools never resolved the current Redis master, round-robining writes that failed `READONLY` on replicas; a new one-shot `lua_redis.prepare_redis_setup` resolves the master (and loads scripts) for tools like `dmarc_report` ([#6015](https://github.com/rspamd/rspamd/pull/6015), [#6009](https://github.com/rspamd/rspamd/issues/6009))</li><li>**Elastic overflow guard**: The row-limit overflow guard called a non-existent `lua_util.newdeque()`; it now resets the buffer via the local `Queue` class</li><li>**DMARC report on PUC Lua**: The connect timestamp is floored before `os.date`, which PUC-Rio Lua rejects for non-integer floats (only seen on the Fedora build)</li><li>**Greylist timeout separation**: The greylisting period (5 min) was being picked up by `lua_redis` as the Redis connection timeout; a separate `redis_timeout` (default 1.0s) is introduced and propagated into nested `redis{}` blocks ([#5977](https://github.com/rspamd/rspamd/pull/5977))</li><li>**rspamadm vault output**: `rspamadm vault list` produced empty output for large vaults because the payload passed through a format-string logger; it is now written to stdout directly via `io.write` ([#6006](https://github.com/rspamd/rspamd/pull/6006), [#6005](https://github.com/rspamd/rspamd/issues/6005))</li><li>**Regexp capture groups**: An empty capture group in the middle of a match no longer discards all following groups (PCRE1 and PCRE2); results are truncated at the last non-empty group instead ([#5974](https://github.com/rspamd/rspamd/pull/5974), [#5973](https://github.com/rspamd/rspamd/issues/5973))</li><li>**x-binaryenc charset**: ICU conversion is skipped for the synthetic `x-binaryenc` charset across all detection paths, silencing the spurious &quot;cannot open converter&quot; warning and correctly marking the part as raw binary ([#5984](https://github.com/rspamd/rspamd/pull/5984))</li><li>**Timeout sanity check**: `configtest` now warns when `task_timeout` is less than a symcache symbol timeout, including per-worker overrides ([#5978](https://github.com/rspamd/rspamd/pull/5978))</li><li>**libc++ builds**: Use `string_view::data()` for pointer access where libc++ returns a wrapped iterator from `begin()`, fixing builds with libc++ 22 on FreeBSD ([#5969](https://github.com/rspamd/rspamd/issues/5969))</li><li>**In-place header rewrites**: `mime_headers`/`mime_encoding` now recompute lengths after in-place strip/trim rewrites, so stale trailing bytes are no longer pulled into the Message-ID or normalised charset name, and large-buffer offsets use `goffset` to avoid 32-bit truncation</li><li>**rfc2047 decode**: A failed base64 encoded-word no longer leaves uninitialised bytes in the decoded header value; the token length is reset to the saved offset on the failure path</li></ul><h3>Security</h3><ul><li>**S/MIME NULL deref**: An S/MIME signed message wrapping a zero-length `pkcs7-data` OCTET STRING crashed the parser on the first byte check; the empty inner recursion is skipped and a defensive guard is added against NULL/empty buffers</li><li>**S/MIME recursion bound (DoS)**: Deeply nested `application/pkcs7-mime` layers re-entered the parser without incrementing the nesting counter, recursing to a depth bounded only by message size and exhausting the worker stack; the S/MIME re-entry is now accounted against `max_nested` and the CMS/PKCS7/BIO objects are freed on the error path</li><li>**MIME parser guards**: Additional defensive guards against NULL dereference (Content-Type iteration, malformed PKCS7 signed-data) and a leak of the recursive parser context on the early error-return path, plus a corrected `begin-base64` UUE prefix offset</li><li>**Nested-query URL DoS**: A crafted message with a few levels of percent-escaped nested query URLs exhausted the multipattern hyperscan scratch pool and aborted the worker via assertion; the scratch budget is enlarged, the query-nesting cap is restored to a fixed functional limit, and scratch exhaustion is made non-fatal ([#6066](https://github.com/rspamd/rspamd/pull/6066))</li><li>**Archive parser hardening**: RAR (v4/v5), ZIP, and 7-zip parsers are hardened against malformed attachments — re-validated filename lengths, guarded section/digest/bit reads, clamped extra-field advances, a fixed EOCD minimum, widened offset arithmetic against 32-bit wrap, and corrected 7-zip varint decoding (uninitialised value and an undefined-shift fix)</li><li>**Image linking guard**: Content-Id image linking is guarded against a NULL decoded header</li><li>**CSS out-of-bounds read**: The CSS ident-escape scanner could read one byte past a tightly-sized style-attribute buffer when a token ended in backslash plus a hex digit; the increment is now bounds-gated</li><li>**Header lookahead over-read**: `rspamd_string_find_eoh` peeked `p[1]` guarded only by `p &lt; end`, reading one byte past the buffer on input ending in `\r\r`; it now checks `p + 1 &lt; end`</li><li>**SPF over-read**: A TXT record of exactly `spf2.` advanced past one unvalidated byte and read past the string end; the parser now advances only past the validated prefix with short-circuiting checks</li><li>**DNS label overrun**: `rdns_parse_labels` never verified that label data fits within the packet, so a reply declaring more bytes than remained made the second-pass `memcpy` read past the (exactly-sized, on TCP) buffer; both plain and compressed labels are now validated, with an off-by-one fix in offset decompression</li><li>**HTML entity overflow**: In-place HTML entity decoding assumed the replacement is never longer than the source, which fails for short names expanding to multiple codepoints (`nGt`, `nLt`, `nvap`); the replacement is now bounds-checked against the remaining buffer</li><li>**Empty-host URL over-read**: `rspamd_url_maybe_regenerate_from_ip` could read `host[-1]` on an all-dots or zero-length-after-decode host; the trailing-dot loop condition is reordered and host length is re-checked after decoding</li><li>**Fuzzy network input hardening**: Three defensive fixes on user-controlled UDP/TCP paths — reset `msg_namelen` before every `recvmsg` to avoid parsing stale stack bytes, validate the reconstructed 14-bit TCP frame length before use, and clamp `n_extra_flags` before the fixed-size reply `memcpy`</li><li>**libucl upstream fixes**: Ported security fixes from libucl — msgpack negative fixint and unaligned-access fixes, a key-length validation, a nesting-depth limit, parser bounds checks, and a NULL guard in schema validators</li></ul>]]></description>
      <category>major</category>
    </item>
    <item>
      <title>Rspamd 4.0.1 - Patch Release with Layered Settings Merge, Fuzzy Dynamic Blocks, and Critical Bug Fixes</title>
      <link>https://docs.rspamd.com/changelog/4.0.1</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.0.1</guid>
      <pubDate>Sun, 05 Apr 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.0.1 - Patch Release with Layered Settings Merge, Fuzzy Dynamic Blocks, and Critical Bug Fixes</h2><h3>Added</h3><ul><li>**Layered settings merge**: Collect-then-merge flow replaces first-match-wins; layers are merged with per-field semantics (actions/scores override by higher layer, symbols union with conflict resolution); `force_actions` and `force_symbols` provide explicit override when layers conflict ([#5959](https://github.com/rspamd/rspamd/pull/5959))</li><li>**Weak dependencies in symcache**: New `disabled` status distinguishes &quot;disabled by settings&quot; from &quot;finished&quot;; hard deps cascade-disable dependents while weak deps let them proceed — seven plugins annotated with correct dependency classification ([#5959](https://github.com/rspamd/rspamd/pull/5959))</li><li>**Fuzzy dynamic block API**: `worker:block_fuzzy_client(addr, prefix_len[, expire_ts[, reason]])` Lua method for runtime IP/CIDR blocking in fuzzy storage without config changes or restarts; custom response codes — 403 for ratelimits vs 503 for bans; `ratelimit_whitelist` check exposed to Lua ([#5962](https://github.com/rspamd/rspamd/pull/5962))</li></ul><h3>Fixed</h3><ul><li>**Proxy milter fd leak**: Original fd was not closed after milter `dup()`, leaking one fd per milter connection and causing unbounded `CLOSE_WAIT` accumulation in production (regression in 4.0.0)</li><li>**Proxy self-scan task timeout**: Self-scan tasks incorrectly used the upstream wire timeout (e.g. 120s) instead of `task_timeout` (8s default), masking hung tasks</li><li>**ARC AAR header parsing**: Replace naive semicolon split with LPeg grammar that respects RFC 8601 comments `(...)` and quoted strings — fixes `cv=fail` on multi-hop ARC chains where `i=N` appeared inside comments ([#5963](https://github.com/rspamd/rspamd/issues/5963), [#5966](https://github.com/rspamd/rspamd/pull/5966))</li><li>**Dynamic ban expiry**: Fix `ev_now()` usage for correct monotonic time in dynamic block expiry</li><li>**Settings flow**: Fix dependency ordering in pre/postfilters and settings apply phase</li><li>**Redis backend build**: Add missing `#include &lt;memory&gt;` in `redis_backend.cxx` ([#5960](https://github.com/rspamd/rspamd/pull/5960))</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 4.0.0 - Major Release with checkv3 Protocol, Built-in Fasttext, Multi-Flag Fuzzy Hashes, and Ring Hash Consistent Hashing</title>
      <link>https://docs.rspamd.com/changelog/4.0.0</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/4.0.0</guid>
      <pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 4.0.0 - Major Release with checkv3 Protocol, Built-in Fasttext, Multi-Flag Fuzzy Hashes, and Ring Hash Consistent Hashing</h2><h3>Breaking Changes</h3><ul><li>**Bayes per-user resharding**: Jump Hash replaced with Ring Hash (Ketama) for consistent upstream hashing; per-user Bayes data on sharded Redis deployments will be on wrong shards after upgrade. Run `rspamadm statistics_dump migrate` before upgrading. Single-server deployments are unaffected. ([#5914](https://github.com/rspamd/rspamd/pull/5914), [4ea7504](https://github.com/rspamd/rspamd/commit/4ea750466))</li><li>**Content URLs included by default**: `include_content_urls` now defaults to `true`; URLs extracted from PDF and computed parts are returned by `task:get_urls()` by default, which may trigger new symbol hits on messages with PDF attachments. Restore old behavior with `include_content_urls = false` in `local.d/options.inc`. ([#5853](https://github.com/rspamd/rspamd/pull/5853))</li><li>**SSL worker option removed**: The `ssl = true` worker option has been removed; SSL is now auto-detected from bind socket flags. Remove `ssl = true` from worker configs and use the `ssl` suffix on bind lines instead. ([#5884](https://github.com/rspamd/rspamd/pull/5884))</li><li>**Proxy load balancing default changed**: Token bucket load balancing is now enabled by default for proxy upstreams, replacing simple round-robin. Remove the `token_bucket` key from proxy upstream config to restore round-robin behavior. ([#5874](https://github.com/rspamd/rspamd/pull/5874))</li><li>**SenderScore RBLs disabled by default**: `senderscore_reputation` is disabled by default as it requires a MyValidity account and was returning blocked results for all unregistered IPs. Users with registered accounts must explicitly re-enable the rule. ([#5907](https://github.com/rspamd/rspamd/pull/5907))</li><li>**DKIM unknown key handling per RFC**: Unknown and broken DKIM keys are now handled strictly per RFC, which may change DKIM results for messages with malformed keys. ([e9e6bac](https://github.com/rspamd/rspamd/commit/e9e6bac43))</li><li>**Suspicious TLDs now map-based**: The hardcoded suspicious TLD list has been replaced with `conf/maps.d/suspicious_tlds.inc`. Customize by creating `local.d/maps.d/suspicious_tlds.inc` (override) or `local.d/maps.d/suspicious_tlds.inc.local` (extend). ([614e68c](https://github.com/rspamd/rspamd/commit/614e68c8b))</li><li>**Neural module autolearn option renames**: Autolearn options in the neural module have been renamed to match RBL module naming conventions. Review custom neural configurations for use of old option names. ([#5835](https://github.com/rspamd/rspamd/pull/5835))</li><li>**libfasttext external dependency removed**: The external libfasttext C++ library has been replaced with a built-in mmap-based shim. The `ENABLE_FASTTEXT` cmake option is removed (always enabled). Packagers must remove the libfasttext build dependency. ([#5897](https://github.com/rspamd/rspamd/pull/5897))</li></ul><h3>Added</h3><ul><li>**Jinja2 configuration templates**: Configuration files are now preprocessed by the [Lupa Jinja2-compatible template engine](/configuration/templates) before UCL parsing. Environment variables prefixed with `RSPAMD_` are exposed as the `env` table in templates; modified delimiters (`{= =}` for expressions, `{% %}` for control structures) avoid conflicts with UCL syntax. New validation filters (`mandatory`, `require_int`, `require_number`, `require_bool`, `require_duration`, `require_json`, `fromjson`) abort startup with a clear error on invalid input, enabling container-ready configuration validation without shell entrypoint scripts. ([#5938](https://github.com/rspamd/rspamd/pull/5938), [#5941](https://github.com/rspamd/rspamd/pull/5941))</li><li>**checkv3 multipart protocol**: New `/checkv3` endpoint using `multipart/form-data` requests and `multipart/mixed` responses; metadata sent as structured JSON/msgpack instead of HTTP headers, per-part zstd compression, optional body part for rewritten messages, and zero-copy piecewise writev for responses. Use `rspamc --protocol-v3` or `rspamc --msgpack` to activate. ([#5880](https://github.com/rspamd/rspamd/pull/5880))</li><li>**Pluggable Hyperscan cache backend**: Hyperscan compilation and caching moved to an async Lua backend with Redis-based shared database support across workers and hosts. Async compilation prevents blocking the main event loop; self-healing cache auto-detects stale blobs and triggers recompile; small databases compiled in-memory without file caching. ([#5813](https://github.com/rspamd/rspamd/pull/5813), [#5952](https://github.com/rspamd/rspamd/pull/5952))</li><li>**Multi-flag fuzzy hashes**: A single fuzzy hash can now carry up to 8 flags simultaneously, allowing multiple rules to match the same digest with independent flag/value pairs. Redis update path rewritten in Lua with EVALSHA and NOSCRIPT recovery. Backward-compatible epoch 12 wire protocol with highest-value flag promoted to the primary slot. Fuzzy hashes now stored in Redis history. ([#5894](https://github.com/rspamd/rspamd/pull/5894), [#5860](https://github.com/rspamd/rspamd/pull/5860))</li><li>**HTML fuzzy phishing detection**: Dual-mode fuzzy matching — template matching and domain-sensitive matching. New `FUZZY_HTML_PHISHING` symbol fires when an HTML template matches but domains differ, detecting reused phishing templates with swapped links. ([173058061](https://github.com/rspamd/rspamd/commit/173058061))</li><li>**Built-in Fasttext shim**: External C++ libfasttext replaced with a zero-dependency mmap-based reader providing shared memory across workers via `MAP_SHARED`, eliminating per-worker heap copies and saving approximately 500MB–7GB RAM. No more C++ exception ABI issues. Existing `.bin`/`.ftz` models continue to work unchanged. Fasttext wired through maps infrastructure for hot-reloading. ([#5897](https://github.com/rspamd/rspamd/pull/5897), [#5909](https://github.com/rspamd/rspamd/pull/5909))</li><li>**Neural network and LLM embedding improvements**: External pretrained neural model support; LLM embedding providers with multi-model support, mean+max pooling, and SIF word weighting; multi-layer funnel architecture; language-based model and URL selection; expression-based autolearn for neural LLM providers; GPT module with configurable consensus thresholds, `context_augment` hook, and mempool variable storage. ([#5924](https://github.com/rspamd/rspamd/pull/5924), [#5903](https://github.com/rspamd/rspamd/pull/5903), [#5897](https://github.com/rspamd/rspamd/pull/5897), [#5835](https://github.com/rspamd/rspamd/pull/5835))</li><li>**HTTPS server support**: Workers can now serve HTTPS natively with SSL auto-detected from bind socket configuration, enabling secure WebUI and API without a reverse proxy. ([#5884](https://github.com/rspamd/rspamd/pull/5884), [d04b367](https://github.com/rspamd/rspamd/commit/d04b367db))</li><li>**Ring Hash (Ketama) consistent hashing**: Proper consistent hashing with virtual nodes ensures only ~1/n keys redistribute when an upstream fails, and keys return to their original upstream on recovery. ([4ea7504](https://github.com/rspamd/rspamd/commit/4ea750466))</li><li>**Token bucket proxy load balancing**: New load balancing algorithm for proxy upstreams with configurable `max_tokens`, `scale`, and `base_cost` parameters for better burst traffic handling. ([#5874](https://github.com/rspamd/rspamd/pull/5874))</li><li>**Multiclass Bayes support**: Classifiers now support arbitrary classes beyond binary spam/ham. WebUI learning interface updated for multi-class workflows. `/stat` and `/bayes/classifiers` endpoints extended with classifier metadata. `rspamadm statistics_dump` supports multi-class dump and restore. ([#5900](https://github.com/rspamd/rspamd/pull/5900), [#5893](https://github.com/rspamd/rspamd/pull/5893), [#5914](https://github.com/rspamd/rspamd/pull/5914))</li><li>**Structured metadata exporter**: New structured formatter for the metadata exporter module with zstd compression option and detected MIME types for attachments. ([#5890](https://github.com/rspamd/rspamd/pull/5890))</li><li>**UUID v7 per task**: Native UUID v7 generation per scanning task synced with the `Log-Tag` header and ClickHouse UUID v7 column support. ([#5890](https://github.com/rspamd/rspamd/pull/5890))</li><li>**ARC trusted_authserv_id**: Reuse upstream authentication results via trusted `Authentication-Results` headers from known authentication servers. ([506ef44](https://github.com/rspamd/rspamd/commit/506ef44b8))</li><li>**Legacy protocol milter headers**: Milter `add_headers` and `remove_headers` exposed in the RSPAMC/SPAMC text protocol with extended symbol info including descriptions and options, enabling Exim to access milter headers via `$spam_report`. ([#5948](https://github.com/rspamd/rspamd/pull/5948))</li><li>**rspamadm new subcommands**: `rspamadm autolearnstats` for autolearn statistics analysis; `rspamadm logstats` and `rspamadm mapstats` as rewrites of legacy Perl scripts; `rspamadm statistics_dump migrate` for Bayes shard migration. ([#5946](https://github.com/rspamd/rspamd/pull/5946), [#5885](https://github.com/rspamd/rspamd/pull/5885), [#5914](https://github.com/rspamd/rspamd/pull/5914))</li><li>**HTTP content negotiation**: Framework for content negotiation on API endpoints; `/stat` endpoint supports zstd-compressed responses. ([#5832](https://github.com/rspamd/rspamd/pull/5832))</li><li>**PDF improvements**: ASCII85 decode support, ligature substitution fix, object padding evasion defeat, and small objects no longer counted toward processing limits. ([73a37be](https://github.com/rspamd/rspamd/commit/73a37be63), [eb1acde](https://github.com/rspamd/rspamd/commit/eb1acde80), [2b91e5e](https://github.com/rspamd/rspamd/commit/2b91e5ef5), [1f02010](https://github.com/rspamd/rspamd/commit/1f020105e))</li><li>**Reply-To validity checks**: New header checks for `Reply-To` address validity. ([e95533f](https://github.com/rspamd/rspamd/commit/e95533f1f))</li></ul><h3>Fixed</h3><ul><li>**Fuzzy UDP use-after-free** (critical): Fixed use-after-free on ev_io watcher in fuzzy UDP sessions. ([4557166](https://github.com/rspamd/rspamd/commit/455716621))</li><li>**Fuzzy TCP CPU busy-loop**: Fixed CPU spin in fuzzy TCP client under certain error conditions. ([06dba44](https://github.com/rspamd/rspamd/commit/06dba4495))</li><li>**SPF address family flag inheritance**: Correct propagation of address family flags in SPF resolution. ([2a8643e](https://github.com/rspamd/rspamd/commit/2a8643e5e))</li><li>**DKIM RSA signing memory leak**: Fixed memory leak in RSA path of DKIM signing. ([9608160](https://github.com/rspamd/rspamd/commit/9608160b1))</li><li>**RHEL/CentOS 10 SHA-1 DKIM policy bypass**: Fixed crypto-policy bypass for SHA-1 DKIM signatures on RHEL/CentOS 10. ([7a38a8e](https://github.com/rspamd/rspamd/commit/7a38a8e33))</li><li>**Ratelimit compatibility with old records**: Fixed backward compatibility with legacy ratelimit bucket records. ([#5842](https://github.com/rspamd/rspamd/pull/5842))</li><li>**Weighted round-robin not respecting weights**: Fixed upstream selection ignoring configured weights. ([f563e25](https://github.com/rspamd/rspamd/commit/f563e25a0))</li><li>**SVG misdetection**: Fixed incorrect HTML detection for messages with embedded SVG content. ([170c4c5](https://github.com/rspamd/rspamd/commit/170c4c5d6))</li><li>**Hyperscan use-after-free on config reload**: Multiple use-after-free issues in Hyperscan cache handling during live configuration reload resolved. ([#5813](https://github.com/rspamd/rspamd/pull/5813))</li><li>**Jemalloc tuning**: Jemalloc tuned for Rspamd&apos;s single-threaded multi-process architecture, reducing memory overhead. ([#5949](https://github.com/rspamd/rspamd/pull/5949))</li></ul><h3>Improved</h3><ul><li>**Consistent hash distribution**: Ring Hash with virtual nodes provides true minimal disruption on upstream failure and guarantees key return to original upstream on recovery, replacing the previous Jump Hash algorithm.</li><li>**Hyperscan async compilation**: Compilation no longer blocks the main event loop; self-healing blob detection ensures cache correctness after Hyperscan version changes.</li><li>**Fasttext memory efficiency**: Built-in shim shares model data across all worker processes via shared memory, eliminating 500MB–7GB of duplicate heap allocations typical in multi-worker deployments.</li><li>**Fuzzy hash expressiveness**: Multi-flag support allows a single stored digest to satisfy multiple independent rule checks simultaneously without duplication in storage.</li></ul>]]></description>
      <category>major</category>
    </item>
    <item>
      <title>Rspamd 3.14.3 - Patch Release with PDF Text Extraction, Task Registry, and ClickHouse Extensibility</title>
      <link>https://docs.rspamd.com/changelog/3.14.3</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.14.3</guid>
      <pubDate>Thu, 08 Jan 2026 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.14.3 - Patch Release with PDF Text Extraction, Task Registry, and ClickHouse Extensibility</h2><h3>Added</h3><ul><li>**PDF text extraction**: Extract text from PDF attachments for content analysis with text quality filtering, FlateDecode and ASCIIHexDecode filter support, UTF-16 detection and conversion, ligature support for Standard and MacRoman encodings, and new `rspamadm mime -r` flag for raw extraction ([#5762](https://github.com/rspamd/rspamd/pull/5762))</li><li>**Task registry for Lua**: New task registry prevents use-after-free bugs in async Lua callbacks using counter-based keys instead of raw pointers for safe validation ([#5803](https://github.com/rspamd/rspamd/pull/5803))</li><li>**ClickHouse extra tables API**: Plugins can dynamically register custom ClickHouse tables via `rspamd_plugins[&apos;clickhouse&apos;].register_extra_table()` with per-table schemas, row callbacks, and independent retention settings ([#5810](https://github.com/rspamd/rspamd/pull/5810))</li><li>**WebUI error log**: New error log modal tracking last 50 API errors with unseen error counter badge, copy-to-clipboard support with HTTP fallback, and color-coded error types with responsive column hiding ([#5821](https://github.com/rspamd/rspamd/pull/5821))</li></ul><h3>Fixed</h3><ul><li>**Neural symbol flags**: Include symbols with no flags by default instead of excluding them</li><li>**Symcache FINE propagation**: Make FINE propagation deterministic ([#5825](https://github.com/rspamd/rspamd/issues/5825))</li><li>**URL numeric IP**: Prevent false positives from numeric IP regeneration in mailto URLs ([#5823](https://github.com/rspamd/rspamd/issues/5823))</li><li>**Settings selector**: Allow spaces in selector regexps</li><li>**Thai language**: Prevent R_MIXED_CHARSET false positives by marking Thai as having diacritics ([#5817](https://github.com/rspamd/rspamd/pull/5817), [#5799](https://github.com/rspamd/rspamd/issues/5799))</li><li>**GPT plugin**: Improved output format instructions for more reliable responses ([#5815](https://github.com/rspamd/rspamd/pull/5815), [#5670](https://github.com/rspamd/rspamd/issues/5670))</li><li>**io_uring HTTP**: Handle connection errors properly in HTTP client backend</li><li>**MIME Lua stack**: Restore Lua stack properly in second-pass detection</li><li>**Redis stats**: Add null checks for task in callbacks</li><li>**Lua 5.4 compatibility**: Fixes in ClickHouse and Elastic plugins ([#5774](https://github.com/rspamd/rspamd/pull/5774))</li><li>**Task lifetime crashes**: Fix potential crashes when Redis is unavailable longer than task lifetime ([#5803](https://github.com/rspamd/rspamd/pull/5803))</li></ul><h3>Improved</h3><ul><li>**replxx build**: Fix build with LLVM 21+</li><li>**macOS build**: Add Homebrew LLVM libc++ library path</li><li>**fmt library**: Update to version 12.1.0</li><li>**SDK headers**: Avoid SDK headers in include path when package ROOT is specified</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 3.14.2 - Patch Release with Metadata Exporter Enhancements and DMARC RUA Validation</title>
      <link>https://docs.rspamd.com/changelog/3.14.2</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.14.2</guid>
      <pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.14.2 - Patch Release with Metadata Exporter Enhancements and DMARC RUA Validation</h2><h3>Added</h3><ul><li>**Metadata Exporter Formatters**: New formatters `multipart`, `msgpack`, and `json_with_message` for better message content handling including binary support and multipart uploads</li><li>**DMARC RUA Validation**: New `rspamadm dmarc_report --recheck-rua` flag to re-validate cached report addresses against exclusion maps (useful with RBLs)</li><li>**Selective AR Header Removal**: New `remove_ar_from` setting in milter_headers to selectively remove Authentication-Results headers from specific domains</li></ul><h3>Improved</h3><ul><li>**Metadata Exporter Docs**: Added comprehensive example for handling multipart metadata exports with Python/aiohttp</li></ul><h3>Deprecated</h3><ul><li>**Metadata Exporter**: `meta_headers` option is deprecated; use `multipart` or `msgpack` formatters instead</li></ul>]]></description>
      <category>patch</category>
    </item>
    <item>
      <title>Rspamd 3.14.1 - Minor Release with Composites Optimization, URL Deep Processing, and lua_shape Migration</title>
      <link>https://docs.rspamd.com/changelog/3.14.1</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.14.1</guid>
      <pubDate>Mon, 01 Dec 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.14.1 - Minor Release with Composites Optimization, URL Deep Processing, and lua_shape Migration</h2><h3>Added</h3><ul><li>**Composites optimization**: Inverted index with bloom filter for fast negative symbol lookups, precomputed atom types, and composites statistics with control protocol command ([#5764](https://github.com/rspamd/rspamd/pull/5764))</li><li>**Multimap combinators**: Support for combinator option in multimap selector rules for flexible matching logic ([#5766](https://github.com/rspamd/rspamd/pull/5766))</li><li>**SPF flattening tool**: New rspamadm utility for SPF record flattening with macro preservation ([#5757](https://github.com/rspamd/rspamd/pull/5757))</li><li>**URL deep processing**: Architecture for C-to-Lua URL filter consultation with async HTML URL processing ([#5748](https://github.com/rspamd/rspamd/pull/5748))</li><li>**Obfuscated URL detection**: Enhanced url_suspect plugin with rspamd_util.decode_html_entities for detecting obfuscated URLs ([#5761](https://github.com/rspamd/rspamd/pull/5761))</li><li>**lua_shape validation**: New validation library replacing tableshape with T.callable() type and callable defaults support ([#5754](https://github.com/rspamd/rspamd/pull/5754))</li><li>**Whitelist symbol flags**: Auto-mark whitelist symbols with SYMBOL_TYPE_FINE flag for better scoring granularity</li></ul><h3>Fixed</h3><ul><li>**url_suspect false positives**: Fix plugin causing massive false positives with high URL volume messages ([#5758](https://github.com/rspamd/rspamd/pull/5758))</li><li>**Network chunking loop**: Prevent infinite loop in split_networks_into_chunks() function ([#5763](https://github.com/rspamd/rspamd/pull/5763))</li><li>**Memory leak**: Fix leak in custom tokenizer result handling</li><li>**lua_shape compatibility**: Multiple fixes for transform logic, registry resolution, error safety, and tableshape compatibility ([#5754](https://github.com/rspamd/rspamd/pull/5754))</li><li>**Composites inverted index**: Fix group matchers handling and improve atom polarity detection ([#5764](https://github.com/rspamd/rspamd/pull/5764))</li><li>**Lua composites**: Copy expression string to memory pool to prevent use-after-free ([#5764](https://github.com/rspamd/rspamd/pull/5764))</li><li>**D3 libraries**: Update with bug fixes and validation improvements ([#5765](https://github.com/rspamd/rspamd/pull/5765))</li><li>**Settings processing**: Keep groups_*/symbols_* fields in settings for runtime processing</li><li>**Redirect URL encoding**: Properly encode URLs with unencoded spaces and special characters ([#5748](https://github.com/rspamd/rspamd/pull/5748))</li><li>**WebUI hover**: Restore hover colors for symbols and fix hover behavior outside status tables ([#5741](https://github.com/rspamd/rspamd/pull/5741), [#5742](https://github.com/rspamd/rspamd/pull/5742))</li><li>**External relay**: Fix mixins and confighelp for external_relay module ([#5754](https://github.com/rspamd/rspamd/pull/5754))</li></ul><h3>Improved</h3><ul><li>**url_suspect performance**: Optimize plugin for high URL volume messages with better deduplication ([#5758](https://github.com/rspamd/rspamd/pull/5758))</li><li>**tableshape migration**: Complete migration of all plugins and libraries from tableshape to lua_shape ([#5754](https://github.com/rspamd/rspamd/pull/5754))</li><li>**Configuration simplification**: Remove use_*_map flags and simplify URL processing configuration ([#5748](https://github.com/rspamd/rspamd/pull/5748))</li><li>**Whitelist maps**: Use contemporary API for maps in whitelist module ([#5746](https://github.com/rspamd/rspamd/pull/5746))</li><li>**WebUI symbols**: Improved hover colors and behavior for symbols display ([#5760](https://github.com/rspamd/rspamd/pull/5760))</li><li>**Plugins registry**: Centralized plugins registry with reworked mixins and documentation ([#5754](https://github.com/rspamd/rspamd/pull/5754))</li></ul>]]></description>
      <category>minor</category>
    </item>
    <item>
      <title>Rspamd 3.14.0 - Major Release with HTML Fuzzy Hashing, TCP Fuzzy Protocol, and Enhanced URL Processing</title>
      <link>https://docs.rspamd.com/changelog/3.14.0</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.14.0</guid>
      <pubDate>Mon, 10 Nov 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.14.0 - Major Release with HTML Fuzzy Hashing, TCP Fuzzy Protocol, and Enhanced URL Processing</h2><h3>Added</h3><ul><li>**HTML fuzzy hashing**: Structural similarity matching for HTML content with per-rule text_hashes toggle and dedicated shingles generation ([#5661](https://github.com/rspamd/rspamd/pull/5661), [#5720](https://github.com/rspamd/rspamd/pull/5720))</li><li>**Fuzzy TCP protocol**: Full TCP support in fuzzy storage and check with auto-switch, connection management, and proper framing ([#5669](https://github.com/rspamd/rspamd/pull/5669))</li><li>**CTA URL extraction**: New task:get_cta_urls() API and dedicated CTA module for proper call-to-action domain extraction ([#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**Web search context**: LLM plugin integration with search API for domain context enrichment with Redis caching ([#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**HTML URL rewriting**: Infrastructure for async HTML URL rewriting with Lua bindings and UTF-8 support ([#5676](https://github.com/rspamd/rspamd/pull/5676))</li><li>**Dark mode**: Full dark mode implementation in WebUI with theme toggle and auto detection ([#5725](https://github.com/rspamd/rspamd/pull/5725))</li><li>**Email aliases**: Advanced aliases resolution with loop detection for converging paths and expand_multiple mode ([#5655](https://github.com/rspamd/rspamd/pull/5655))</li><li>**ESMTP arguments**: Milter ESMTP argument parsing with Lua API access for per-recipient metadata ([#5663](https://github.com/rspamd/rspamd/pull/5663))</li><li>**URL hash method**: Exposed url:get_hash() method for efficient deduplication without string conversion overhead ([#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**Postfix wizard**: Configwizard integration for automated Postfix setup using postconf utility ([#5667](https://github.com/rspamd/rspamd/pull/5667))</li><li>**BSD workflows**: Comprehensive GitHub Actions workflows for FreeBSD, NetBSD, and OpenBSD with Lua version selection ([#5726](https://github.com/rspamd/rspamd/pull/5726), [#5728](https://github.com/rspamd/rspamd/pull/5728), [#5729](https://github.com/rspamd/rspamd/pull/5729))</li><li>**Automated code review**: GitHub Actions workflow for Droid-powered code review with Rspamd-specific guidelines ([#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**Multimap multisymbol**: Support for symbols with leading numerals in multimap plugin ([#5680](https://github.com/rspamd/rspamd/pull/5680))</li><li>**Remove headers array**: Support array of positions for milter remove_headers operations ([#5673](https://github.com/rspamd/rspamd/pull/5673))</li><li>**Client IP forwarding**: Proxy client IP preservation in message headers through chain ([#5671](https://github.com/rspamd/rspamd/pull/5671))</li><li>**Milter header support**: rspamc --mime support for milter.add_headers object format ([#5684](https://github.com/rspamd/rspamd/pull/5684))</li><li>**Public suffix automation**: Automatic synchronization of public suffix list via GitHub Actions ([#5718](https://github.com/rspamd/rspamd/pull/5718))</li><li>**Fuzzy migration utility**: Redis migration utility for fuzzy storage data ([#5692](https://github.com/rspamd/rspamd/pull/5692))</li><li>**Integration test suite**: Docker-based integration tests with ASAN, leak detection, and real corpus ([#5688](https://github.com/rspamd/rspamd/pull/5688))</li><li>**DMARC report headers**: Auto-Reply-To and Precedence headers to prevent out-of-office replies ([#5686](https://github.com/rspamd/rspamd/pull/5686))</li><li>**NetBSD memory support**: Memory usage tracking for NetBSD platform ([#5726](https://github.com/rspamd/rspamd/pull/5726))</li></ul><h3>Fixed</h3><ul><li>**DNS truncation**: Preserve req-&gt;pos during reply validation to prevent packet truncation on UDP-to-TCP retransmits ([#5739](https://github.com/rspamd/rspamd/pull/5739))</li><li>**DNS transaction ID**: Regenerate transaction ID before copying to TCP buffer to avoid collisions ([#5739](https://github.com/rspamd/rspamd/pull/5739))</li><li>**DMARC report batching**: Add batching and forced GC for Redis connections to prevent connection pool exhaustion ([#5737](https://github.com/rspamd/rspamd/pull/5737))</li><li>**Batch size validation**: Validate and normalize batch_size to prevent fractional indexing and loop errors ([#5737](https://github.com/rspamd/rspamd/pull/5737))</li><li>**Allocator mismatches**: Fix jemalloc/system malloc mixing in getline(), hiredis, and libucl to prevent crashes ([#5721](https://github.com/rspamd/rspamd/pull/5721), [#5724](https://github.com/rspamd/rspamd/pull/5724))</li><li>**Hyperscan version**: Use runtime version instead of compile-time for database validation and auto-recreate invalid cache files ([#5724](https://github.com/rspamd/rspamd/pull/5724))</li><li>**DNS round-robin**: Fix nameserver rotation from /etc/resolv.conf using ROUND_ROBIN instead of MASTER_SLAVE ([#5721](https://github.com/rspamd/rspamd/pull/5721))</li><li>**Memory leaks**: Fix leaks in fuzzy storage khash, upstream parsing, address parsing, OpenSSL providers, and UCL objects ([#5709](https://github.com/rspamd/rspamd/pull/5709))</li><li>**Fuzzy TCP bugs**: Fix double-release, timeout handling, buffer overflow, endianness mismatch, and race conditions ([#5669](https://github.com/rspamd/rspamd/pull/5669), [#5716](https://github.com/rspamd/rspamd/pull/5716))</li><li>**Shutdown tracking**: Keep srv events active during shutdown to track auxiliary processes via pipe notifications ([#5728](https://github.com/rspamd/rspamd/pull/5728))</li><li>**ARC signing**: Restore strict header ordering per RFC 8617 and add ed25519 key support ([#5684](https://github.com/rspamd/rspamd/pull/5684))</li><li>**Composite evaluation**: Implement two-phase evaluation for postfilter dependencies and fix symbol lookup ([#5681](https://github.com/rspamd/rspamd/pull/5681))</li><li>**URL extraction DoS**: Refactor to use hash-based deduplication with 50k URL limit to prevent string table exhaustion ([#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**Bayes autolearn**: Allow skipping local/authenticated mail in default autolearn condition ([#5679](https://github.com/rspamd/rspamd/pull/5679))</li><li>**Bayes Redis discovery**: Improve Redis server discovery for Bayes storage ([#5714](https://github.com/rspamd/rspamd/pull/5714))</li><li>**ESMTP args parsing**: Robust per-recipient parsing in milter with safe cursor advance and refcount management ([#5663](https://github.com/rspamd/rspamd/pull/5663))</li><li>**HTML attribute offsets**: Correct offset calculation for URL rewriting in HTML attributes ([#5676](https://github.com/rspamd/rspamd/pull/5676))</li><li>**OpenBSD support**: Fix kinfo_proc member names and disable Hyperscan on OpenBSD ([#5729](https://github.com/rspamd/rspamd/pull/5729))</li><li>**FreeBSD packages**: Fix zstd package name and add IGNORE_OSVERSION for version mismatches ([#5729](https://github.com/rspamd/rspamd/pull/5729))</li><li>**NetBSD build**: Fix package installation with pkgin and correct dependency names ([#5726](https://github.com/rspamd/rspamd/pull/5726), [#5729](https://github.com/rspamd/rspamd/pull/5729))</li><li>**Bayes metadata leak**: Fix memory leak in stat metadata tokenization ([#5688](https://github.com/rspamd/rspamd/pull/5688))</li><li>**MIME anonymization**: Remove Authentication-Results and anonymize envelope-from in Received headers for LLM processing ([#5687](https://github.com/rspamd/rspamd/pull/5687))</li><li>**Multimap symbols**: Handle symbols with leading numerals in multimap ([#5680](https://github.com/rspamd/rspamd/pull/5680))</li><li>**Aliases validation**: Prevent creation of malformed email addresses in aliases module ([#5655](https://github.com/rspamd/rspamd/pull/5655))</li><li>**HTML fuzzy cache**: Fix cache key collision between text and HTML fuzzy hashes ([#5661](https://github.com/rspamd/rspamd/pull/5661))</li><li>**CSS normalization**: Fix CSS class normalization in HTML fuzzy token generation ([#5661](https://github.com/rspamd/rspamd/pull/5661))</li></ul><h3>Improved</h3><ul><li>**RBL configuration**: Refactored with new from selectors, content_urls checks, and lower_utf8 for hashed domains ([#5733](https://github.com/rspamd/rspamd/pull/5733))</li><li>**URL prioritization**: Prioritize CTA URLs in redirector and Lua helpers with proper phishing bonus preservation ([#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**Fuzzy checks structure**: New structured checks configuration with backward-compatible legacy flags support ([#5720](https://github.com/rspamd/rspamd/pull/5720))</li><li>**Hash performance**: Replace GHashTable with khash in fuzzy_check and CTA URL extraction for better performance ([#5720](https://github.com/rspamd/rspamd/pull/5720), [#5732](https://github.com/rspamd/rspamd/pull/5732))</li><li>**WebUI icons**: Replace Glyphicons with FontAwesome SVG icons ([#5702](https://github.com/rspamd/rspamd/pull/5702))</li><li>**WebUI libraries**: Update CodeJar to 4.3.0, Node.js/ESLint, and D3-based visualization libs ([#5684](https://github.com/rspamd/rspamd/pull/5684), [#5717](https://github.com/rspamd/rspamd/pull/5717), [#5738](https://github.com/rspamd/rspamd/pull/5738))</li><li>**Bayes learn guards**: Make learn guards configurable instead of hardcoded ([#5701](https://github.com/rspamd/rspamd/pull/5701))</li><li>**Lua logger**: Add type specifiers support for better formatting ([#5668](https://github.com/rspamd/rspamd/pull/5668))</li><li>**DMARC reporting**: Refactor to use helper functions and async maps for better maintainability ([#5722](https://github.com/rspamd/rspamd/pull/5722))</li><li>**Config refcounting**: Add CFG_REF_* macros with debug logging for better lifecycle tracking ([#5709](https://github.com/rspamd/rspamd/pull/5709))</li><li>**Memory pool destructors**: Smart destructor preallocation based on pool type with specialized allocation strategies ([#5693](https://github.com/rspamd/rspamd/pull/5693))</li><li>**Heap implementation**: Convert to fully intrusive kvec-based implementation eliminating double allocation ([#5693](https://github.com/rspamd/rspamd/pull/5693))</li><li>**OpenSSL lifecycle**: Move providers from global to libs_ctx for clearer ownership ([#5709](https://github.com/rspamd/rspamd/pull/5709))</li><li>**Libucl stack management**: Automatic stack preservation for included files without outer braces ([#5717](https://github.com/rspamd/rspamd/pull/5717))</li><li>**Body rewriting**: Improve body rewriting support in rspamc and proxy ([#5675](https://github.com/rspamd/rspamd/pull/5675))</li><li>**AI assistant config**: Add Claude Code and Cursor AI configuration for development ([#5667](https://github.com/rspamd/rspamd/pull/5667))</li><li>**Fuzzy logging**: Add protocol logging and reduce dumb log verbosity ([#5669](https://github.com/rspamd/rspamd/pull/5669), [#5701](https://github.com/rspamd/rspamd/pull/5701))</li><li>**Docker configuration**: Add explicit console logging and runtime flags ([#5701](https://github.com/rspamd/rspamd/pull/5701))</li></ul>]]></description>
      <category>major</category>
    </item>
    <item>
      <title>Rspamd 3.13.2 - Feature Update with Enhanced Security and Performance</title>
      <link>https://docs.rspamd.com/changelog/3.13.2</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.13.2</guid>
      <pubDate>Sun, 05 Oct 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.13.2 - Feature Update with Enhanced Security and Performance</h2><h3>Added</h3><ul><li>**Fuzzy check encryption**: Separate encryption keys for read and write operations in fuzzy_check plugin ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li><li>**DKIM ED25519 support**: Full ED25519 support for DKIM signing and verification with OpenSSL version checks ([#5664](https://github.com/rspamd/rspamd/pull/5664))</li><li>**Vault KV v2 support**: HashiCorp Vault KV version 2 support for DKIM key management with backward compatibility ([#5654](https://github.com/rspamd/rspamd/pull/5654))</li><li>**MetaDefender integration**: MetaDefender Cloud Lua module for SHA256 hash lookups as free-tier anti-malware scanning alternative ([#5656](https://github.com/rspamd/rspamd/pull/5656))</li><li>**LLM context support**: User/domain context support for LLM-based classification with Redis-based conversation context ([#5647](https://github.com/rspamd/rspamd/pull/5647))</li><li>**DMARC RUA exclusion**: Configuration option to exclude specific RUA addresses from DMARC report storage ([#5653](https://github.com/rspamd/rspamd/pull/5653))</li></ul><h3>Fixed</h3><ul><li>**DKIM bodyhash calculation**: Fixed relaxed bodyhash calculation for lines with only spaces to comply with RFC 6376 Section 3.4.4 ([#5662](https://github.com/rspamd/rspamd/pull/5662))</li><li>**DKIM key loading**: Fixed ED25519 key loading to prevent memory corruption in union handling ([#5664](https://github.com/rspamd/rspamd/pull/5664))</li><li>**HTTP map intervals**: Enforced server-controlled refresh intervals and prevented aggressive polling behavior ([#5660](https://github.com/rspamd/rspamd/pull/5660))</li><li>**HTTP map overflow**: Prevented time_t overflow in expires header processing ([#5660](https://github.com/rspamd/rspamd/pull/5660))</li><li>**Once received plugin**: Fixed duplicate symbol addition by changing break to return in check_quantity_received ([#5658](https://github.com/rspamd/rspamd/pull/5658))</li><li>**Redis Sentinel**: Properly propagate unused Sentinel options ([#5597](https://github.com/rspamd/rspamd/pull/5597))</li><li>**Fuzzy check decryption**: Fixed reply decryption when using separate read/write keys ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li><li>**Fuzzy check fallback**: Added fallback when only one specific encryption key is set ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li><li>**Fuzzy check filtering**: Fixed duplicate key filtering in reply decryption ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li><li>**Fuzzy ping servers**: Allow read/write servers configuration ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li></ul><h3>Improved</h3><ul><li>**Fuzzy check performance**: Refactored encryption key selection into helper functions for better maintainability ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li><li>**Fuzzy check efficiency**: Stop early when found a correct key to improve performance ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li><li>**Development workflow**: Added cursor rules for improved development experience ([#5665](https://github.com/rspamd/rspamd/pull/5665))</li></ul>]]></description>
      <category>minor</category>
    </item>
    <item>
      <title>Rspamd 3.13.1 - Feature Update with Stability and Security Improvements</title>
      <link>https://docs.rspamd.com/changelog/3.13.1</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.13.1</guid>
      <pubDate>Tue, 30 Sep 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.13.1 - Feature Update with Stability and Security Improvements</h2><h3>Added</h3><ul><li>**Archive module**: Full support for encrypted ZIP archives, including both ZipCrypto and AES encryption; both reading and writing of AES-encrypted ZIP archives is supported with updated Lua bindings using libarchive for flexible compatibility with all standard ZIP encryption schemes</li><li>**Encrypted maps**: Support for encrypted maps to enable new map distribution scenarios</li><li>**Redis TLS**: Configurable TLS connections in Redis backend for improved compatibility in secure environments</li></ul><h3>Improved</h3><ul><li>**MIME encoding refactoring**: Major overhauls and multiple fixes for MIME encoding logic, including improved handling and decoding of UTF-8 in MIME headers, resulting in more robust email processing and better compatibility</li><li>**Learning system**: Numerous fixes to learn checks and autolearn flag handling, prevention of duplicate message learning, and extended multiclass learning test coverage</li><li>**Map helpers alignment**: Map helpers now enforced to be aligned to 64 bytes to prevent unaligned memory access errors on certain platforms</li><li>**CLI enhancements**: Enhanced secretbox CLI and additional security test coverage</li><li>**Platform compatibility**: Improved compatibility with Lua versions above 5.1 and better support for 32-bit platforms</li></ul><h3>Fixed</h3><ul><li>**Critical fixes**: Fixed bug when converting zero-length strings to numbers</li><li>**XML parsing**: Fixed XML prolog detection in lua_magic module</li><li>**Build issues**: Fixed build issues on 32-bit platforms</li><li>**Empty input handling**: Addressed issues with empty input handling in lua_magic</li><li>**Test stability**: Improved stability of automated testing with multiple miscellaneous test fixes</li><li>**Compatibility**: Minor compatibility improvements and bugfixes (buffer allocation, missing cmath include, etc.)</li></ul>]]></description>
      <category>minor</category>
    </item>
    <item>
      <title>Rspamd 3.13.0 - Major Release with Breaking Changes</title>
      <link>https://docs.rspamd.com/changelog/3.13.0</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.13.0</guid>
      <pubDate>Wed, 17 Sep 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.13.0 - Major Release with Breaking Changes</h2><h3>Breaking Changes</h3><ul><li>Multiclass Bayes classification with named classes (2–20 classes), processed in a single Redis call per message. ([#5547](https://github.com/rspamd/rspamd/pull/5547))</li><li>Backward compatible with `is_spam`; new configuration supports labeled classes and autolearn.</li><li>Lua API and autolearn support multiclass workflows with class‑aware Redis caching.</li><li>CLI: `rspamc learn_class:transactional …`, `rspamc learn_class:newsletter …`.</li><li>Lua API: `task:get_multiclass_result()` returns class probabilities and confidence.</li></ul><h3>Added</h3><ul><li>Neural module overhaul to a provider‑based architecture (symbols, LLM embeddings), versioned and backward compatible. ([#5579](https://github.com/rspamd/rspamd/pull/5579))</li><li>Pluggable feature fusion with trained normalization (unit/zscore/none) applied at train and inference.</li><li>Redis‑backed caching for LLM embeddings to control cost and latency.</li><li>Multimap selectors: SA‑style selectors with dedicated `selector` field; integrated with Hyperscan and regex cache. ([#5615](https://github.com/rspamd/rspamd/pull/5615))</li><li>MIME parser automatically detects part types. ([#5619](https://github.com/rspamd/rspamd/pull/5619), [#5608](https://github.com/rspamd/rspamd/pull/5608))</li><li>HTML parser extracts more features for downstream modules.</li></ul><h3>Improved</h3><ul><li>HTTP timeouts configuration and handling. ([#5614](https://github.com/rspamd/rspamd/pull/5614), [#5603](https://github.com/rspamd/rspamd/pull/5603), [#5601](https://github.com/rspamd/rspamd/pull/5601))</li><li>Upstream reliability with probe mode; reduced need for forced revive.</li><li>DNS nameserver resolution switched to `getaddrinfo`.</li><li>Standardized on C++20; build, test, and CI improvements (including ARM support and modern fallback maps). ([#5592](https://github.com/rspamd/rspamd/pull/5592), [#5598](https://github.com/rspamd/rspamd/pull/5598), [#5580](https://github.com/rspamd/rspamd/pull/5580))</li><li>Regular code cleaning and RPM tweaks.</li><li>WebUI: E2E scan test flows; Bootstrap upgrade; Bayes class management from the web interface. ([#5606](https://github.com/rspamd/rspamd/pull/5606), [#5607](https://github.com/rspamd/rspamd/pull/5607))</li><li>GPT/LLM integrations: improved parameter/prompt handling; initial support for OpenAI GPT‑5 and other models. ([#5612](https://github.com/rspamd/rspamd/pull/5612), [#5572](https://github.com/rspamd/rspamd/pull/5572))</li></ul><h3>Fixed</h3><ul><li>DCC plugin rewritten and optimized. ([#5602](https://github.com/rspamd/rspamd/pull/5602))</li><li>DKIM relaxed body canonicalization; improved reliability in multimap, WebUI, and configuration. ([#5593](https://github.com/rspamd/rspamd/pull/5593))</li><li>Numerous minor bug fixes and CI/build improvements.</li></ul>]]></description>
      <category>major</category>
    </item>
    <item>
      <title>Rspamd 3.12.1 - Minor Release with Bug Fixes and Improvements</title>
      <link>https://docs.rspamd.com/changelog/3.12.1</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.12.1</guid>
      <pubDate>Tue, 17 Jun 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.12.1 - Minor Release with Bug Fixes and Improvements</h2><h3>Added</h3><ul><li>**Bayes Classifiers Endpoint**: Add `/bayes/classifiers` HTTP endpoint for better classifier management</li><li>**Enhanced Scheduling**: Further improvements in scheduling next checks for better performance</li><li>**AEAD Testing**: Add comprehensive tests for AEAD (Authenticated Encryption with Associated Data) used in Rspamd</li><li>**Improved Maps Consistency**: Better consistency in cached flag for maps and logging when dealing with Expires</li></ul><h3>Improved</h3><ul><li>**Maps Locking**: Eliminate maps locking for better performance and reduced contention</li><li>**Proxy Headers**: Fix proxy headers duplication and improve header handling</li><li>**Milter Logic**: Do not add log tag header in milter logic for cleaner output</li><li>**Connection Handling**: Do not explicitly add Connection header if it&apos;s already present</li><li>**Attachment Processing**: Treat *.library-ms and *.search-ms attachments as harmful (with proper application)</li><li>**Maps Concurrent Loading**: Fix race conditions in maps concurrent loading</li><li>**Write Operations**: Try to avoid incomplete writes for better data integrity</li></ul><h3>Fixed</h3><ul><li>**Maps Locking Issues**: Eliminate maps locking to prevent deadlocks and improve performance</li><li>**Proxy Headers Duplication**: Fix proxy headers duplication issue</li><li>**Milter Header Logic**: Fix logic error in milter_headers.lua: skip_wanted() function</li><li>**Connection Header**: Prevent duplicate Connection headers in proxy</li><li>**Maps Concurrent Access**: Fix race conditions in maps concurrent loading</li><li>**Incomplete Writes**: Prevent incomplete writes that could cause data corruption</li><li>**Attachment Security**: Properly apply security rules for *.library-ms and *.search-ms attachments</li><li>**Logging Consistency**: Improve consistency in logging when dealing with Expires and cached flags</li></ul><h3>Changed</h3><ul><li>**Maps Architecture**: Eliminate maps locking mechanism for better performance</li><li>**Header Processing**: Improve header processing to avoid duplicates</li><li>**Attachment Handling**: Enhanced security for Windows library and search attachments</li><li>**Scheduling Logic**: Improved scheduling algorithm for next checks</li><li>**Logging Format**: Better consistency in logging output and cached flag handling</li></ul><h3>Security</h3><ul><li>**Attachment Security**: Enhanced protection against potentially harmful Windows library and search attachments</li><li>**Header Validation**: Improved header validation to prevent duplication and injection</li><li>**Write Protection**: Better protection against incomplete writes that could lead to data corruption</li></ul><h3>Performance</h3><ul><li>**Maps Performance**: Eliminate maps locking for better concurrent access performance</li><li>**Scheduling**: Improved scheduling algorithm for better resource utilization</li><li>**Header Processing**: Optimized header processing to reduce overhead</li></ul><h3>Testing</h3><ul><li>**AEAD Testing**: Comprehensive tests for AEAD encryption used throughout Rspamd</li><li>**Maps Testing**: Improved testing for maps concurrent loading scenarios</li><li>**Header Testing**: Better testing for header processing and duplication scenarios</li></ul>]]></description>
      <category>minor</category>
    </item>
    <item>
      <title>Rspamd 3.12.0 - Major Release with Enhanced Features and Security Improvements</title>
      <link>https://docs.rspamd.com/changelog/3.12.0</link>
      <guid isPermaLink="false">https://docs.rspamd.com/changelog/3.12.0</guid>
      <pubDate>Tue, 10 Jun 2025 00:00:00 GMT</pubDate>
      <description><![CDATA[<h2>Rspamd 3.12.0 - Major Release with Enhanced Features and Security Improvements</h2><h3>Added</h3><ul><li>**GPT Module Enhancements**: Add Ollama support and token usage logging for OpenAI-compatible APIs</li><li>**Fuzzy Storage Improvements**: Add support for separate read and write servers in fuzzy check</li><li>**CDB Maps Support**: Allow CDB files as external maps for improved performance</li><li>**Contextal Integration**: New plugin to integrate with Contextal platform for advanced threat detection</li><li>**Enhanced Logging**: Allow to specify max log tag length for all log messages and log tag in proxy</li><li>**Proxy Improvements**: Add keep-alive support, HTTPS backend connections, and extra headers specification</li><li>**Redis Version Support**: Allow to specify Redis version for compatibility</li><li>**HEIC File Support**: Add HEIC files patterns for better image analysis</li><li>**Enhanced Maps Management**: Show all maps status and output content for all maps</li><li>**File Upload in WebUI**: Add file upload functionality to Test Selectors</li><li>**Convenience Methods**: Add various convenience methods for Lua development</li><li>**Enhanced Fuzzy Operations**: Add deletion of specific fuzzy hashes in WebUI</li><li>**Improved Error Handling**: Better error messages for multimap invalid types</li></ul><h3>Improved</h3><ul><li>**Security Enhancements**: Critical fix to disable macros and file variables by default in lua-ucl</li><li>**Maps Loading**: Fix race condition in maps loading by unlocking backend on switch</li><li>**Greylisting**: Improve body hash calculations for better accuracy</li><li>**Replies Module**: Rework to consider all recipients and use SMTP ones</li><li>**WebUI Updates**: Update Bootstrap, D3 and PrismJS libraries to latest versions</li><li>**Lua Logging**: Major improvements to logging output format and complex table key support</li><li>**Fuzzy Storage**: Better handling of fuzzy lua callback when there are no shingles</li><li>**Milter Headers**: Fix logic error in skip_wanted() function</li><li>**Known Senders**: Improved recipients test logic and consistency with replies module</li><li>**Maps Management**: Grey out not loaded maps in the Maps table for better visibility</li><li>**Redis Integration**: Improve Redis script loading and caching framework usage</li><li>**Configuration**: Use safe parsers everywhere except configuration for security</li><li>**Build System**: Modernize cmake and improve sanitizers support</li><li>**Documentation**: Update API docs for multiple HTTP headers and various other improvements</li></ul><h3>Fixed</h3><ul><li>**Critical Security**: Disable macros and file variables by default in lua-ucl parser</li><li>**Race Conditions**: Fix race condition in maps loading by unlocking backend on switch</li><li>**Memory Issues**: Fix lua-bit stack buffer overflow vulnerability</li><li>**Proxy Crashes**: Prevent crashes when accessing upstream address in self-scan mode</li><li>**Maps Management**: Fix maps IDs and static maps description passing</li><li>**Fuzzy Storage**: Filter invalid domains in fuzzy extra data</li><li>**Redis Integration**: Fix various Redis-related issues and improve script loading</li><li>**WebUI Issues**: Fix Fuzzy hashes card close button alignment and map editor modal handling</li><li>**Build Issues**: Fix Debian package build and RPM log directory attributes</li><li>**Compiler Warnings**: Fix various compile warnings and compatibility issues</li><li>**Test Framework**: Fix various test issues and improve test configuration</li><li>**Documentation**: Fix writing rules tutorial link and various documentation issues</li><li>**URL Lists**: Update default URL for openphish and fix various URL-related issues</li><li>**Headers Processing**: Fix header processing in various modules</li><li>**Lua Integration**: Fix various Lua-related issues and improve error handling</li></ul><h3>Changed</h3><ul><li>**Configuration**: Use safe parsers everywhere except configuration for enhanced security</li><li>**Maps Architecture**: Rework to use locks/loaded per backend for all maps</li><li>**Replies Logic**: Consider all recipients and use SMTP ones instead of just From header</li><li>**Fuzzy Storage**: Remove servers completely and use `read_servers` for all compatibility</li><li>**Logging Format**: Improve format string processing and logging output</li><li>**Build System**: Modernize cmake and rework OSDep for better maintainability</li><li>**WebUI**: Rework file upload JS implementation and improve user interface</li><li>**Documentation**: Update various documentation files and improve API documentation</li><li>**Error Handling**: Improve error messages and handling throughout the codebase</li><li>**Performance**: Various performance improvements in maps, Redis, and fuzzy operations</li></ul><h3>Removed</h3><ul><li>**Deprecated Features**: Remove various deprecated and unused code paths</li><li>**Unused Helpers**: Remove unused cleanTextUpload helper and other obsolete code</li><li>**Legacy Support**: Remove compatibility code that is no longer needed</li></ul><h3>Security</h3><ul><li>**Critical Fix**: Disable macros and file variables by default in lua-ucl parser</li><li>**Memory Safety**: Fix lua-bit stack buffer overflow vulnerability</li><li>**Input Validation**: Improve input validation and error handling throughout</li><li>**Configuration Security**: Use safe parsers everywhere except configuration</li></ul><h3>Performance</h3><ul><li>**Maps Loading**: Optimize maps loading with better caching and race condition fixes</li><li>**Redis Operations**: Improve Redis script loading and caching framework usage</li><li>**Fuzzy Storage**: Better handling of fuzzy operations and storage management</li><li>**Memory Usage**: Various memory optimization improvements</li></ul><h3>Documentation</h3><ul><li>**API Documentation**: Update API docs for multiple HTTP headers and various modules</li><li>**Tutorial Links**: Fix writing rules tutorial link and other documentation references</li><li>**Examples**: Add more examples for Lua HTTP and other modules</li><li>**Configuration**: Improve configuration documentation and examples</li></ul><h3>Testing</h3><ul><li>**Test Framework**: Fix various test issues and improve test configuration</li><li>**Fuzzy Tests**: Add comprehensive tests for split, read-only, and write-only server modes</li><li>**CDB Maps**: Add tests for CDB maps functionality</li><li>**HEIC Recognition**: Add tests for HEIC file recognition</li></ul>]]></description>
      <category>major</category>
    </item>
  </channel>
</rss>