Skip to main content
🚀 Major Release

Rspamd 4.3.0

Major Release with SMTPUTF8 Address Checks, Separate URL Mismatch Scoring, Browser-Accurate HTML, CSS and URL Parsing, and Hardened MIME, Archive, SPF, DKIM and ARC Handling

🛡️ Security

  • An HTTP header line without a colon no longer aborts the worker: The bundled http_parser allowed a CR or LF to end a header field name. If such a field was the last header line, it reached the header handler with no value. Its value length then wrapped to SIZE_MAX, and the worker aborted. One unauthenticated request was enough. Any other line of this kind was joined to the next field name, so X\r\nFoo: bar became XFoo: bar. The parser now refuses a header line without :, as upstream http-parser and nginx do
  • An empty Deliver-To no longer aborts the worker: An empty Deliver-To header or query argument hit an assertion that aborted the worker. Any client could therefore keep the workers crashing. An empty value now keeps the previous value, and the brace stripping checks the length before reading
  • Milter session use-after-free when the upstream fails: For milter connections, the proxy's error path released the session of the MTA connection one time too many. This happened when the retry could not select an upstream. That is always the case with a single upstream (the default hosts = "localhost") and the default token bucket rotation. The worker then either crashed with SIGSEGV or stopped calling accept(). In the second case the listen backlog filled up and MTA connections stayed in CLOSE_WAIT. Because heartbeats_loss_max defaults to 0, the worker could stay in that state indefinitely. Releases since 4.0.0 are affected, including 4.2.3. 3.14 is not affected. A failed milter message is now completed the same way as a scanned one:
  • the connection moves to a fresh proxy session and gets exactly one TEMPFAIL;
  • the milter transaction is reset, and the old session is released once;
  • the milter library owns the transport, and QUIT, EOF, I/O errors, timeouts and protocol errors all close it through one path.
  • Milter session use-after-free on a second end of body: A second end-of-body command sent before Rspamd replied to the first one started another scan on the same proxy session. Whichever scan finished first freed the session while the other was still using it. Any client of the milter socket could trigger this. It is now a protocol error and closes the connection. Only the reply ends a pending end of body. An ABORT does not
  • Milter commands and ESMTP arguments are no longer read out of bounds or after free: CONNECT read the address family and the port without checking that the command contained them. MACRO looked for the end of the macro name beyond the end of the command. The proxy's self-scan task also borrowed the MAIL and RCPT ESMTP argument tables of the milter session. Milter frees these tables on ABORT or on the next MAIL, which can happen while the scan is still running. The task now holds its own references, and the arguments of one message are no longer passed on to the next message
  • lua_http error handler no longer runs on freed data: When a reply completes, a keep-alive connection goes back to the pool and its parser restarts. Bytes that followed the reply in the same read were then reported as an error of the finished request. So was the end of a stream that ends a reply with no length. In both cases the lua_http error handler ran on freed data. Data after a delivered reply is no longer an error of that request, and such a connection is closed instead of reused
  • Pooled HTTP connections respect TLS verification and the peer address: The key of the idle connection pool did not include TLS verification. A request that required a verified certificate could therefore get a connection that was made with no_ssl_verify. The key was a host name, and a Host header can set it. A request could therefore go over a connection to a different address, and forbid_local was checked against an address other than the one actually used. Both are now part of the pool key. Other pool fixes:
  • a connection is checked for pending data and end of stream when it is taken from the pool, so a late reply is no longer read as the reply to the next request;
  • interim replies (100, 103) are skipped, and the final reply is read after them;
  • the idle time that a server asks for is capped at 300 s, 128 idle connections are kept per key by default, and close in any Connection header ends the connection;
  • limits, timeouts and the HTTPCrypt peer key of one request no longer carry over to the next request
  • Encrypted HTTP requests cannot be answered in plain text: A client also parses a reply that arrives while it is still writing the request, such as an early 413. On a fresh connection, that reply did not have to be encrypted. Anyone on the network path could therefore answer an encrypted request in clear text. The connection is now marked as encrypted as soon as an encrypted request is written. Every matching Key header also replaced the peer key of the message without releasing the old one. On a client, this meant that a Key header in the plaintext reply headers could replace the key used to decrypt the reply. A Key header is now ignored once the message has a peer key
  • forbid_local in lua_http covers all local and special-purpose addresses: The option refused loopback, link-local and the networks in local_addrs. However, 0.0.0.0 and :: were allowed, and a connection to either reaches the local host, where the controller trusts 127.0.0.1 and ::1. The option now also refuses:
  • the rest of 0.0.0.0/8, the shared space 100.64.0.0/10, 192.0.0.0/24 and 198.18.0.0/15;
  • IPv4 multicast and the reserved top of IPv4;
  • unique local and multicast IPv6;
  • any of these, or of the local ranges, written as an IPv4 address inside IPv6, either mapped or behind the NAT64 prefix.
  • Out-of-bounds recipient index in X-Rspamd-Rcpt-Esmtp-Args: The recipient index was converted with strtol into an int without a range check. A negative index read and wrote before the start of the per-recipient array, and a large index grew the array to billions of slots. Only digits are accepted now. The index is bounded by max_recipients, or by 1024 when there is no configuration. The same bound applies to rcpt_esmtp_args in the v3 protocol. Milter passes ESMTP keywords without a value, such as SMTPUTF8 or REQUIRETLS, as bare keywords in these headers. The worker used to drop them and now keeps them
  • DKIM keys of an undeclared type no longer abort the worker: The RSA and ECDSA verification helpers asserted that OpenSSL accepts the key. A DKIM record whose key does not match its declared type aborted the worker. Examples are an EC key under k=rsa or an Ed25519 key under k=ecdsa256. Such a key now fails verification. The helpers also leave no errors on the OpenSSL queue, and the Ed25519 helper checks that the key has a raw public key
  • ARC forwarders are trusted only for what they vouched for: The seal of a whitelisted forwarder was trusted at any position in the chain. Seals cover only the earlier ARC sets. Anyone could therefore add a set after a genuine one and soften a DMARC reject. Stricter rules now apply:
  • a forwarder is trusted only when every later instance, up to the newest one, is also whitelisted and verified;
  • the newest ARC-Message-Signature must verify on its own, not only through the whitelist;
  • the DMARC adjustment requires the forwarder's own dmarc=pass with a header.from equal to the From domain. Quotes, angle brackets, a trailing dot, IDN and case are normalised before the comparison;
  • a result counts only from the element of its own method, and an ARC-Authentication-Results header with two DMARC results proves nothing;
  • the sealer is the d= that the C code verified
  • ARC-Authentication-Results are parsed from the raw header value: arc.lua parsed ARC-Authentication-Results after decoding encoded-words. An encoded-word in a comment, such as a sender address that a forwarder wrote there, could close the comment once decoded and add a dmarc=pass that softened a DMARC reject. Results are now read from the raw value, and Authentication-Results reused for a seal are copied unchanged. A trusted forwarder that writes the sender unescaped into a comment could also let that text open a quote or a comment, which then hid the forwarder's own dmarc=fail. A DMARC result is now treated as ambiguous, and is not trusted, in two cases. The first is when splitting the header on every ; finds more DMARC results than the split that respects quotes and comments. The second is when the header was too long to read
  • Sender text is escaped in Authentication-Results: The sender's MAIL FROM and HELO were written unescaped into a comment of the Authentication-Results header, and the reader closed a comment at its first ). A sender could therefore end the comment and add a result of their own to the Authentication-Results and ARC-Authentication-Results headers that Rspamd writes, for example dmarc=pass header.from=bank.example. Comments are now escaped, \ is escaped in quoted values, and control characters become ?. The reader honours quoted-pairs in comments, unquotes quoted values, and records the method of each element
  • Empty DKIM tag-specs are skipped, as arc.lua skips them: The C parser treated the ; of an empty tag-spec (;;) as part of the next tag name, while arc.lua splits the header on ;. A seal that ended in ;;d=trusted.example was verified with the key of the first d=, while arc.lua took the sealer to be trusted.example. Empty tag-specs, and tag-specs without =, are now skipped
  • MIME parsing no longer stops at an empty part: A part that consists only of a line break (--b, an empty line, --b) is empty. The parser treated such a part as an error that ended parsing of the whole message. No part after it was parsed or scanned, at any nesting level, and nothing was flagged. Such a part is now skipped. The leftovers pass also continues after a garbage segment

🔄 Changed

  • URL mismatches are scored separately from phishing: The phishing module used to fold a mismatch between the URL shown for a link and its real destination into the phishing result. It now reports such mismatches separately from phishing scoring. Link rewriting by trusted services is handled with explicit trust maps (see Added). The stock configuration loads rewrite permissions from dynamic maps, trusts selected security URL wrapper hosts, and keeps the scores for the mismatch rollout conservative. This changes scoring: review local scores and rules that build on the phishing symbols (#6335)
  • Symbol options are stored verbatim and encoded only when joined: Options are now stored exactly as they are inserted. Only invalid characters are replaced, with U+FFFD. Regexps, composites and multimap therefore see the original values. Some outputs join options into one string: X-Spamd-Result, X-Virus, metadata_exporter, ClickHouse and the symbols log line. They percent-encode an option only when it contains one of []{};,, and in that case also encode %, so the joined form can be decoded without ambiguity. Several plugins used to join multiple values into one comma-separated option: asn, reputation, rbl, dmarc, content rules and MULTIPLE_UNIQUE_HEADERS. They now insert each value as a separate option. Rules and external tools that parse these options, or the joined headers, may need changes (#6344)
  • SPF records are evaluated in the order of their terms: The elements of a flattened record were sorted with a comparator that did not define a valid order. As a result, the first match was not the first matching term. For example, -ip4:1.2.3.4 +ip4:1.2.3.0/24 -all let 1.2.3.4 pass, and so did -all ip4:1.2.3.4. The order of the record is now kept. An include was copied in with the qualifiers of its own terms, and the include's own qualifier was dropped. So -include:x passed whatever x passes, and a fail inside x became a fail of the outer record. A match inside an include now takes the include's qualifier when the included record passes. Otherwise, evaluation continues after the include, as RFC 7208 specifies. A redirect is applied after all mechanisms, wherever it appears in the record. This changes SPF results for records that relied on the old order. In Lua, elements that only end an include carry a jump field
  • Stricter DKIM signatures and keys: Signatures and keys that relied on the following cases now fail verification:
  • a known tag that appears twice makes a signature invalid (RFC 6376 section 3.2). Previously the last value won;
  • l=0 was treated as if no l= were present. It means that no body is signed, so such a signature is no longer valid for a message that has a body;
  • RSA keys shorter than 1024 bits are not valid (RFC 8301);
  • a key is accepted only if it has the type that k= declares: an RSA key, or an EC key on a named P-256, P-384 or P-521 curve;
  • tags of a key record are matched by their whole name, so xp= is no longer read as p=
  • DKIM d= domains are lowercased: Domain names are case-insensitive, but an ASCII d= tag was stored in whatever case the signer used. Results, symbol options and case-sensitive matchers such as glob maps all saw that case. A signer could therefore evade a glob-based lookup just by changing the case of d=. The parsed domain is now lowercased. Non-ASCII domains were already folded by IDNA conversion. Verification is not affected, because it hashes the raw header. Maps and rules that list mixed-case d= values need lowercase entries (#6350)
  • ASCII SMTPUTF8 syntax violations have their own symbols: With the new SMTPUTF8 address checks, INVALID_FROM_8BIT and INVALID_RCPT_8BIT (6.0) fire only for addresses that contain 8-bit characters, which matches their names. An all-ASCII address can still break draft-ietf-mailmaint-smtputf8-syntax, with an A-label atom in the local part or a control character. Such addresses get the new INVALID_FROM_ASCII_ADDR and INVALID_RCPT_ASCII_ADDR symbols, scored 1.0. These addresses are valid under RFC 5322, and the draft still lists its A-label rule as an open issue, so this is weak evidence on its own (#6345)
  • Multipart children without Content-Type are read as text: RFC 2046 makes text/plain the default for a body part without Content-Type. However, such a child got no text flag, and only lua_magic's heuristic decided what it was. One or two bytes outside ASCII and UTF-8, such as a typo or a legacy charset, hid it from all text checks. It now gets the same default as a message without Content-Type: it is text, and it is marked broken when it holds 8-bit data that is not UTF-8. In a multipart/digest, the default is an embedded message. The default applies only to data that is not binary. A child is not text if control characters appear in more than 1/32 of the first 4 KB of its decoded data. Executables and archives are therefore no longer read as garbage text. A child without headers now starts after the empty line that ends the headers, so lua_magic sees its data from the first byte. Short-text fuzzy hashes of headerless children change. Each child of a digest without Content-Type counts as two parts against the parts limit
  • get_files(n) and get_files_full(n) count files, not names: Rules read only a limited number of archive names. CHECK_ARCHIVES reads the first 10, and mime_types, multimap and the scanners read 1000. A zip lists the other names of a file (Unicode Path) right after the file, and lists the names of its local headers after its directory. Names could therefore crowd out files: nine harmless names of a first file pushed a second payload.exe out of CHECK_ARCHIVES. The functions now read the first n files of the directory and the first n files of the local headers. Each file is followed by its other names, flagged as aliases (RSPAMD_ARCHIVE_FILE_SAME_ENTRY, alias in get_files_full). An entry has at most 9 such names, so reads stay bounded. Some rules count the files of an archive, such as SINGLE_FILE_ARCHIVE_WITH_EXE and the single-file checks of mime_types. These rules skip aliases, while extension checks still see them
  • archive.unpack is bounded by default: All limits of archive.unpack were off unless set explicitly. Without options, output is now limited to 1 GiB and to one million headers. Zero still means no limit. Only filters that libarchive implements itself are used. "All" filters used to include filters that run an external program on the data (grzip, lrzip, lzop). A numeric key in files was converted to a string in place while the table was being walked, which breaks lua_next. This is also fixed
  • inflate() in the rspamd_util Lua module returns errors instead of logging them: The function logged an error for every stream it could not read. Such streams come from the files being parsed, and a single PDF can contain thousands of them if the sender wants it to. The error is now returned as a second value, for the caller to handle
  • url:get_redirected() and url:get_phished() are now distinct: get_redirected was an alias of get_phished, and both returned the linked URL in every case. A redirect target was therefore reported as the phished URL, and the text of a link as a redirect target. Each function now answers according to the flags of the URL. A redirect that is set later replaces the shown URL, so such a URL has no phished URL
  • The image flag is set only on URLs seen exclusively as image sources: A URL that appeared both as a link and as an image source, in either order, got the image flag. task:get_urls() leaves images out by default, so an image with the same address as a link hid that link from rules. The flag now stays only while every occurrence of the URL is an image source, and such links are returned by get_urls()
  • A missing Date is treated as no date: task:get_date{format = 'message'} returns 0 when the Date header is missing or invalid. The time('message') selector returned 1970 for this value. The spamassassin plugin's check_for_shifted_date read it as a date far in the past, so rules like DATE_IN_PAST_96_XX matched mail without any Date header. Both now return nothing, as SpamAssassin does. More dates are now rejected as invalid (see the date parser fixes below), so this case occurs more often
  • PDF text extraction reads content streams as a PDF reader does: A content stream is now read as a whole, as operands and operators. Previously a regexp cut it into BT..ET blocks. Several cases ended a block's text: unknown operators, marked content, pattern colours, inline images and a stray operand. Other cases lost the whole block: ET inside a string, BT at the start of a stream, 32 KB blocks, and, without Hyperscan, CRLF line ends. Other changes:
  • a font set outside a text object is honoured;
  • pages are taken from the page tree in document order, with resources inherited down the tree. Previously, any object that called itself a page was read in file order, and orphan pages used up the text budget;
  • shared streams are parsed only once;
  • moves of the text position (Td, Tm) produce a space or a line break depending on the distance, so words placed one by one are no longer glued together;
  • the text of form objects that a page draws is read;
  • a page that reads as garbage still keeps the lines that read as text, and zero-width characters are not counted;
  • bytes drawn in invisible mode are counted in flags.invisible_text.

✅ Added

  • SMTPUTF8 address syntax checks: Addresses are now checked against draft-ietf-mailmaint-smtputf8-syntax. This rule set is much finer-grained than RFC 6532, for example for bidirectional text. Details:
  • the original MIME senders and recipients are checked, before alias expansion, and bare quoted local parts are preserved;
  • an A-label is recognised only when the whole atom round-trips through a valid IDNA2008 U-label;
  • joining, normalisation and IDNA properties are generated together with the PRECIS table, so builds with older ICU give the same verdict;
  • the cost of the checks is bounded.
  • Phishing rewrite trust maps: New explicit trust maps list the hosts that may rewrite links, such as security URL wrappers. The stock configuration loads these permissions from dynamic maps and trusts selected wrapper hosts (#6335)
  • BAD_DKIM_SIGNER multimap rule: The d= of every checked DKIM signature, taken from DKIM_TRACE, is matched against the bad_dkim_signers glob map served from maps.rspamd.com. Failing signatures also match, because a forged signature that claims a listed domain only hurts its sender. Glob entries cover signers that rotate subdomains (#6350)
  • HTML_TINY_PIXEL_TRACKER and the HTML_TINY_PIXEL_PROBE composite: Bounce and address probes typically have no content, and their only images are tiny external tracking pixels, with no links at all. HTML_TINY_PIXEL_TRACKER detects this. It uses the images_tiny_external feature, which the HTML parser already collected but no rule used. Details:
  • length, links, images and URLs are evaluated over all text parts, so a part that carries a pixel cannot outvote informative sibling parts;
  • any attachment part, named or not, and any nameless binary part without a disposition, marks the message as having content;
  • in HTML parts only image source URLs are tolerated. Any other URL or email address, inside a tag or bare, marks content.
  • DATE_FAR_FUTURE_NO_TZ: A Date header without a timezone is read as UTC+14, the earliest instant any sender could have meant. This is done by appending +1400 and passing the result to the regular date parser. The symbol fires when that instant is still more than 48 hours in the future. The detection is conservative: the weekday is not cross-checked (#6359)
  • Symbols for unreadable and truncated archives, and stricter archive name checks: MIME_UNREADABLE_ARCHIVE (1.0) fires when a part starts as an archive of a known format but its file list cannot be read. The old code branch for this case was never reached. MIME_TRUNCATED_ARCHIVE (0.0) fires in two cases. The first is an archive with more names than are checked: 1000, or the per-message names limit. The second is a document package with more parts than are checked. An empty 7z file is not counted as unreadable, and neither is a volume of a split archive. Several name checks were easy to bypass:
  • the extension is now taken the way Windows opens the name: trailing dots and spaces and ::$DATA are dropped, so evil.exe is exe;
  • an archive named x.docx was not checked at all. It is now skipped only when its content really is that kind of document;
  • the parts of OOXML and ODF packages are checked, and a program among them triggers MIME_BAD_EXTENSION;
  • EXE_IN_ARCHIVE looks at the first 1000 names instead of the first 10;
  • a name is checked by its last 1024 bytes, which keeps the checks cheap for long names
  • TEXT_WORDS_TRUNCATED: A long text part is represented by its first words and a sample of the rest. When it is long enough, none of the rest is included. A message also has a word budget shared by all its parts. Nothing indicated that either limit had been applied, so statistics, fuzzy hashes and word rules treated a message built around these limits like any other. The part now carries a flag for each case, and get_stats() of a text part reports words_sampled and words_truncated. The TEXT_WORDS_TRUNCATED symbol (no weight) reports both. The limits themselves are unchanged
  • PDF processing symbols: The parser sets flags when it gives up on part of a file. Rules can now see these flags as symbols:
  • PDF_NO_OBJECTS: the file has PDF markers but no objects;
  • PDF_DECODE_LIMIT: a stream has more filters, or more decoded data, than is processed;
  • PDF_UNDECODED_STREAMS: a filter failed on some streams;
  • PDF_PARSE_ERRORS: some dictionaries cannot be read, or some objects raised an error;
  • PDF_TEXT_TRUNCATED: the file has more text than is extracted.
  • OOXML_INCOMPLETE: An Office document has more parts than are processed (file:truncated), or parts that could not be parsed (file:failed=N). No weight by default
  • SVG_INCOMPLETE: An SVG image has text, links or embedded documents over the limits (file:text, file:urls, file:payloads), or an embedded image that was not read (file:nested=reason). No weight by default
  • The HTML tags limit is harder to reach, and text past it is handled better: A part may hold 8192 tags. Past that limit, the rest of the part was read without any styles, so all of its text counted as visible. Long reply threads reach the limit, and so did 57 KB of deliberately placed comments or empty elements. Changes:
  • comments, declarations and processing instructions have their own budget and no longer use up the element limit. A comment over that budget is merged into the previous comment when only whitespace separates them;
  • a run of identical empty inline elements without attributes is kept as its first element only;
  • past the limit, open elements are tracked as small records, starting from the elements open at the limit and what the stylesheet says about them. Text of an element hidden by its style, by the hidden attribute, or by a rule with a single compound selector goes to the invisible text. Common implied end tags are honoured.
  • archive.unpack options: With ignore_case, the names in files select members regardless of the case of their ASCII letters. This matches how applications match the parts of an OOXML package. filters = false refuses an archive wrapped in a compression filter. The OOXML handler sets it, because a package is always a plain zip
  • rspamadm statistics_dump dump --user: The repeatable --user option dumps only the statistics of the given users from a per-user classifier. The user prefix is built from the classifier prefix pattern (%s, %l, %r/%u), the same way the Redis backend builds it. Membership is checked with SISMEMBER instead of fetching the whole <SYMBOL>_keys set. The emitted _keys lines list only the selected prefixes, so the output can be restored without registering other users (#6343)
  • WebUI libraries updated: D3 is updated to 7.9.2, CodeJar to 5.0.0 and Tabulator to 6.7.1 (#6369, #6360)

🔧 Fixed

  • Milter transactions: A MAIL command without ABORT after an unfinished transaction overwrote and leaked the previous sender, ESMTP arguments, recipients and message. It now aborts the unfinished transaction first, as libmilter does. A repeated CONNECT leaked the previous client address and now replaces it
  • Proxy keepalive sockets are closed: With keepalive set for a backend, the proxy did not close a connection's socket, because the connection might be in the pool. The pool only dropped its reference. Sockets that were never pooled, or that left the pool, therefore stayed open. In this mode connections now own their sockets. A pooled connection taken for a mirror whose message cannot be copied is released
  • Empty HTTP header values: rspamd_http_parse_date and rspamd_pubkey_from_base32 treat a length of 0 as meaning a NUL-terminated string. Header values are not NUL-terminated, so an empty Date, Last-Modified or Key value made them run strlen past the end of the header storage. An empty date is now treated as no date, and an empty key is not decoded
  • Repeated request headers are appended in constant time: Each repeated header walked the whole chain of earlier headers. /checkv3 metadata is not bound by the HTTP header size limit and can repeat a name hundreds of thousands of times. This kept a worker busy for tens of seconds before the task timeout even started
  • Unix socket path in the IP header: Milter and proxy pass a unix socket path in the IP header for local clients. The worker parsed this path as a listen socket definition, so owner= and group= tokens from the client made it look up users and groups. The path is now taken as it is
  • Repeated From, IP and Settings-ID headers: Each repeated From, IP or Settings-ID request header or query argument replaced the previous value without releasing it. The previous value is now released. The last value still wins, and an invalid IP keeps the previous address
  • Heap corruption in idna_convert_utf8() with a pool argument: The Lua method used its userdata as the memory pool, while the userdata only holds a pointer to the pool. The converted name was allocated in that invalid pool, which corrupted the heap and crashed the worker shortly afterwards. The bug needs a domain with 8-bit characters and a pool argument. The ARC module passes a pool when adjust_dmarc is used with a map of signers
  • Lua Redis replies of any type and depth: A reply of an unknown type, such as a RESP3 map, set or double, pushed no value. Inside an array, the next value then went to the wrong stack slot and crashed the worker. At the top level, the callback was never called. Deeply nested arrays overflowed the Lua stack. Every reply now produces exactly one value, with a map returned as its keys and values in sequence. Nesting stops at 32 levels. Other fixes:
  • a connection on which a caller ran SELECT, MULTI, WATCH, SUBSCRIBE or a similar command is closed when it is released. The next user of a pooled connection no longer inherits that database or transaction;
  • add_cmd on a released connection and a request without a command no longer crash, and connect_sync honours no_pool;
  • addresses from Sentinel replies must be addresses or host names, and at most 64 masters are followed;
  • the debug log of server parameters no longer includes the password
  • Redis connection pool: hiredis frees a context when its connection ends. A pooled connection that had been handed out, with no command in flight, had no callback to notify its holder. The pool therefore kept a pointer to freed memory and read it on release. A request made from a coroutine holds its connection until the Lua object is garbage-collected, so a Redis restart or failover in between was enough to trigger this. Such a context is now kept, without accepting commands, until its holder releases it. Other fixes:
  • the pool key hashed the database, user and password concatenated. Database 12, and database 1 with password 2, therefore shared connections. Each field is now hashed together with its length;
  • a failed AUTH or SELECT on a new connection is now logged, and the connection is not used. Previously, commands ran without a user or in database 0;
  • at most ten times max_conns connections to one server are in use at once. A server that did not answer used to get a new connection for every request
  • Redis TLS for script uploads, statistics_dump and fuzzy storage: Some Redis clients dropped the ssl, ssl_ca, ssl_cert, ssl_key and sni settings and connected in plain text, which a TLS-only Redis rejects. This affected SCRIPT LOAD requests, the synchronous script loader, rspamadm statistics_dump and the fuzzy storage Redis backend. They now use these settings (#6347)
  • Redis Sentinel with TLS and credentials: Sentinel now has its own connection settings for credentials, TLS and timeouts. The legacy credential settings keep working. Workers and synchronous tools share the same topology validation. The last valid upstream lists are kept after discovery errors. Connection settings are decoded once for all Lua request builders and for fuzzy storage. TLS settings are now part of the configuration hash. Script uploads no longer depend on a scan task, and waits for script availability are bounded
  • Order of fuzzy storage updates in Redis: Updates of a repeated digest are applied in order. Independent digests are still updated concurrently. Sync batches are serialised (#6320)
  • DNS answers are limited to the question asked: Every record in the answer section was passed to the consumer, regardless of which name owned it. A reply to a TXT query for one name could include a record for another name, which SPF, DKIM or DMARC then read as the answer. Only records owned by the queried name are kept now, plus records owned by a name that an accepted CNAME leads to. A zero byte in a name or TXT record cut the string short for its readers, and a dot inside a label split it in two. Both are now replaced. A TCP frame that announced no data left the channel in the middle of a frame. There is one TCP channel per server, so every later TCP reply from that server failed until restart. This is fixed
  • The DNS TCP channel is kept after a failed first connect: librdns connects a TCP channel immediately. It freed the channel when connect() failed with anything other than EINPROGRESS, and nothing created it again later. Every truncated UDP reply was then delivered as is for the lifetime of the process. SPF therefore failed for any TXT record set larger than the UDP limit. This happens when the resolver is not yet reachable at startup. Examples are "No route to host" from an eBPF service load balancer such as Cilium, or ENETUNREACH while the network is still coming up. The channel is now kept, and the next truncated reply reconnects it (#6355)
  • IDN names in DNS lookups: The resolver converted IDN names with the transitional rules. A host with a sharp s or a final sigma was therefore looked up under a different name than the one the URL parser reports and browsers resolve. Names are now converted without the transitional rules
  • Lua PTR requests: A PTR request for a string that is not an address released a reference it did not hold. Without a pool, the address itself was queried instead of its reverse name
  • Map retries back off by failure class: Every failed map update used to wait poll_timeout * 20 before the next try, which is 100 minutes with the default interval. A map whose first fetch failed because DNS or the source was not ready yet stayed empty for that long. Failures are now classified:
  • transient errors are DNS errors (including NXDOMAIN), connect, I/O and TLS errors, and HTTP 5xx and 408. A map without data retries after 10 s, doubling up to the poll interval. A map with data starts at the poll interval and doubles up to 20 times it;
  • permanent errors are HTTP 4xx and 3xx, and content that is empty, oversized, cannot be decrypted or cannot be decompressed. The retry waits 20 times the poll interval, but a map without data first tries once more at the poll interval;
  • 429 and 503 replies with Retry-After are retried when the server asks, within 2 s and 20 times the poll interval. A 429 without Retry-After counts as permanent.
  • Map retries no longer depend on data expiry: A failed update was scheduled through the expiry branch whenever a next check was set. With two backends, for example, one could reply with a one-hour Expires and the other fail with 429 Retry-After: 30. The next attempt then came 3599 seconds later. A failure now decides the retry time on its own. Retry-After: 0 was treated as a missing header, which turned a 429 into a permanent failure. Zero, and dates in the past, now retry at the 2 s minimum. Three failure paths never reached the scheduler and leaked the periodic timer, so the map was not polled again until restart:
  • a failed connect to a host given as an IP literal;
  • failure to send both DNS requests;
  • a missing DNS resolver
  • SPF term syntax: A slash in a macro value was read as a prefix length after expansion. a:%{l}.example with the local part host/0 therefore passed any address. a//64 gave IPv4 a prefix length of zero, a/65536 wrapped to zero, and garbage after a length was ignored. Such terms are no longer used. Mechanism names are compared in full (+allfoo was read as +all), and so is the version (v=spf10 was accepted). ip4=1.2.3.4 and include=domain are mistakes found in records of legitimate senders, and they are still read as the sender meant them
  • SPF dual prefixes and ip6=: a/24//64 was not read correctly. After the IPv4 length, one of the two slashes was consumed and the other became a bad length. The term was dropped, and the domain fell back to its all. The second slash is now required and consumed, and a/24/64 is not a valid term. ip6= never worked for an address, because the parser found the first colon of the term, which is part of the address, before the =
  • SPF checked address, element and cache limits: Macros and ptr now use the address that the record is checked for. With external_relay, this is not the address of the connection. A flattened record has at most 8192 elements and is cached for a week at most. A negative TTL is no longer accepted as a TTL
  • rspamadm dnstool spf-flatten follows the record order: The tool stopped collecting networks at the first all. With the new ordering, that is the all of the first include. The tool also wrote out the all of each include. It now skips elements that only end an include
  • DMARC records are read to their end: The grammar stopped at the first tag it could not read and dropped the rest. v=DMARC1; x1=a; p=reject therefore published no policy, and spaces around = had the same effect. With two tags of the same name, the result depended on their order, or on a table walk when they differed in case. Unknown and malformed tags are now skipped, and the first tag of a name wins. pct must be a whole number from 0 to 100. nan, inf and negative values used to be accepted. rua is limited to 1024 bytes and must not contain control characters, because it is stored in a key for every message of the domain. Fields of aggregate reports are escaped
  • DMARC alignment for IDN From domains: The From domain of an EAI message is a U-label. MAIL FROM or the DKIM d= may hold the A-label form of the same domain, and the two forms were compared as written. The DMARC module now uses the A-label form of the From domain everywhere: in alignment, in the policy lookup and in reports (#6346)
  • DMARC organisational policy after a lookup that cannot be made: A lookup that could not be made was counted as pending forever, for example for a From domain label longer than 63 bytes. The policy of the organisational domain was then never applied
  • dmarc_report runs no longer collide: rspamadm dmarc_report renamed keys to *_processing but ignored the result, so overlapping runs could send the same report twice. Keys are now claimed with RENAMENX, which refuses instead of overwriting, and a run skips any date or report that another run has claimed. Claimed keys are read from the write server. A lagging replica could return them empty, and the data would then be deleted without being sent. The reporting Redis script hard-coded a two-day expiry. It now uses reporting.keys_expire (#6348)
  • DKIM signatures cover headers added by lua_mime.modify_headers: Headers added through lua_mime.modify_headers were not included in DKIM signatures. When a header that the message already had was modified for the first time, only its first instance was copied into the modified view that DKIM signing reads. A header added next to two existing ones was therefore signed as if there were only two, which broke the signature. Every instance is now kept (#6336, #6312)
  • The DKIM signature header is hashed as RFC 6376 specifies: Relaxed canonicalisation treated \v and \f as whitespace, so these characters could be added to a signed message. Only space and tab are whitespace now. The signature tag was matched as any b= at the end of a tag name. As a result, xb= was left out of the hash and b = was not, which made a valid signature fail. The tag is now found by its whole name. The scan also read one byte past the header, which is past the end of the message when the signature header is the last one
  • DKIM keys are released when the key caches are disabled: With a key cache size of 0, every fetched key leaked, for both verification and signing
  • ARC results are read in any case and version: The method of an Authentication-Results element was lowercased, but the result stayed under the key as written. DMARC=pass or dmarc/1=pass therefore gave no DMARC result. Elements are now read as RFC 8601 method specs (method, version, result), and property names are lowercased. The method is taken after leading comments, which RFC 8601 allows, so (forwarder result) dmarc=pass header.from=... is read correctly. An empty element (;;) no longer hides the results that follow it
  • ARC headers are selected by their i= tag: The header for an instance was the first header that contained i=N; as a substring, so text in another tag or in a comment could select the wrong header. The tag list is now parsed properly. The ARC-Seal header list is sized from i= (3 * i - 1 headers), but it was built before i= was checked, so a seal without i= reserved space for 2^32 headers. i= is now validated first. Instance numbers of 0, or above the ARC limit, are refused, so a value above UINT_MAX is no longer truncated into a valid instance
  • ARC-Authentication-Results are split in linear time: The LPeg split scanned an unclosed quoted string or comment to the end from each quote or parenthesis. A crafted header therefore cost the square of its length to split. A scanner now reads quoted-pairs once and gives the same result. No difference from the old split was found on 450,000 random strings. Headers up to 16 KB are read. Some chains now fail before their ARC-Authentication-Results are parsed:
  • chains with more than 50 sets (RFC 8617);
  • chains with fewer signatures or ARC-Authentication-Results than seals;
  • chains with a header that cannot be parsed, such as one with comments nested deeper than 200 levels.
  • Search for a boundary after the end of a part: A multipart whose boundary is not inside it keeps searching further on. This recovers broken structures: for example, a b4-- part reopens the --b4-- line of its parent. Each such part scanned the rest of the message. The step budget that bounded this could be used up by thousands of cheap parts placed in front. After that, recovery was skipped and the content behind them was not scanned. Candidates are now looked up by hash in an index that is built on demand, and the result equals that of an unlimited scan. 9000 such parts with 90000 candidates take 0.2 s instead of 14.7 s
  • MIME nesting depth is restored after each child: Each multipart, message/rfc822 or promoted .eml child raised the nesting counter and never lowered it again. After 64 sibling containers, the next container was refused as too deeply nested and the rest of the message was dropped. Content placed after enough harmless containers was therefore never scanned. Reaching the nesting limit also ended parsing of the whole message. Now only the content past the limit is skipped, and the message is still flagged
  • Embedded messages are parsed once: The leftovers pass also searched for orphan boundaries in the bodies of embedded messages, which their own parsing runtimes already handle. An orphan boundary in front of a quoted-printable next level made each level parse the next level twice. 14 levels produced 10000 parts, and a 100 KB message took 1.9 GB and 4 s. The outer pass now skips a body when the inner runtime has already searched the same lines for boundaries. An unencoded embedded message also reuses the boundary candidates that its parent found. Previously each level scanned them again and counted them again against the candidates limit
  • Transport padding after MIME delimiters: RFC 2046 allows spaces and tabs after a delimiter, but the parser hashed them as part of the boundary. With padded delimiters and an unpadded close delimiter, all parts of a multipart were treated as its preamble and were not scanned
  • Multiparts inside opaque S/MIME: The content of an application/pkcs7-mime signed-data part was parsed with the boundary candidates of the outer message. A multipart inside it therefore had no parts, and its text and URLs were not scanned. It is now parsed in a runtime of its own. The decoded ASN.1 structures are freed first, so nested S/MIME no longer keeps them at every level
  • Transfer encodings of embedded messages and multipart children: An embedded message is parsed from its decoded data. This covers message/rfc822 parts and attachments promoted to messages. Its parts still inherited the encoding of the parent part and were decoded a second time, into garbage. From a message part, only 7bit and 8bit are now inherited. Similarly, a multipart's own Content-Transfer-Encoding was applied to children that had none. RFC 2045 allows only 7bit, 8bit and binary on a multipart. With base64 on the multipart, readable children were decoded into garbage. With quoted-printable, decoding could open an HTML comment around a link. Only 7bit and 8bit are now passed to children. Base64 is passed only to a child whose whole data is base64
  • Readable text with a file signature or control bytes stays text: A text/plain or text/html part was treated as an image, a document or data without text when lua_magic found a file signature in it. GIF89a at the start made it an image, and %PDF-1.x in its first KB made it a PDF. <?xml, BEGIN:VCALENDAR or BEGIN:VCARD turned text extraction off. Such a part now stays text when two conditions hold. Its data must not be binary, meaning control characters in at most 1/32 of the first 64 KB. It must also not be an attachment named as the detected format. The content modules still read the part, so PDF_* and SVG_* symbols still fire. Text formats (XML, SVG, CSV, iCalendar, vCard) stay text whatever bytes they contain. Binary data that clients still show as text now has its links read as well. Examples are a binary text/plain part, or a headerless child that starts with binary bytes. Exceptions are attachments, parts named as the detected format, and images that the HTML shows by Content-ID. R_BAD_CTE_7BIT ignores the 8-bit bytes of a document that a content module has read
  • Binary text/html is read as HTML: Clients show a text/html part as HTML even when its data looks like a file. Rspamd read the links of such a part from its raw bytes:
  • entities were not decoded, so https&#58;//evil.com was missed;
  • UTF-16 data hid all of its links;
  • links were counted as in text, not as in HTML, which changed the counts that URI_COUNT_ODD checks.
  • UTF-16 without a byte order mark: ICU reads UTF-16 without a byte order mark as big-endian, as Apple Mail does. Browsers and Thunderbird read it as little-endian, which matches most such data. The byte order is now chosen from where the zero bytes fall, and defaults to little-endian. The labels unicodefeff and unicodefffe are read as UTF-16LE and UTF-16BE. Because clients disagree, links in the other byte order are read as well. Padding can therefore no longer select a byte order that hides a link most clients show. A link found only in the other byte order is added. A link found in both keeps the name that the other reading shows for it, and is not counted twice
  • Parts without Content-Type are marked broken correctly: A part without Content-Type that contains 8-bit bytes should be marked broken unless it is valid UTF-8, but the check was inverted. Valid UTF-8 was marked broken and stopped being text, and everything else passed. On a corpus of 4998 messages, this gave BROKEN_CONTENT_TYPE on 5 ham messages, and none after the fix. A message or embedded message without Content-Type is read as text, whatever it starts with
  • Text is no longer treated as an archive with no files: Archives are detected before text. A text part that started with an archive magic became an unreadable archive or a gzip file with no name, and its words and links were not read. A part shown as text is now treated as an archive only if files can be listed from it
  • Boundary, charset, name and filename past the parameters cap: Parameters past the cap of 1024 per header were dropped. Junk parameters placed in front therefore hid the boundary, charset, name or filename that mail clients use. Past the cap, each of these now has its own slots: its plain value, name*=, and each RFC 2231 piece up to 999. The first parameter that matches a slot takes it. Values that only name a charset, or that end the pieces, are also kept for the resolver. After 1024 junk parameters, filename*=utf-8''invoice+AC4-exe; filename*=utf-7'' is therefore invoice.exe, as in Thunderbird
  • RFC 2231 values are resolved as mail readers resolve them: When the first value of a name is name*= or a piece, it is resolved as Thunderbird does: name*= if it decodes, otherwise the pieces joined from 0 up to the first missing one, otherwise the plain value. Pieces used to be joined with any other value of the name, so filename*1=w; filename="a.exe" was read as a.exew. Other rules now follow the readers:
  • piece numbers have no leading zero and go up to 999, and a repeated piece ends the sequence;
  • bytes that do not decode in their charset do not count as a value;
  • escapes decode only % followed by two hex digits, so a+b.exe stays a+b.exe instead of becoming a b.exe;
  • a plain value that comes first is still taken, as Python does.
  • Quoted pairs and empty values in Content-Type and Content-Disposition: Quoted pairs in quoted values are unescaped as mail clients do, so a boundary with an escaped character is now the one clients use. An empty unquoted value (foo=;) stopped the Content-Disposition parser, so attachment; foo=; filename="invoice.exe" had no filename. The parser now continues past it. Resolving RFC 2231 names zeroed an array of 1000 pointers for each name, and a header can hold up to 1024 names. Short chains of values now use a short list instead. Lowercasing a value that gets shorter keeps the new length
  • Header hashing, comments and addresses:
  • the table of message headers is seeded with a random per-process value, so colliding header names can no longer be prepared in advance;
  • a Received clause that failed on an unclosed comment was retried from the next byte, rescanning the rest of the header each time. Parsing now stops there and keeps the clauses read so far. The date is still taken after the first ;;
  • nested comments are balanced when they are stripped from a header. (()) was never closed, and the rest of the value was dropped as a comment;
  • an SMTP address is accepted only if the whole input parses. Previously, a path followed by a space and other text was still taken as the address. A Return-Path with a trailing comment is parsed again without the comment
  • MIME memory leaks: Several items leaked and are now freed:
  • headers of parts that the parser refused because of the nesting or parts limit;
  • parameters of a rejected Content-Type or Content-Disposition;
  • repeated for addresses in Received headers;
  • names of charsets that ICU cannot open
  • Data that fails to uudecode is kept whole: Such data was copied into a buffer sized for decoded data, about 3/4 of the input, so the end of the text and its URLs were cut off
  • Quoted-printable encoder: A space or tab at the end of a line close to the fold limit is written encoded after a soft line break. The line break was then processed again with the space still counted, and it overwrote the last hex digit (=2=20). This corrupted mail that Rspamd rewrites: footers, the 8bit to quoted-printable downgrade in lua_smtp, and metadata_exporter output. In CR mode, a soft break written right before a literal LF was decoded as a soft CRLF, so the LF was lost. No soft break is written there now
  • Charset conversion:
  • the converter cache held only 32 converters, while ICU knows a few hundred. A message that cycled through many charsets reopened a converter for almost every encoded word. All opened converters are now cached, and different spellings of one charset share an entry;
  • ICU's default data has no ISO-8859-16 converter, and only three exact names were routed to the internal table. Names such as iso-8859-16, l10 or ISO_8859-16 therefore failed to convert. Every alias now uses the internal table;
  • when CED guessed a UTF-8 compatible charset, the part was marked as UTF-8 without checking its bytes. Invalid sequences are now replaced, as they are in a part labelled UTF-8
  • SMTP date parsing:
  • impossible days are rejected. The conversion rolled 31 Feb over to 3 Mar, and such a Date now gets INVALID_DATE;
  • zone minutes over 59 are rejected. +0060 was read as one hour;
  • day, month and zone names are accepted in any case, because RFC 5322 makes them case-insensitive;
  • UTC is accepted as a zone, and comments may contain quoted pairs such as \);
  • three-digit years have 1900 added, as RFC 5322 specifies. 049 was read as 2049. Zero-padded four-digit years such as 0102 are read the same way, since mailers that print tm_year with %04d write them
  • Received dates and nreceived_bad_time: On error, the date parser returned (uint64_t)-1, and that value went straight into the Received timestamp. A bad date now gives 0, the same as a missing date. 0 is true in Lua, so in the meta features that neural networks use, a dateless hop added its distance from 1970 to the sum of hop delays. Such hops are now skipped. The token keeps its meaning and position. The lua_meta version and the neural Redis prefix are therefore unchanged, and trained networks are kept. About 7% of tokens change in a sample of about 14000 messages
  • Time conversion before 2000 and leap days: rspamd_gmtime stored days, seconds and years as unsigned values, so any time before 2000-03-01 wrapped around: 0 came out as year 438499967. rspamd_tm_to_time put dates in non-leap years between 1902 and 1967 one day late. It also treated century years not divisible by 400 as leap years. The callers format fuzzy reply times and HTTP dates
  • The HTML tree is built closer to what browsers build: Text that mail clients show was dropped or treated as hidden:
  • text after the end of the body or of the document was lost, and so was text after a head element without a head tag. End tags of body and html now close nothing;
  • start tags now end an open p, li, dd, dt, option, heading, table part or link, within the scopes defined by the HTML standard. A hidden paragraph therefore no longer hides the paragraphs after it;
  • text placed directly in the head is body text;
  • formatting elements that an implied end tag closes are opened again in the following elements, as browsers do. Examples are font, b and i. In <p><font color=white>one<p>two, both lines are white. These elements are tracked until their own end tag, and a table or a cell clears them;
  • a numeric reference to a no-break space gives a space, as &nbsp; does. A three-digit colour without # follows the legacy rules, so fff is #0f0f0f
  • HTML past the tags limit: Past the limit of 8192 tags per part, the rest of the input was appended to the text as is, including markup. Links lost their displayed text and their phishing check, and scripts and styles became text. A few KB of empty tags at the start were enough. The rest is now read as a browser reads it, without creating tags:
  • markup and comments are dropped;
  • links are registered and checked against their displayed text, and a base applies to relative links. These links are also rewritten and listed by get_html_urls, and a repeated link is not parsed again;
  • images get their URLs and alt text, raw text elements keep their rules, and blocks still separate words;
  • an svg element that is open at the limit still makes its links read xlink:href;
  • elements open at the limit are always tracked, so a hidden element left open no longer hides the rest of the part
  • HTML attributes that Rspamd interprets are kept past the limits: The attribute limits are 128 per tag and 65536 per message, and they counted every attribute. Junk attributes in front therefore pushed out href, src, style and similar attributes, and a link after 128 junk attributes was never checked. Interpreted attributes are now always kept, and a repeated one is ignored, as browsers do. Only other attributes count against the limits
  • No character references are decoded in tag and attribute names: scr&#105;pt was decoded into a script element, so the following content was hidden as raw text. For browsers it is an unknown element, and its content is shown. In <a hr&#101;f="good" href="evil">, the destination was taken as good. Names are no longer decoded. After an attribute without a value, the next attribute name lost its first character: a /style gave /style, and a b style gave bstyle. A new name now starts the way it does in browsers
  • HTML link destinations:
  • href is followed only for links and action only for forms. In <a action="good" href="evil">, only good was extracted;
  • src is the destination of iframe, frame, embed, audio, video, source and track elements. Browsers load it and ignore any href on these elements;
  • formaction on submitting buttons and inputs is a link;
  • xlink:href of an SVG <a> without href is followed, extracted, rewritten and listed;
  • URL rewriting no longer replaces the src of a link whose href is empty
  • Links under a base and links with odd characters: With a <base>, every link that was not absolute was appended to the base. <a href="//evil.com/x"> under <base href="https://bank.example/"> was therefore taken as bank.example, out of reach of the phishing check. Resolution now follows the browser rules:
  • a link with its own scheme is absolute;
  • a link starting with two slashes takes only the scheme of the base, and a link starting with one slash takes its host;
  • tabs and line breaks are removed anywhere in a link, and C0 controls and spaces are stripped around it;
  • any number of slashes or backslashes after a special scheme reads as two, so /&#9;/evil.com, \\evil.com and HTTPS:\\evil.com all lead to evil.com
  • Unquoted attribute values and broken markup: In browsers, an unquoted value continues past a quote, up to a space or >. The URL is still the part before the quote, because href=3D"... from undecoded quoted-printable is a relative link. The exception is a quote inside the authority followed by @: href=https://www.paypal.com"@evil.com/ is now taken as evil.com. URL rewriting replaces the whole value. The rewrite policy and task:get_html_urls() receive the whole value too, which may differ from the text that task:get_urls() returns. Tags with broken markup that browsers accept are now rewritten and listed. Examples are <a/href=...>, a stray quote and a missing closing quote
  • Author CSS overrides presentational attributes, and visibility is inherited: The color, bgcolor and hidden attributes took precedence over author CSS. Any author declaration of the same property now replaces them. visibility:hidden was compiled into a hidden display that no descendant could undo. It is now a separate inherited property, so a descendant with visibility:visible is shown
  • Hiding by opacity, offset or clip: Hiding by opacity, by an off-screen position or by a clip was recorded as a hidden display. opacity: 0; display: block !important was therefore read as visible text. These ways of hiding now have their own flag, which no display value resets. A stylesheet rule that sets display on a descendant no longer makes the content of a display:none ancestor visible. Unknown elements now take class, id and universal rules, because browsers render them as inline elements
  • HTML character references: A numeric reference used the replacement of the last named entity with the same code point. For 26 code points, that entity adds a combining mark or a variation selector, so &#62; gave > plus U+20D2. References to C1 controls are now read as Windows-1252, so &#146; is a right single quote. An unfinished entity at the end of the text was copied twice
  • HTML text after many NUL bytes: Each NUL becomes a three-byte replacement character. Text stopped being appended once it was longer than its source, so a run of NUL bytes at the start hid all the text after it
  • LRM and RLM in preheader padding: React Email's <Preview> pads the preview text with U+200E and U+200F, among other invisible characters. Every padding unit counted as two hidden characters, so short previews crossed the HIDDEN_TEXT threshold. These marks are now ignored (#6374)
  • Tiny external images: External images with an explicit zero size now count as tiny, so <img width="0" height="0"> trackers are detected. Sizes come from inline styles and stylesheets through the CSS parser. They follow the cascade, !important, and minimum and maximum bounds, and physical units are converted to pixels. Relative units and functions such as calc() mark the size as unknown. Relative image sources are resolved against the document base, as browsers resolve them. cid: and data: are matched case-insensitively
  • CSS syntax is read as browsers read it:
  • comments do not nest, and they end at the first */;
  • a backslash in a string escapes the next character, and a line break ends the string as a bad string;
  • a malformed or too deeply nested rule is skipped on its own, an empty declaration is skipped, and a declaration with a block in its value is dropped on its own;
  • declarations apply in source order, so shorthands and longhands override each other;
  • rules in @media all, screen or only screen are applied;
  • an escape decodes only into a name character, never into a brace, a colon or a semicolon
  • CSS cascade: An !important stylesheet declaration now wins over normal inline styles and over more specific rules. The flag used to be set on a copy, and for the wrong case. Specificity was a single sum, with 100 per id and 10 per class, so eleven classes outranked one id. Ids, classes and types are now compared in that order
  • CSS tokens and sizing values: Numbers, identifiers and URLs are tokenised as the CSS specification defines them:
  • 1. and 1e+ are not numbers, while +.5 is, and long runs of digits are no longer cut;
  • identifiers may start with -;
  • only url creates a URL token, so a ; inside urlx(...) does not end a declaration;
  • function tokens own their parentheses, so calc((1px) + (2px)) is one component.
  • CSS colours: hsl() without saturation turned every grey into black, so white-on-white text written this way was not detected. A hex colour with a digit that is not hex is not a colour, and the four-digit form with alpha is now accepted. color and bgcolor attributes are read in any case, with spaces around the value and without #
  • CSS parsing cost: A property declared twice in one block was merged with a nested loop over both value lists, so a few style attributes could take seconds to parse. A single pass now gives the same result. A token that was pushed back to the tokeniser was counted twice against the tokens limit, so the limit was reached at about half its value
  • The URL host is the one a browser goes to:
  • with several @ signs, the host starts after the last one in the authority. A backslash ends the authority, so http://a.com\@b.com/ is a.com;
  • in an href, encoded separators (%2F, %3F) in the authority stay encoded, so http://a.com%2F@b.com/ is b.com;
  • hosts are mapped with UTS #46 without the transitional rules, as browsers do. A host that grows during mapping is rebuilt instead of losing its registrable domain. A label that cannot be mapped is kept as is and marked as obscured;
  • a byte over 0x7f in the user or password no longer drops the URL, and a host is checked again after percent-decoding
  • Numeric hosts follow the URL standard: http://0177.0.0.1/ was reported as 177.0.0.1 and was not marked as obscured, while browsers go to 127.0.0.1. Octal, hex and short forms are now read as the URL standard specifies. Every form other than plain dotted decimal is marked as obscured, and an out-of-range number is not an address. When an IPv6 host is rebuilt, it keeps its closing bracket and the slash of an empty path
  • URLs that mail clients make clickable: Several forms are now extracted, and the malformed ones are marked as obscured:
  • a scheme glued to the preceding word (Clickhttps://);
  • a single slash or a backslash after http, https and ftp;
  • www. without a scheme.
  • URL scanning cost and bounds: In a token such as sip:sip:sip:..., each scheme scanned the rest of the token for @, so a 200 KB line took seconds. Candidates now share the result of that lookahead. A file:// at the very end of the text made the scanner read past the end. When max_urls is reached, the task now sets the urls_limit_reached variable, so rules can see that the URL set is incomplete. The limit is logged once per message
  • Lua URL methods:
  • url:to_http() percent-encodes control characters and spaces again. Previously, %0d%0a in a path came out as a raw line break. The function also no longer leaks the buffer for URLs with userinfo;
  • url.create with text that contains no URL returned the text itself as a URL;
  • set_redirected raised an error when given such a string;
  • task:inject_url dereferenced a null pointer when its third argument was userdata other than a MIME part;
  • the length of the callto scheme was set to the length of tel
  • task:get_urls() sampling: Over max_lua_urls, each URL was kept with a probability based on the whole set, including URLs that the call filters out. Adding email addresses to a message could therefore make its links disappear from rules. The URLs that pass the filter are now counted first, and exactly the limit is drawn from them. The draw uses a per-process seed, so a sender cannot predict which URLs are kept. The plain array form of protocols was ignored, sort = false still sorted, and the comparator returned a number, which table.sort does not accept
  • url_redirector:
  • a chain that ended in an error, a refused request or a status other than 200 was cached as resolved for a day. It is now cached for error_expire (10 minutes);
  • a scan that found another scan resolving the same URL gave up, so only the first copy of a mailing got the target. It now waits up to lock_wait (3 seconds) and then resolves the URL itself;
  • the new URL_REDIRECTOR_LIMIT symbol reports how many redirector URLs exceeded max_urls and were not followed;
  • Location is read as browsers read it: backslashes count as slashes, and tabs and line ends are removed. Two Location headers no longer leave the chain unfinished, and a Location written into a symbol option is cut at 128 bytes
  • Zip directories that readers use: The end record was searched for only 1 KB back from the end, so an archive comment over 1 KB hid every file name. It is now searched 64 KB back, and the last usable record is taken. Several other layouts are now listed:
  • zip64 records reached through their locator, both the zip64 and the 32-bit directory when they differ, and a directory offset of 0xffffffff without zip64 records, as written by zip -fz to a pipe;
  • concatenated zips (cat a.zip b.zip). Both archives are listed: the last one, which Python and unzip list, and the first one, which 7-Zip and WinRAR list. The first archive's entries are walked as 7-Zip walks them;
  • local headers, which bsdtar and macOS use, and which 7-Zip lists when the directory does not name them. Archives whose central directory is cut off or broken are listed from their local headers;
  • Info-ZIP Unicode Path names as each reader shows them (7-Zip, Python, bsdtar, unzip, yauzl). Names are normalised as bsdtar normalises them, at most 9 names are kept per entry, and all are found in one pass;
  • zip names up to their first NUL. Python, unzip and bsdtar show payload.exe\0.txt as payload.exe.
  • Rar archives are followed through unrar, 7-Zip and bsdtar: Readers interpret rar 4 and rar 5 headers differently. Rspamd now follows each reader and lists every file that any of them lists:
  • rar 5 quick open cache: unrar and WinRAR read a header from its cached copy. The archive itself can name a decoy with a bad CRC, where 7-Zip and bsdtar stop. Meanwhile WinRAR lists and extracts the script that the copy names, and a malware campaign uses this. Rspamd now walks the archive a second time, as unrar does. An archive is marked as obfuscated when that walk exceeds 8 times the archive's size;
  • rar 4 Unicode names are decoded as unrar decodes them. Previously only the ASCII name was listed, so invoice.pdf could extract as invoice.exe unnoticed;
  • rar names are listed up to their first NUL, and in the form unrar's UTF-8 decoding gives. For example, invoice\xc0\xaeexe is invoice.exe;
  • names are read only from their own headers, and fields past the end of a short header read as zero. A missing 0x8000 flag no longer makes Rspamd take the next header from file data;
  • files listed before a broken header, a block with no size, or appended bytes are kept. Padding no longer hides an encryption record, and Rspamd follows bsdtar into the next volume
  • 7z names: Names are stored as UTF-16LE at any offset. They are now assembled from bytes instead of read through a misaligned pointer, which also gave the wrong byte order on big-endian hosts. An unpaired surrogate no longer drops the name. It becomes U+FFFD, and the file is flagged as obfuscated. Names in packed headers, the 7-Zip default, failed to convert in the C locale and are now read under a UTF-8 locale. One bad name no longer hides the files after it. A header no longer ends at an unknown property or an empty name. 7z names are checked for control and zero-width characters, as zip names are
  • Gzip header fields in RFC 1952 order: The header checksum flag was read as two bytes before the name, so evil.exe was listed as il.exe
  • Archives not at the start of a part: Unpackers find a zip from its end and open an archive that follows a self-extracting program. Rspamd parsed only parts that start with an archive magic. Some parts are now also searched for a zip end record, or for a rar or 7z magic in the first megabyte: parts of unknown type, programs, and text sent as a file to save. Such a part counts as an archive only if files can be listed from it. A program keeps its type, while mime_types and multimap check the names inside it. A part that starts as a zip, rar, 7z or gzip file but yields no files is flagged as unreadable, which drives MIME_UNREADABLE_ARCHIVE
  • Archive names are hashed with a secret seed: The name tables used g_string_hash or a zero seed, so it was easy to create colliding names. A zip with 16384 such names took a second to list, and the time quadrupled with each doubling. A random per-process seed is now used
  • lua_magic detects file types by position: Some signatures stand at a fixed offset (tar, iso, zoo, ace, djvu). They were searched for anywhere and only checked for position afterwards. With Hyperscan only the first match counts, so the same bytes placed earlier in the file hid the type. They are now compared at their fixed positions, with any engine. Other fixes:
  • the PE header check never passed, so MSCF or @echo off anywhere in a program made it a cabinet or a batch file. The MS-DOS header pointer is now followed;
  • an OOXML package needs [Content_Types].xml and a directory of its kind. Two xl/... members no longer make any zip a spreadsheet;
  • a disk image signature in the last 512 bytes, for example inside a zip comment, no longer overrides the type found at the start of the file;
  • types of equal weight are ordered deterministically, and compound files without a known document stream get the ole type
  • Standalone SVG is validated as a whole document: SVG magic candidates are validated as complete documents, so embedded SVG can no longer suppress HTML parsing and URL extraction. Extra root elements and text outside the root are rejected, and the raw input limit is enforced before conversion. CSS targets are scanned in one forward pass with deadline checks (#6332)
  • Scanners and content handlers use both the name and the content of an attachment: Scanners skip types that are not worth scanning, such as images and text. A file that starts as a GIF or reads as text, but is named x.js or x.hta, is opened by its name. A part named as a different kind than was detected is therefore now scanned. A document whose detected kind differs from its declared type is read by the handler for the detected kind, for example a PDF declared as a spreadsheet. x.jpeg detected as jpg no longer counts as named as another kind, which used to send every such image to the scanners
  • PDF syntax that readers accept: Objects were found only with a line end before the header and after endobj, so files with spaces there had no objects. Whitespace in the header, a missing endobj, and markers inside streams of known length are now handled. Other fixes:
  • dictionaries are no longer dropped for null, hex strings with whitespace, references with other than single spaces, comments, empty names or padding;
  • names are unescaped for any hex digits, so /#4AS is /JS, and string escapes follow the PDF format;
  • actions written in place (/OpenAction, /A, /AA) are processed, and launch actions are detected, including those with only a /Win dictionary;
  • /Encrypt is found whatever follows the key, and in any trailer
  • PDF processing bounds: A stream now takes at most four filters, and the total output of all filters in a file has a budget. 600 stored deflate layers used to cost a quarter of a second and a megabyte each. Other bounds:
  • object streams parse only the pairs that /N declares, and the deadline is checked for each decoded stream. 98 such streams used to run for 6 seconds against a 2-second limit;
  • the text budget counts text written rather than content read. A 30 KB file can no longer produce 14 MB of text, and a long document no longer loses the text of its last pages to graphics;
  • content stream retries are bounded, and a stream that is not read to its end sets the truncated-text flag;
  • errors in ASCIIHexDecode, in arrays starting with %REF%, and in octal escapes over 255 no longer stop processing of later objects. Each object is processed under its own protected call
  • Simple PDF fonts with a ToUnicode cmap: The codes of a simple font are always one byte each. Producers nevertheless declare two-byte codespaces in such cmaps, and the text came out as a few stray letters. The text is now decoded one byte at a time, and codes the cmap does not map fall back to the font encoding. A /Differences array stored as a separate object is honoured, and it no longer counts codes past 255
  • ASCII85 with zero groups: In the ASCII85 decoder of rspamd_util, a z stands for four zero bytes, but the output was sized for five characters per four bytes. Data with many zeroes therefore failed to decode. The decoder also accepts an optional output limit
  • OOXML packages that applications open: Changes:
  • a package was recognised by its first 100 members only. All members are now examined, and an archive named as a document goes to the document handler;
  • part names are matched case-insensitively, relationship targets starting with / are accepted, and part names are built only from validated segments;
  • a story that cannot be parsed is skipped and counted. It no longer takes away the text of the whole document;
  • shared strings are read before sheets, and the parts budget is 1024;
  • packages are no longer searched inside xz or bzip2 streams. A 14 KB message made the worker decompress 48 MB
  • SVG images are read as browsers read them: href takes precedence over xlink:href. Tabs and line ends are removed before a scheme is read, so java&#9;script: is detected. Style sheets of any size are scanned in pieces, whereas 64 KB of padding used to hide an import. Presentation attributes with URLs are scanned too. Text or links over a limit are left out, and the rest of the document is still read, with a flag. An SVG sent with a generic content type is handled by its name, and nested images are read up to three levels deep
  • Calendar and vCard parts: The ATTACH, CONFERENCE, COMMENT, CONTACT and X- properties are now checked for URLs, because applications put meeting links there. A line without a colon was read as the start of the next property's name, and that property's URLs were missed. A megabyte of such lines took 23 seconds to process. Such lines are now skipped. Every line used to be kept as a table for the lifetime of the task. Now the first megabyte is read, 10000 properties are kept, and truncated is set when there is more
  • Language detection: A single letter of Greek, Georgian, Armenian, Thai or kana, or a single ideograph, used to set the language of the whole part. Changes:
  • a script now decides the language only when it holds a fifth of the letters. Ideographs and syllables count four each, and the script with the most letters wins;
  • stop words are counted as they occur in the text, and the coverage of the stop word list only breaks ties. A few dozen added words no longer win for a language with a short list;
  • the result no longer depends on what the worker detected before, so the same text gets the same language in every worker;
  • Hangul, Malayalam and Sinhala are now detected, and Hebrew is mapped to a language
  • Mixed charset detection: R_MIXED_CHARSET is the average badness over the words of a part, so plain words diluted a mixed one: pаypal among eight plain words did not fire. The sum for mixed-script words is now divided by at most 24 words. Letters with diacritics still count as a share of the part, so a long English text with names like Müller no longer fires. R_MIXED_CHARSET_URL converts xn-- hosts to their displayed form before checking them
  • lua_mime.extract_text_limited runs in linear time: The text sent to external services was trimmed with a pattern that took quadratic time on long runs of spaces. No text at all was returned when the first line exceeded the byte limit. Lines are now cut to the limit and trimmed without a pattern
  • Link placeholders in text: Link positions in HTML parts were offsets into the text before zero-width characters and line ends were removed. Each removed character shifted a placeholder over the following words. A zero-width character at the start of a line also became a word of its own
  • max_html_len is written to its own field: The option was registered at the offset of max_word_len. Setting it changed the word length limit and zeroed words_decay, while the HTML limit stayed at its default
  • textpart:filter_words(): The full mode aborted on a part that contains a URL, because the URL's placeholder word has no normalised text. A custom tokenizer no longer moves backwards in the text when two exceptions overlap
  • task:set_recipients() and task:set_from(): set_recipients with the rewrite flag freed the wrong list and leaked the replaced one. set_from overwrote the original envelope sender instead of keeping it next to the rewritten one, and leaked the previous rewrite
  • lua_aliases rejects external maps for equivalent domains: Per-key HTTP or CDB map definitions were accepted for equivalent domains. Lookups are synchronous, so every lookup failed and user-defined equivalent domains were never folded. Such definitions are now rejected with an explicit error at configuration time (#6351)
  • statistics_dump: Unnamed sections of the form classifier { backend = "redis"; ... } are now supported. Glob characters in prefixes are escaped in SCAN MATCH, so a user name containing *, ? or [ no longer matches tokens of other users (#6343)
  • Multipattern matching without Hyperscan: Three problems affected matching without Hyperscan:
  • in Hyperscan builds, every pattern was added to the fallback array, and a CPU that cannot run Hyperscan added it again. Half of the patterns never reached the ACISM trie;
  • ACISM inner nodes linked to leaf suffixes and missed matches. With {abcd, bc, cd}, cd was not found in abcd;
  • the ACISM table ended at the last transition, while lookups read a full alphabet past a state. This caused a heap overread with the MIME boundary trie
  • LRU caches: Aging compared seconds with minutes, so every scan halved every counter, and hot keys were evicted together with cold ones. Entries with a TTL, such as DKIM keys, SPF records and keypairs, were never evicted. A table could therefore grow far beyond its size, for example fuzzy storage fed with random public keys
  • Regexp cache: Past 8192 entries, new regexps were not stored in the cache and were never freed. All regexps are now kept, and exceeding the size only logs a warning
  • mime_expressions: Three early returns leaked the regexp flags. An error from one slashed argument was reused for the next argument, which freed it twice at configuration load
  • zstd streams: Errors were not detected with ZSTD_isError, and the output buffer leaked on errors. A truncated frame made the output grow forever. Truncated input is now an error
  • Number parsing: rspamd_strtol and related functions treated an empty string as 0 and did pointer arithmetic on NULL. An HTML attribute without a value reached them this way. The hex overflow check used the decimal cutoff, so long hex numbers could wrap around. An opacity with no number in it is no longer treated as 0
  • Controller memory leaks: Statistics of metrics requests and interrupted requests are now released. So are query arguments in all handlers, including passwordless authentication (#6341)
  • Logger: Memory is reclaimed on configuration reloads, and the previous logger is restored when a reload fails. The syslog logger honours max_tag_len and tag_strip_policy (#6300)
  • hs_helper worker without Hyperscan: A build without Hyperscan refused to start with unknown worker type when the configuration was read from the source tree, as the functional tests do. The section is now skipped in such builds. An unknown worker type now reports its name in the error
  • SMTPUTF8 checks for Catalan and older ICU: Catalan L·L is accepted, not only l·l. Builds with older ICU give the same verdict as newer ones (#6345)
  • iPhone Mail X-Mailer with five-digit build numbers: iOS 27.2 on iPhone Duo sends X-Mailer: iPhone Mail (24B91016). The regexp allowed at most four digits after the build letter, so FORGED_X_MAILER (4.5) fired on every message from such devices (#6353, #6349)

🔄 Improved

  • libucl synced with upstream: The bundled libucl is synced with upstream, including CBOR support and fixes to the parser, emitter, schema and allocation. Rspamd's hashing, its Lua userdata and text APIs, and its safe parser defaults are preserved
  • Build fixes: Rspamd now builds with older ICU, such as the version on CentOS 8, whose U8_NEXT takes int32_t offsets. backward-cpp now builds against the libbfd of binutils 2.39 and later
  • RPM packaging on Fedora 44: The spec file uses vectorscan instead of hyperscan and RPM's native user and group allocation. It also generates Unix Makefiles explicitly (#6363)
  • SPF evaluates records in term order and applies the qualifier of an include;
  • DKIM rejects duplicated tags, l=0 over a body, and keys under 1024 bits;
  • DKIM d= is lowercased;
  • symbol options are stored verbatim and are encoded only when joined;
  • multi-value plugin results are now separate options;
  • headerless multipart children are text by default;
  • several archive, URL, date and Lua APIs behave differently, as described above.

Recommended upgrade for all users.