Skip to main content
🐛 Bug Fix Release

Rspamd 4.2.2

Patch Release with Multi-Currency Wallet Detection, MIME Email Alerts and a lower_utf8 Heap Overflow Fix

🛡️ Security

  • 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'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

🔄 Changed

  • 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

✅ Added

  • 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:
  • 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.
  • 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.
  • 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.
  • 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.
  • 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)
  • Bounded wallet scanner that finds split addresses: The scanner stays within fixed limits on hostile input. These are exposed as lua_crypto_addresses.limits:
  • a budget of checksum decodes per scan and per part;
  • a cap of 1024 addresses;
  • text searched in overlapping 64 KiB slices, so the match table stays bounded for any body size.
  • 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)
  • 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:
  • expanded addresses that do not parse are dropped instead of sent;
  • values substituted into headers are collapsed to one line, so CR/LF in a value cannot add a header such as Bcc;
  • placeholders are expanded in a single pass, so message text that looks like ${...} is never compiled as a selector.
  • Multipart email alerts from email_parts: email_alert can now build a multipart message, so email_template no longer needs a hand-written boundary.
  • 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.
  • Template body: the template's own body becomes the first part and keeps the Content-Type and transfer encoding it declares.
  • Transfer encodings: these are chosen defensively. Content that is not safe as declared is sent as quoted-printable or base64.
  • Filenames: long or non-ASCII filenames are RFC 2231 encoded and split across continuation parameters.
  • 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)
  • 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)
  • 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)
  • EHLO and 8BITMIME in lua_smtp: lua_smtp, which sends metadata_exporter'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:
  • 8-bit text parts are re-encoded as quoted-printable;
  • other parts are re-encoded as base64;
  • nested message/* entities are converted recursively.
  • 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)
  • smtp newline normalisation mode: normalize_newlines("crlf") 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 <CR>.<CR><LF> 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)

🔧 Fixed

  • 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)
  • 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' 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)
  • 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<ZWSP>pal.com became https://paypal.comcom. This broke the phishing check for redirected links
  • 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<WJ>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:
  • Invisible characters after the hostname end the URL without a flag.
  • A variation selector after a non-ASCII character, as in an emoji, is not flagged.
  • Invisible bytes do not count toward the hostname length limit; the raw hostname is limited to 1012 bytes.
  • 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
  • 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'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:
  • 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.
  • Prose such as "the dot food co-op" no longer counts as a spelled-out dot.
  • Spaced protocols such as h t t p s : / / are handled.
  • Complete zero width sequences are stripped rather than individual bytes.
  • The symbol is now one-shot (#6291)
  • 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 "password", 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 "wallet" is no longer a scam signal for any currency, Bitcoin included, because every deposit, withdrawal or payment notice that names an address also says "wallet". A withdrawal confirmation to a Tron or Bitcoin address used to score 9.20. "password" and the sextortion phrases remain signals (#6294)
  • The MIME header encoder emits valid RFC 2047 encoded-words: rspamd_mime_header_encode produced output that decoders misread:
  • An encoded-word was glued to the ASCII part of its word: Pr=?UTF-8?Q?=C3=BCfung?= decodes as "Pr üfung".
  • Adjacent encoded-words had no whitespace between them.
  • Structured mode encoded every ASCII special even in 7-bit values, so "John via list a@b.c", as produced by DMARC From munging, lost its address.
  • 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 <CR>.<CR><LF> 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)
  • 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)
  • 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'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)
  • 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 "attempt to index a string value". It now uses the absolute index of the result table (#6284)
  • 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)
  • 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
  • replies and known_senders honour reply_sender_privacy: The obfuscation calls used settings.sender_prefix, so the reply_sender_privacy* options were ignored
  • 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
  • fuzzy_check reads html_ignore_domains at rule level: html_ignore_domains was only read inside a rule's checks block. Set directly in the rule, as html_weight can be, it was ignored
  • metadata_exporter always sends an integer MAXLEN to Redis streams: The MAXLEN argument for redis_stream is now always formatted as an integer (#6292)
  • 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 "unknown element in actions section"
  • 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

This patch release fixes a heap overflow in `lower_utf8` that could be reached from attachment file extensions, and a worker crash caused by late HTTP error callbacks after keepalive was declined. Cryptocurrency detection now covers twelve currencies instead of Bitcoin alone, with independent symbols per currency, a bounded scanner that also finds split addresses, and a `crypto_addresses()` selector. The scam composite only trusts checksum-verified addresses. metadata_exporter email alerts gain custom variables, multipart bodies, automatic header encoding and schema validation. The SMTP client gains EHLO, 8BITMIME with a 7-bit fallback, and correct dot-stuffing. Fixes to HTML table layout, invisible characters in URLs and URL_OBFUSCATED_TEXT remove several sources of false positives. Before upgrading, note that the MIME parser no longer includes the line break before a boundary in a part, which changes the message digest and the digests of non-base64 parts in multipart messages. Recommended upgrade for all users.