Skip to main content
🐛 Bug Fix Release

Rspamd 4.2.1

Patch Release with HTTP Memory Safety Fixes, Fuzzy Key Hardening and More Reliable Maps

🛡️ Security

  • 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'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)
  • Zero-copy HTTP reads kept inside the message body: A zero-copy read lands in the message'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)
  • 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'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)
  • 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
  • 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)
  • 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)
  • 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

🔧 Fixed

  • 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
  • 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)
  • 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 "ssl connection timed out" (#6274)
  • 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'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)
  • Failed map reloads keep the previous map: Several paths could replace a working map with nothing or with partial data:
  • 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.
  • When the last backend failed, the consumer was not told and installed partial data in place of the map. The failure is now reported.
  • 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.
  • HTTP validators are restored when a response cannot be decoded or cached, so the next conditional request does not skip the update.
  • The controller's /savemap now writes the whole map; a short write used to replace the map with a truncated one.
  • 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
  • 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
  • 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
  • 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 "cannot send SCAN request" 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)
  • DNS resolver TCP queue, reply handling and retransmits fixed:
  • Queued TCP packets were not counted on enqueue, so the counter underflowed and kept the write watcher armed on an empty queue.
  • The last byte of a TCP packet was dropped. A packet is now written only with its length prefix and all of its payload.
  • 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.
  • 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.
  • 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.
  • 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'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
  • 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
  • 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
  • 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
  • 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'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
  • 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
  • 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
  • url_redirector keys by the raw URL, caps whole chains and resolves relative Location:
  • 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.
  • A whole chain, cached hops included, is now capped by max_chain_length (16).
  • 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.
  • RBL whitelists work for IPv6 addresses: RBL options have the form <element>:<label>, optionally followed by :<dns reply>. 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)
  • 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)
  • 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)
  • rar, 7z and libarchive parsing hardened:
  • rar4 parsing checks that the block type byte is present before reading it.
  • The 7z names property is bounded by its declared size before the external flag is read and the names are scanned.
  • Each 7z streams info section is accepted only once; a repeated substreams section allocated a new folder-sized array each time.
  • 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.
  • 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.
  • 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
  • HTML link text bounded, JSON attachments no longer sniffed as HTML: An <a> that is never closed spans the rest of the part. With N of them, each link'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 </a> as <\/a>, 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, #6270)
  • 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)
  • Quoted HTML attributes and entity replacements bounded: Each > 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; &nGt; and similar entities used to overwrite text not yet read and stop decoding. Numeric references now saturate instead of overflowing an int
  • 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
  • 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
  • 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
  • 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'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 "cannot insert object with no key". So {"b": ""} did not parse while {"b": "", "c": 1} did. The key is now carried over when the tail finishes a map value
  • Mailchimp exempt from SUBJ_EXCESS_QP and REPLYTO_EXCESS_QP: Mailchimp'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)

This patch release closes memory safety defects in the chunked HTTP path. A peer-chosen chunk size could overflow the heap, and zero-copy reads could walk into a freed body. It also fixes fstring length wrapping and an uninitialised shared secret used to decrypt fuzzy requests sent to a low order key, and it tightens fuzzy storage by bounding the TCP backlog and enforcing key expiry for writes and deletes. Maps are more reliable: large chunked HTTP maps no longer pick up trailing garbage that persisted in their cache, failed reloads keep the previous map, and HTTP cdb maps read correctly from the cache. Parsing of DNS replies, archives, HTML, CSS and msgpack is hardened further. RBL whitelists now work for IPv6 senders, SPF `a` and `mx` get correct default prefixes per address family, and the fuzzy count scan introduced in 4.2.0 works over Redis Unix sockets. Recommended upgrade for all users, especially those running a fuzzy storage reachable by untrusted clients.