Skip to main content
๐Ÿš€ Major Release

Rspamd 4.2.0

Major Release with Public Suffix Lookup, a Single Symbol Scheduler, Office and SVG Content Extraction, and Bounded Parsing of Untrusted Input

๐Ÿ”„ Changed

  • One scheduler over the symbol dependency graph (incompatible): The per-class schedulers โ€” priority-serialised prefilters and postfilters, topologically ordered filters โ€” are replaced by a single execution plan computed when the cache is initialised. Every executable item gets a stage (connfilters, prefilters, filters, postfilters, idempotent) and a level within that stage: declared prefilters keep their priority as the level, postfilters and idempotent symbols keep the inverted priority so a higher priority runs later, and filters keep priority as a soft ordering key. A dependency now inherits the earliest stage and level among its dependents, so an edge from an earlier stage moves its target rather than executing it by accident from the wrong stage โ€” previously a prefilter depending on a filter pulled that filter, and everything it depended on, into the prefilter stage at runtime with no ordering guarantee at all. The runtime keeps a pending counter per bucket, reproducing the old priority serialisation without the "events pending" heuristic, and the same gate applies to eager dependency execution and to reverse dependencies, which could previously start a postfilter while the filters stage was still running. Item states moved to a single transition function with two new done-without-running states, suppressed (settings, disable_all_symbols, a pre-result โ€” no cascade, can be re-enabled) and skipped (passthrough, score limit, passed stage โ€” cascades to hard dependents); a symbol that already ran is no longer re-executed by task:enable_symbol, running items are not clobbered by disable calls, process_all pre-results allow further processing as documented, and the idempotent bit of the pre-result skip mask is honoured. Timeout accounting follows the plan: the longest dependency chain within a bucket, buckets summed within a stage, stages summed. The plan is visible through rspamadm configdump -d, the new rspamadm configdump -e execution-order dump and rspamd_config:get_symbol_type() (#6225)
  • Host TLDs resolved through the public suffix lookup (incompatible): rspamd_url_find_tld and the URL parse path no longer scan each host with a multipattern; they probe label suffixes right to left against a flat hash map, which is about seven times faster per host and involves no hyperscan scratch. Two behaviours change as a result: ! exception rules are honoured, having previously been skipped outright by the URL scanner, and a host that is itself a public suffix resolves to the whole host instead of being attributed to some shorter suffix (#6213)
  • Two-pass URL discovery in free text (incompatible): Discovery of bare URLs in text now runs the small static-matcher multipattern, compiled synchronously in memory, together with a dot-anchored probe of the distinct final labels of the suffix list. Since a TLD candidate is only ever accepted at the end of a domain token, anchoring on the final label is equivalent to matching the full suffix, and candidates from both passes are merged in ascending match-end order, which the fin deduplication and the newline cursor of the processing callbacks rely on. The upshot is that the URL scanner no longer builds the largest hyperscan database in rspamd โ€” 10.5k patterns, 1.3 s to compile and 6.5 MiB serialised โ€” and no longer registers it in the pending compilation queue of hs_helper (#6213)
  • multimap top filter returns the full public suffix (incompatible): The filter used to return the last label of the eSLD, conflating the uk and co.uk namespaces; it now returns the actual public suffix through the new lookup, falling back to the old derivation when no suffix list is loaded or nothing matches. Maps used with this filter must list full suffixes โ€” com.au rather than au โ€” so review any top-filtered map before upgrading (#6213)
  • RBL URL composition maps matched by suffix lookup (incompatible): Composition maps used per-TLD buckets of regexps, optionally compiled into hyperscan tries, to pick which part of a domain to query. The same longest-suffix matching is now one custom rspamd_tld_lookup rule set with a little flag handling on top: exceptions keep the default eSLD, wildcard rules keep the whole host, and since compose wildcards match zero or more labels the bare parent is registered alongside each wildcard rule (#6213)
  • Fuzzy Redis hash counts come from a periodic SCAN (incompatible): The Redis backend maintained <prefix>_count inside the update script โ€” incremented on every add including a re-learn of a digest already stored, decremented on every delete including deletes of digests that were not there, and never decremented when a hash expired โ€” so the reported number of stored hashes only ever grew, and a value that went negative was displayed as an enormous one. Updates no longer touch the counter. Instead the first fuzzy worker walks the keyspace with a slow SCAN matching digest keys only (the prefix followed by 64 bytes) and publishes the result, which every worker keeps reading as before. The scan is pinned to one read server, preferring a replica when configured, paced by a duty cycle that backs off when Redis is slow, and checkpointed so a restarted worker resumes its pass; the lock and the last completion time live in Redis, so storages sharing one Redis run a single pass per interval. Defaults are a pass at most every 4 hours, SCAN COUNT 1000 and a 10% duty cycle, all configurable in the count_scan section of the fuzzy worker. Negative counters left behind by older versions are reported as 0 until the first pass completes (#6259)
  • DKIM alignment split out of the passing-signature score: The credit a passing signature used to earn on its own is now shared between two symbols โ€” R_DKIM_ALLOW goes from -0.2 to -0.1 and the new R_DKIM_ALIGNED takes the other -0.1. A signature aligned with the author is worth what a passing signature was worth before, while one that merely verifies for an unrelated domain now earns half of that. No DMARC verdict moves, and installations that tuned R_DKIM_ALLOW should revisit the pair together (#6197)

โœ… Added

  • SVG content and smuggling indicators: SVG attachments render with a scripting engine in mail clients and browsers, which makes them a routine container for HTML smuggling and credential phishing. They are now parsed with the shared bounded XML scanner: visible text from text, tspan, title, desc and foreignObject HTML; hyperlinks and remote resources from image, use, script, iframe and CSS url(); data: URIs with their media types, decoding HTML and nested SVG payloads within a size budget; and the constructs a picture never needs โ€” script elements, on* handlers, javascript: URLs, animation elements rewriting href, foreignObject, forms and password inputs, meta refresh, embedded documents โ€” plus a keyword scan of script bodies for atob, Blob, createObjectURL, location and friends. An external DOCTYPE is accepted since most editors emit one; internal subsets are rejected. On the Lua side gzip-compressed SVGZ is supported, text and URLs are injected into the task, decoded HTML payloads are injected as HTML parts and nested SVG payloads are folded into the outer document, all under a per-task budget and timeout configured in lua_content { svg { ... } }. New symbols: SVG_CONTENT, SVG_SUSPICIOUS, SVG_SCRIPT, SVG_FOREIGN_OBJECT, SVG_DATA_URI, SVG_EXTERNAL_LINKS, SVG_EXTERNAL_RESOURCES, SVG_FORM and SVG_REDIRECT, with small default weights on the scripting and embedding indicators (#6240)
  • XLSX and PPTX content extraction with relationship-level facts: The bounded OOXML pipeline that handled DOCX now covers spreadsheets and presentations. SpreadsheetML handlers read shared strings, worksheets and Excel 4.0 macrosheets โ€” inline and formula strings, sheet hyperlinks, and URL literals inside formulas such as HYPERLINK and WEBSERVICE โ€” while the workbook handler reports sheet visibility and auto-executing defined names (Auto_Open and company). A DrawingML handler is shared by slides, notes slides and sheet drawings, and Word drawings with hlinkClick now yield URLs too. The Lua side owns the configuration, the per-task budget and a per-format story map two relationship levels deep (workbook โ†’ worksheet โ†’ drawing, presentation โ†’ slide โ†’ notes); relationship-only parts such as Word settings and Excel external links are fetched for their relationships alone. The real format comes from the main part's content type, with the MIME hint recorded as declared_format when the two disagree, and story selection truncates on the part budget instead of marking a large workbook suspicious for having many sheets. New symbols XLSX_CONTENT, XLSX_EXTERNAL_LINKS, XLSX_SUSPICIOUS, PPTX_CONTENT, PPTX_EXTERNAL_LINKS, PPTX_SUSPICIOUS, OOXML_MACROS, OOXML_OLE_OBJECT, OOXML_REMOTE_TEMPLATE and OOXML_EXTERNAL_DATA ship with zero weight by default, so you can observe them before scoring them (#6238)
  • Bounded DOCX content extraction: DOCX attachments are extracted through the same bounded pipeline, with the extraction core moved to C++, entity decoding shared with the other formats, and unprocessable attachments flagged rather than silently ignored (#6211)
  • HTML part injection from Lua: task:inject_part() accepts "html" next to "text". The injected part is marked as HTML and goes through the regular HTML parser, so its URLs, features and CTA links are collected exactly as for a real text/html part. Content extractors use this to feed decoded payloads โ€” HTML smuggled inside an SVG, for instance โ€” back into the scan (#6240)
  • Public suffix lookup component: A new component performs longest-suffix matching of hostnames against the effective TLDs file with full public suffix list semantics โ€” wildcard rules, exception rules and the ICANN/private section split โ€” by probing host label suffixes right to left against a flat hash map, bounded by the deepest rule in the list, instead of scanning the host with the multipattern engine. Hosts are folded with the UTF-8 aware case folder so uppercase IDN hosts match Unicode rules, with an ASCII fast path since hosts and probed labels are overwhelmingly ASCII and the per-codepoint ICU call dominated the lookup cost (#6213)
  • Suffix lookup exposed to Lua as rspamd_tld_lookup: Custom suffix-like rule sets โ€” exact, wildcard and exception rules with longest-match semantics โ€” can be built through the C API and the new Lua module, which also queries the suffix list rspamd itself loaded. Modules that used to reimplement this with regexps get the same per-label hash matching the URL scanner uses (#6213)
  • url:get_public_suffix() and the get_public_suffix selector transform: The URL method returns the public suffix of the host together with the match flags, so a plugin can tell wildcard, exception and private-section suffixes apart from plain ICANN ones, and the selector transform makes the same value available to multimap, ratelimit, reputation and every other selector pipeline (#6213)
  • Selector extractors declare the symbols they need: An extractor may list the symbols it depends on โ€” asn and country need ASN_CHECK, symbol(NAME) needs NAME โ€” either statically or as a function of its arguments. lua_selectors.get_dependencies() collects them for a selector string and lua_selectors.register_dependencies() registers them for the symbol evaluating that selector, skipping composites and classifiers and warning about symbols belonging to a later stage. The ratelimit, multimap, rbl and generic reputation rules register the dependencies of their selectors, so a ratelimit bucket keyed by symbol(DKIM_CHECK) finally gets DKIM_CHECK executed before the ratelimit check rather than reading whatever happened to be there (#6225)
  • Prefilters may depend on filters: A prefilter can now declare a dependency on a normal symbol โ€” a ratelimit keyed by the DKIM domain, for example. The symbol and its own dependencies are hoisted into the prefilter stage at the level of the depending prefilter: they wait for higher-priority prefilters, run alongside those of the same priority and complete before the lower levels and the remaining filters start, while keeping the passthrough and score-limit rules of filters. Hoists are logged at info level and shown by rspamadm configdump -e, so the cost of such a dependency is visible rather than implicit (#6225)
  • CSS compound selectors and combinators: Selectors used to be reduced to a single simple selector, so anything with a compound (div.mainbox) or a combinator (div p, div > p, h1 + p, h1 ~ p) was either ignored or, in a grouped list, wrongly applied by its first part โ€” and mail templates hide spacers and preheaders with exactly these rules, so hidden text detection missed them. The parser now builds a subject compound plus a right-to-left chain of combinator links and drops whole any selector it cannot evaluate (pseudo classes, attribute selectors, unknown elements, dangling combinators) so the rest of the list still applies. The style sheet indexes each selector by the most specific part of its subject and evaluates the remainder against the tag tree on lookup, and matching rules are applied by specificity with later source order winning ties, instead of the old fixed id/class/tag order (#6252)
  • R_DKIM_ALIGNED reported by the DKIM module: Whether a signature authorises the author rather than merely some domain used to be decided in two places โ€” the DKIM module holds the signing domain and knows whether the signature verified, while DMARC recomputed the comparison over DKIM_TRACE options with its own eSLD lookup. Two implementations of one rule can only drift, and nothing outside DMARC could ask the question at all. The DKIM module now compares each signing domain against the From domain and reports the best verdict it reached โ€” strict for an exact match, relaxed when only the organisational domains agree โ€” as the R_DKIM_ALIGNED symbol and in the task mempool. Signatures that only tempfailed are tracked separately, since a policy must not report a definitive failure while an aligned signature is still unresolved, and DMARC consumes these facts and drops both of its own comparisons, keeping the DKIM_TRACE walk only for the per-domain lists its reports need (#6197)
  • Mailbox identity and equivalent domain classes in lua_aliases: A comparison-only view of an email address: mailbox_identity() returns a canonical key โ€” lowercased, plus tags stripped, gmail dots removed, domain folded through equivalent domain classes โ€” and canonical_domain() folds a domain alone. Equivalent domains are those delivering into the same mailbox namespace (googlemail.com and gmail.com), supplied by a kv map or an inline table through init_equivalent_domains(), with a builtin Google class as the fallback. The rewrite path deliberately keeps the transmitted domain, since the domains stay distinct for SPF, DKIM and DMARC: the identity is only ever for comparing addresses and must never be written back to a task (#6228)
  • Sampled fuzzy storage statistics from the count scan: The count scan knew only how many digests exist. With count_scan.stats_sample (default 10) each SCAN batch runs inside a read-only Redis script that also reads every Nth digest found โ€” chosen by two digest bytes, so the sample is stable across resumed passes โ€” and returns aggregates: hash counts, average and maximum weight per flag, multi-flag and shingled hashes with their shingle slots, and creation age buckets. The scanner accumulates them, persists them with the checkpoint, scales them to the full count at the end of a pass and publishes a JSON document. Fuzzy workers read it on the stats timer and expose it as a storage object in the /fuzzystat control reply, and rspamadm fuzzystat prints it as a block; the wire STAT reply still carries only the count, so clients see nothing new. Cost is roughly 3 ยตs of Redis time per sampled digest within the existing duty cycle, and stats_sample = 0 restores the plain SCAN (#6260)
  • Lazy per-source statistics for fuzzy keys: Every storage key used to receive a 1024-entry per-source stats table preallocated at 128 buckets, which with thousands of keys costs several megabytes of idle memory per worker for tables that mostly hold one or two addresses โ€” while the one key that really does see a large source population, the default key, churned its table on nearly every request and produced a breakdown nobody could use. Per-source tables are now created on the first request that uses a key, starting at four buckets and growing on demand. The cap is configurable through the worker option max_ips_per_key (still 1024 by default) and per key through a max_ips extension, where 0 disables tracking; the default key tracks nothing unless it has an explicit max_ips, since it is the one key whose source set a bounded table cannot represent. A table can still overflow its cap, so evicting insertions are counted per hour and once they exceed eight times the cap the table is dropped and the key flagged for the rest of its life. The stats output gains ips_inserted and ips_overflow, and rspamadm fuzzystat says when per-IP stats were disabled for a key instead of printing nothing (#6248)
  • ucl.untrusted_parser() in the Lua API: Lua had no way to bound UCL parsing โ€” ucl.parser() gives the libucl defaults, which only guard nesting depth, and every one of the roughly 88 call sites in tree used it bare. That is fine in config mode, but most of those parse JSON or msgpack arriving from somewhere we do not control. The new parser applies a depth of 64, a million elements, 64 MB of tree, 1 KB keys and 16 MB strings; the caps are absolute rather than derived from the input, since a Lua caller builds the parser before it has the text, and any of them can be overridden per call site with zero still meaning unlimited. parser:set_limits() and parser:get_limits() are exposed on any parser, with set_limits overlaying only the fields it is given so tightening one does not silently clear the rest, and an unknown key being an error rather than a quiet no-op. ucl.parser() is unchanged (#6209)
  • ClickHouse data-skipping indexes for point lookups (schema 12): The rspamd table is ordered by TS alone, so any lookup that is not a time range โ€” by Message-ID, sender domain, client IP, URL eSLD, attachment digest or a subject word โ€” reads every row of that column in the window. Measured on an installation handling 20M messages a day, a single Message-ID over 30 days meant 303M rows, 15.5 GB and 1.6 s, with the second run fast only thanks to the query-condition cache. Bloom-filter skip indexes at granularity 4 are added on MessageId, From, MimeFrom, IP, Urls.Tld and Attachments.Digest, plus a bloom filter on Subject, both in the CREATE TABLE schema and as an 11 โ†’ 12 migration. ADD INDEX is metadata-only: new parts carry the index and older parts pick it up as they merge or expire, while MATERIALIZE INDEX is left to the operator because it rereads the column as a background mutation (#6241)
  • metadata_exporter redis_list pusher: Mirrors the existing redis_stream pusher but uses RPUSH, so exports can be consumed by clients that only read a plain list โ€” logstash's redis input, for one, which cannot read a stream. Per-recipient routing and an optional max_len cap are supported; since RPUSH cannot cap the list itself, max_len needs a separate LTRIM whose scheduling result and callback error are both checked and logged at error level, because a persistently failing trim would otherwise silently defeat the advertised cap. A trim failure deliberately does not defer: the RPUSH has already succeeded, so the message is exported, and deferring would re-run the exporter and push a duplicate โ€” a breached memory cap is recoverable, a duplicated export is not (#6200)
  • Pinning a lua_redis request to an upstream: lua_redis.request accepts attrs.upstream (and optionally attrs.host) to send a request to a specific server rather than selecting one โ€” needed to continue a SCAN cursor, which is only meaningful on the server that issued it (#6259)
  • WebUI: fuzzy storage liveness: A new fuzzy_check.ping_storage_all() Lua API pings every configured server of a rule โ€” the deduplicated union of the read and write server lists โ€” using each upstream's current address in a dedicated UDP session per server, leaving upstream rotation, DNS side effects and aliveness bookkeeping untouched; dead and not-yet-resolved upstreams are probed too, with a missing address reporting a sync error and kicking a one-shot re-resolve. The GET-only /plugins/fuzzy/status controller route, which works with the read-only password, pings every server of every rule and replies once all expected results have landed, with a deadline flushing a partial reply should a callback ever be lost. In the Status tab the storage tooltip shows each server address with its liveness mark and check time, storages with failed servers carry an "N/M down" badge visible without hovering, and a Check button in the card header re-probes on demand so the state does not wait for a refresh cycle that may never come with auto-refresh off (#6249)
  • WebUI: enriched fuzzy storage table and unavailable storages (#6243)
  • WebUI: Bayes learns balance bar and min_learns badge: The Status tab shows how learns are balanced between ham and spam and whether the configured minimum has been reached (#6239)
  • WebUI: richer Status servers table: Three-state health derived from /healthy and /ready, an expandable per-server details row, load rate and request latency columns, last-seen time for down servers, majority-based version and config_id drift detection, and a per-server Writable badge (#6212)
  • WebUI: informative All SERVERS cluster row: The aggregate row is derived from cluster data rather than a mix of the first neighbour's values, averages and blanks โ€” majority version, configuration and git ids with drift badges, youngest uptime, cluster-wide scan-time envelope, slowest-server latency, an up/total health counter and an expandable details row with summed traffic and memory. This also fixes the literal "undefined" version shown when the first neighbour was down or legacy (#6229)
  • WebUI: git build id in the cluster table: The build-time git id is exposed as git_id in /stat and rendered in a dedicated column, hidden when no live server reports one, with a drift badge like version and configuration mismatches so dev builds of different commits can be told apart (#6216)

๐Ÿ”ง Fixed

  • Destroyed regexp map helpers dropped from the hyperscan queue: The pending compilation queue kept raw pointers to regexp map helpers, relying on every reload to replace the queued pointer with the new helper. A reload that never queued a new version โ€” an empty map, a map shrunk below the in-memory compilation threshold, or a read error โ€” left the entry pointing at the helper that reload destroyed, and a later "regexp map loaded" notification for the old digest resolved the stale entry and read freed memory, crashing the worker. Every queue entry referring to a helper is now removed when that helper is destroyed (#6263)
  • Reread deferred for a map file that has just been truncated: Editors that save in place truncate the file and write it afterwards, and the stat watcher can observe zero length in between, swapping an empty map in until the write is noticed โ€” a window with no rules at all, and for regexp maps a queued version that never gets compiled. A file going from non-empty to empty now schedules its reread on the minimal timer instead of immediately, and a stat event for the finished write cancels the deferred check and rereads at once, so only a file that really has been emptied waits (#6263)
  • Cold passive workers converge on HTTP maps quickly: Scanner workers never fetch HTTP maps themselves; they wait for the active worker to announce a downloaded map through the shared memory cache and poll it on their own timer. On a cold start, with no cached map files, each scanner checked the cache once before the controller had finished downloading and then slept through a full jittered poll interval โ€” minutes โ€” serving no map data, so every forked scanner converged independently and slowly, disagreeing with its siblings, while the controller reported all maps as loaded. Per-process data delivery is now tracked: a still-empty map is rechecked every 2โ€“4 seconds on passive workers, the shm cache is read whenever it exists and the process has no data regardless of the last-modified comparison, and the on-disk cache file is used when the shm announcement is gone but the file has already been saved (#6214)
  • Symbol cache dependency cycles broken at init: A cycle used to be merely reported and kept, so every walk over the graph had to cope with it โ€” and the timeout traversal followed same-bucket dependencies with no path guard, overflowing the stack in rspamd_symcache_get_max_timeout(), which both configtest and worker startup call. Cycles are now broken right after dependencies are resolved: a depth-first walk drops the back edge of every cycle along with its reverse and reports it as an error naming both symbols, with the traversal keeping the current path as a safety net (#6225)
  • Connfilter and prefilter dependencies on filter symbols rejected: The stage sanity check was skipped whenever the dependency target was a filter, so a connfilter or prefilter could depend on one and the runtime then eagerly executed it โ€” and its own dependencies โ€” from the wrong stage instead of ordering anything. Such an edge is now rejected at cache init, so rspamadm configtest reports it with the stages of both symbols, and virtual symbols are checked against their parent's stage. Every rejection path also actually drops its edge now: add_dependency() pre-sets the dependency item, so rejected and self-dependency edges used to survive the "remove empty deps" pass and reach the runtime anyway (#6225)
  • Late-registered symbols planned, stuck async counters no longer spin: Symbols registered after cache init โ€” multimap regexp rules loaded from a map, for instance โ€” got no execution stage, and the resort then indexed the per-stage bucket table past its end. The plan is now recomputed on every resort and is idempotent: only symbols without a plan are seeded from their declared type and priority, so levels assigned at init stay put. Separately, an item whose async counter is never released stayed pending forever; the old scheduler moved on silently while the new one kept the bucket closed, and with no session events the task processing loop recursed until the stack overflowed. A bucket with pending items but no session events no longer blocks the next levels, items blocked on such dependencies are skipped with a warning naming them, and the underlying lua_http counter leak is fixed as well (#6225)
  • ignore_passthrough symbols run after a pre-result: task:set_pre_result() marked the classifier stages as processed to skip classification, and since task processing resumes from the highest processed stage that also jumped over the filters stage โ€” so filters flagged ignore_passthrough never ran after a prefilter pre-result, contrary to the flag's documented meaning. The processed stages are left alone: the symbol cache already suppresses non-exempt symbols, and classification is skipped by task processing when the result carries a passthrough that stops further checks, with least and process_all results and disabled actions not counting (#6225)
  • Custom apply keys survive a settings merge: The merge built the settings object from a fixed list of known keys, silently discarding every other top-level key of a layer's apply block. Custom apply keys are an extension point โ€” a settings block may carry arbitrary values that Lua rules read back through task:get_settings() โ€” and with a single layer they survived, because that path returns the layer object as is, but as soon as a second layer appeared (a static block plus a layer pushed by the Redis settings handler, an external map, or any prefilter) they vanished, so a rule keyed off apply.tenant or apply.mode silently changed behaviour depending on how many layers matched. Unknown keys are now merged per key with the higher layer winning and the value taken whole, with _merge_info treated as known so a layer cannot clobber the merge metadata. The merged path also shares the single-layer action application code now, which fixes two further bugs: a freshly created action slot never had its flags initialised and could be skipped depending on what the uninitialised memory held, and actions { foo = null } for an action the task does not have created it with a zero threshold, turning a request to disable an action into one that enabled it on everything (#6230)
  • Actions re-enabled from higher settings layers (#6230)
  • ip_map and client_ip_map actually match in settings (#6205)
  • CSS selector matching bounded: Descendant and subsequent-sibling links backtracked over every candidate, so deep nesting with a long chain revisited the same elements combinatorially โ€” seconds of CPU for a 28-deep document. Where the rest of the chain is made of the same kind of link the nearest candidate is always valid, so that walk is now greedy and linear, and the mixed cases keep backtracking under a budget of compound evaluations. A document-wide matching budget is shared across selectors, elements and style blocks, charging compound parts, attribute scans and sibling traversal, and incomplete cascades are dropped when it is exhausted; class bucket lookups are deduplicated, compound sizes capped, specificity cached and the selector limit enforced before index insertion (#6252)
  • A repeated CSS selector is its own cascade entry, and only the winning rule is allocated: Every selector scanned its bucket for a duplicate to merge into, which was quadratic in the number of rules and also dragged the selector's earlier declarations past the rules written in between, so .a {font-size:0} .b {font-size:14px} .a {color:red} hid a .b .a element. A repeated selector now forms a separate cascade entry. Lookup used to compile every matching rule into a pool block, including those the cascade then discarded, growing the pool by rules times elements; the winner is now the only pool allocation and the rest compile into a stack temporary, filling only what the winner leaves undefined with set semantics rather than parent-propagation ones, so a less specific display:none no longer overrides a more specific display:block (#6252)
  • A grouped selector that cannot be evaluated is dropped whole: Ignoring an unevaluatable selector worked only when it was not part of a list โ€” at EOF the state is an ignore one, so nothing was attached, but a comma attached whatever simple selector had been parsed before the parser gave up. div.mainbox ul li.spacer, div.mainbox ol li.spacer therefore registered a rule for the bare tag div and applied its declarations to every div in the document, with no class having to match anything. Grouped descendant rules are ubiquitous in mail templates, and the ones carrying font-size: 0, opacity: 0, color: transparent or display: none are exactly the spacer and mobile-only rules, so a legitimate message could have its entire body moved to the invisible buffer: one confirmation mail lost all 81 words of its text part, HIDDEN_TEXT scored 4.06 on 411 invisible characters, and with the word count at zero R_SUSPICIOUS_IMAGES reached its ceiling of 5.0 off a single logo (#6252)
  • Relative and shorthand font sizes no longer hide visible text: Three defects made ordinary mail look hidden. The font shorthand walked every value and set the font size from each one that parsed as a dimension, so the last won and unitless numbers were accepted as lengths โ€” in font: 400 1rem/1.5 <family> the line-height became the size, the block got a 1px font and everything inheriting from it compiled as invisible; webmail clients emit exactly that on body, so an ordinary reply's whole body moved to the invisible buffer, HIDDEN_TEXT fired at full weight on ham, and Bayes, the neural network and every text rule saw a nearly empty part. vw and vh were spelled wv and wh in the dimension map, so the two most common viewport units never matched and the bare number was read as pixels, turning font-size: 2vw into 2px. And a percent size introduced by a stylesheet block stayed negative and never met the visibility limits, because the block is merged after the parent has already propagated, while sizes above 128% were clamped to 100% by an int8 field and the fallback parent size was 10px where the rest of the parser and every browser use more. Unitless numbers are rejected for the shorthand, parsing stops at the first length, unknown units leave the size unset, the field is widened, sizes are resolved again once the stylesheet block is in place, and the fallback is 16px (#6245)
  • A unitless zero is accepted as the font shorthand size: The shorthand parser rejects unitless numbers, since in that position they can only be the weight or the line-height โ€” but zero is the exception: never a valid weight, a valid length in every rendering mode, and font: 0 Arial renders text invisible. A zero is let through so the hiding trick is still detected, while the first-length rule keeps a zero line-height (16px/0) from being taken as the size (#6246)
  • Transparent tag content read from the buffer it was written to: html_tag::get_content picked the invisible buffer for any block that was not visible, but transparent text is written to the parsed buffer as spaces, so the offsets of a transparent tag were applied to the wrong buffer and the content came back empty โ€” or as a slice of unrelated display:none text when the message contained some. The destination buffer is now recorded on the tag when its offsets are computed and read from there, and the length passed to Lua comes from the recorded offsets rather than from whatever survived in the buffer after a block margin trimmed the trailing spaces. R_WHITE_ON_WHITE now fires on white-on-white text whether or not the message also carries hidden text (#6262)
  • Layout padding ignored and substantial hidden content penalised in the HTML hidden text checks (#6234)
  • chartable handles language diacritics correctly, including non-Latin ones (#6231)
  • chartable Unicode spoof detection hardened (#6234)
  • Nested SVG payloads charged to the shared payload budget: max_payloads was enforced per document by the native extractor and per task only for injection, so a nested SVG brought its own full quota and the part specific ended up holding more payload content than the budget advertised. The remaining task budget is now passed to the extractor, a payload is kept only while budget remains with payloads_truncated recording the cut, and nested payloads are charged to the same counter before being folded into the outer document (#6240)
  • Injected HTML parts get the regular URL pipeline: An injected part had no URLs array, so the HTML parser could not attach its links โ€” part:get_urls() stayed empty and CTA and candidate-link processing was skipped โ€” and bare URLs in the visible text of an injected HTML part were never extracted either, since only the plain text branch ran the text extractor. Injected parts now get their URLs array allocated and the strict text extraction runs after HTML processing, mirroring what a real text/html part receives (#6240)
  • OOXML relationship-only parts kept ahead of story truncation: Story selection walked the relationship list in package order and stopped at the first entry that did not fit the part budget, so a package front-loading enough worksheets or headers pushed the relationship-only parts โ€” Excel external links, Word settings with the attached template โ€” past the truncation point and opened with truncated=true but without the OOXML_EXTERNAL_DATA and OOXML_REMOTE_TEMPLATE evidence. Relationship-only entries are now selected first within each level, since they cost a single part and carry the indicators, with content stories picked in package order from the remaining budget. xlIntlMacrosheet is also registered next to xlMacrosheet in the workbook story map, so international XLM macrosheets are parsed for text and formula URLs instead of merely being counted as macros (#6238)
  • OOXML processing resources bounded and DOCX content symbol scores registered (#6211)
  • Customer fuzzy keys take precedence over IP bans: The source policy is now applied after decryption, so a key with its own limits, expiry and permissions is honoured rather than being pre-empted by an address ban; telemetry for blocked requests is preserved before they are silently dropped, and IP restrictions are kept for the shared default key. Covered for static and dynamic bans over both UDP and TCP (#6255)
  • Fuzzy key expiry time fields initialised before mktime: strptime leaves the time-of-day and DST fields untouched when parsing DD-MM-YYYY, so an expired key could pick up a far-future expiry from stack contents. The fields are initialised before the conversion and failed date parses are rejected (#6255)
  • Unkeyed aggregate fuzzy stats kept without per-IP tracking: The unkeyed aggregate bucket was only updated when the session carried a per-source stat record, which used to stand in for "the client had an address". With per-source tracking now optional โ€” max_ips_per_key = 0 or after an overflow โ€” that record is NULL for every request and the aggregate checked/matched/added/deleted/errors counters froze. The bucket is now updated whenever the request is unkeyed and the bucket exists (#6248)
  • Fuzzy write servers pinged with the write keypair: The status probe generated its PING with the read encryption keys for every server, so write servers of a rule with distinct read and write keys โ€” and the only server of a write-only rule โ€” were pinged with the wrong keypair and always reported as failing. The keypair selection now follows the server list direction, and the override applies only when the read and write lists are actually shared: a write-only rule may also define them as separate lists, which the parser aliases only when a single list is configured, and such read storages were being pinged with the write keypair, going unanswered and showing up as "fail" for healthy servers (#6249)
  • Empty fuzzy storages reported in /stat (#6243)
  • DKIM alignment requires a single author: Alignment answers whether a signature authorises the author, which presumes there is one. The check took the first MIME From address whenever the array was not empty, so a From field naming several mailboxes still produced R_DKIM_ALIGNED and the mempool verdict โ€” and with a negative weight, the signal was cheaper to earn on a message with no single author than on a well-formed one. What the message itself carries is now counted: where addresses are flagged as originals exactly one of those must exist, otherwise the array must hold exactly one entry, which is the same precondition DMARC applies and which leaves a rewrite intact (#6197)
  • SPF returns permerror for a domain with multiple SPF records: Only the first record starting with the version string was taken and the rest of the answer ignored, so a domain publishing two SPF records was evaluated as if it published one, where RFC 7208 4.5 requires permerror. An RRset carries no order, so which record won depended on the order the resolver happened to answer in, and the same message could pass at one receiver and permerror at the next. Records are now counted rather than broken out of on the first, with the duplicate branch reporting the record as handled so the caller does not fall back to na, which is checked before permerror and would have masked the error. Domains publishing one SPF record alongside unrelated TXT records โ€” vendor verification tokens and the like โ€” are unaffected (#6196)
  • SPF version section matched as RFC 7208 4.5 defines it: The version literal is case-insensitive and terminated by a space or the end of the record, and the bytewise prefix compare honoured neither โ€” so a duplicate spelled V=SPF1 escaped the one-record limit, while v=spf10, which the RFC discards, counted as an SPF record and could permerror a domain publishing a single valid one beside it. The comparison also no longer reads past a short TXT string: rdns exposes TXT data as a bare NUL-terminated pointer with no length, and the previous helper read the first four bytes of both operands before comparing any of them, which overran a legal one-character or empty TXT string (#6196)
  • forged_recipients compares addresses by mailbox identity: Envelope and header addresses were compared as raw strings, so a message addressed to johndoe@googlemail.com with RCPT TO johndoe@gmail.com, or From j.o.h.n@gmail.com with MAIL FROM john@gmail.com, was reported as FORGED_RECIPIENTS or FORGED_SENDER even though both sides name one and the same mailbox โ€” and dot variants within one domain only passed on the recipient side thanks to the "same domain, other user" fallback, with the sender check having no such leniency. Both sides now match by lua_aliases.mailbox_identity: equivalent domains are folded, gmail dots and plus tags stripped, and the authenticated user and Delivered-To checks use the same key, while the symbol options still show the wire addresses and the addresses on the task are untouched. Equivalent domain classes come from a new rspamd/equivalent_domains.inc map covering Google, Proton, Apple and Yandex, fetched from maps.rspamd.com with the usual local.d/maps.d hook and a shipped fallback snapshot, configured through the equivalent_domains option (#6228, #6227)
  • Greylist uses the lowest passthrough priority: The greylist pre-result now sets priority 0, so any other pre-result โ€” force_actions, a score-based reject โ€” always wins the tie regardless of execution order among postfilters (#6220)
  • Milter no longer inserts a bogus space after the header colon: The milter protocol passes header names and values separately, and unless SMFIP_HDR_LEADSPC is negotiated the MTA eats exactly one space after the colon on the way in and adds one back when the filter modifies headers. Rspamd rebuilt every header as name: value regardless, so a header folded right at the colon (Message-ID:\r\n <id>) gained a space that had never been there and simple DKIM canonicalisation started to fail. SMFIP_HDR_LEADSPC is now negotiated whenever the MTA offers it, making header values travel verbatim in both directions and covering Header:value with no space at all, with the leading space supplied by rspamd on add, change and insert actions โ€” empty values left unpadded, since that is how a header removal is expressed. For MTAs that do not offer the flag, the space is still added unless the value starts with CR/LF, the one folded case still distinguishable from a space the MTA ate (#6194)
  • url_redirector honours redirectors_only: map:get_key() returns false rather than nil on a miss for glob, regexp and set maps, so the gate written as get_key(...) ~= nil was always true and every redirect target was followed regardless of the configured host map. A truthiness-based check is now applied consistently at the three sites that can issue an HTTP request for a hop โ€” the live 30x follow decision, the mid-walk cache-miss bridge and the cached nested bridge โ€” closing map-churn windows where a cached chain ends on a host delisted after being cached. An unrelated bug on the same nested bridge is fixed too: the seen table was keyed by the raw cached string while the walk keys by the URL's string form, so the cycle guard could false-fire and silently abort the extension for most URLs. A debug line is now logged before every request and for each host map lookup, giving a single grep anchor for whether a hop was really fetched (#6198)
  • url_suspect html_entities pattern restricted to ASCII: The pattern looks for two numeric HTML entities within 20 characters, on the premise that clustered numeric entities mean someone is hiding a URL โ€” which only holds for ASCII, where the technique spells out the characters of the URL itself. Ordinary prose in many languages entity-encodes accented letters instead, and those cluster naturally: v&#230;r s&#248;d, Gr&#252;&#223;e aus M&#252;nchen, r&#233;serv&#233; aux clients all matched, so URL_OBFUSCATED_TEXT fired on routine non-English newsletters. Latin-1 letters live at 192โ€“255, well clear of the ASCII range the technique uses, so restricting the pattern below 128 keeps the detection and drops the false positives; multi-byte entities were already outside the pattern and are unaffected (#6206)
  • url_suspect derives the public suffix through the lookup: Stripping one label from the eSLD mangles hosts that are themselves a public suffix; the suffix lookup is asked directly, with the old derivation kept only as a fallback when no suffix list is loaded (#6213)
  • lua_maps keys url-list maps by their effective type: map_add_from_ucl() caches maps so modules pointed at the same feed share one object, but a map given as a list of URLs was keyed by the digest of the list alone, so the type requested by the first caller won and every later caller silently received a map of a foreign type. url_redirector asks for a glob map of redirector_hosts_map while the multimap redirector rule asks for a hash map of the same URLs; multimap registers first, so url_redirector ran on a hash map and no glob pattern in the feed could ever match. The type is now part of the cache key, and a type prefix inside the list (glob;http://...) is stripped before the lookup so the key always carries the effective type rather than the requested one (#6226)
  • WebUI stat refresh cycles ordered by start, not completion: The refresh cycle id was assigned in the /stat success callback, so overlapping refreshes were ordered by completion โ€” an older request finishing after a newer one received the larger id and re-rendered Credentials, the Status table and the chart with its stale captured parameters, such as the selected server. The id is now assigned and captured before the query, so the later-started cycle always supersedes the earlier one and obsolete cycles drop their state entirely (#6212)
  • WebUI stale-rate threshold raised above the largest auto-refresh preset: At the 30-minute and 1-hour presets every sample span exceeded the previous 15-minute limit, so the Load column stayed "-" forever (#6212)
  • rspamadm fuzzy_ping help fixed: The -n option's description was wrong (#6237)

๐Ÿ›ก๏ธ Security

  • DNS reply parser hardened against malformed packets: The parser consumes fully attacker-controlled bytes, yet it decoded names, resource records and TCP frames with unchecked or wrapping arithmetic. Name decoding is reworked around a single bounds-checked iterator: question comparison and label parsing bound every access to the packet, cap compression-pointer hops and total name length, and reject truncated or reserved labels instead of reading out of bounds. Encoded names embedded in RDATA are bound to their RDLENGTH, with compression pointers still allowed to target the whole packet, and PTR, NS, CNAME, MX, SRV and SOA verify exact RDATA consumption so a short or long RDLENGTH cannot shift the parsing of the records that follow. A records require exactly four bytes, every RR is length-validated and resynchronised to its RDATA end, TXT chunk accounting uses size_t rather than an 8-bit counter that wrapped after 255 segments, partially parsed allocations are freed on every error path and every allocation is checked. TCP reads drain iteratively under a per-event packet budget instead of recursing, and the read counter is widened so a maximal 65535-byte DNS-over-TCP frame no longer overflows a 16-bit field
  • UCL container nesting bounded and trees freed iteratively: Freeing a UCL tree recursed once per nesting level, so a deeply nested document could exhaust the stack while being cleaned up, long after the parser had returned successfully โ€” a 20k-level document crashed reliably on free. Destruction now walks an explicit worklist threaded through the dying objects' next pointers, which is allocation free and uses constant stack. Nesting itself was unbounded for brace containers, since the maximum was only ever consulted by the msgpack and csexp parsers; container depth is now tracked separately from the dotted-key level and checked before anything is allocated, so the 1024 default applies to every parse type. ucl_parser_set_limits() is added for callers handling untrusted input โ€” max depth, nodes, allocation, key length and string length, with zero meaning unlimited and only the depth limit on by default โ€” and both the UCL and msgpack parsers charge every element they create, including the array wrapper a duplicate key builds. Several latent defects are fixed along the way: an allocation-failure path that left a value object with a stale union pointer, a sibling chain loop that released a sibling's children before checking its refcount so a shared object could have its children freed underneath it, an unchecked allocation and two ignored append results, a NULL container unconditionally reported as a syntax error and masking the real cause, and a msgpack container unwind by tail recursion that made parse depth a stack depth problem of its own (#6207)
  • UCL parsing of untrusted network input bounded: Request bodies were parsed with the safe flags but without any structural budget, so a body well within max_message could still describe a tree orders of magnitude larger than its wire size โ€” 50 MB of [1,1,1,...] builds gigabytes of objects. An untrusted parser now applies a depth of 64, a million elements, 1 KB keys, 16 MB strings and a tree cap of min(96 ร— input + 256 KB, 64 MB), the factor coming from the cost of an element against the densest JSON that can describe one, so it sits above anything legitimate while the absolute cap bounds a large body. It is used for checkv3 metadata in both JSON and msgpack form, for both controller body parsers and for both proxy parsers (#6207)
  • Untrusted UCL parser used for external input in Lua: Call sites parsing input from outside the local configuration were moved to ucl.untrusted_parser() so a hostile reply cannot turn into an unbounded object tree โ€” most directly the HTTP request headers parsed by http_headers and settings, and otherwise replies from network services: the LLM providers and gpt, the six lua_scanners backends, bimi, contextal, the updates and openphish feeds, the external neural service, the anonymiser in lua_mime and the vault lookup in lua_dkim_tools. Settings is split rather than converted wholesale โ€” the request header and external map parsers are untrusted, the settings map and Redis ones stay on the ordinary parser โ€” and clickhouse, elastic, lua_cache, history_redis, the neural model paths and everything in config mode or rspamadm are deliberately left alone, since they handle data we produce ourselves and can legitimately be large enough to reach the caps (#6209)
  • lua_compress decompression output and memory bounded: inflate tested its size limit only when the output buffer filled with input still pending, so a stream ending in Z_STREAM_END returned whatever it had produced, and a buffer that did fill doubled past the limit rather than being clamped โ€” a caller asking for 512 Kb could be handed 1 Mb, having allocated twice what it asked for. A single ceiling is now kept, allocated one byte above it so a full buffer proves the data does not fit, and checked after the loop as well as during it. zstd took the decompressed size from the frame header and passed it straight to g_malloc: that value is attacker controlled, a few dozen bytes suffice to claim terabytes, and g_malloc aborts rather than returning NULL, so a worker decompressing an untrusted body died where it stood. The header is now an allocation hint capped against the size of the input, with the existing growth loop finding the real size. The inflate loop also resumes from where inflate stopped rather than rewinding the output pointer and overwriting what had already been produced
  • PDF resource amplification bounded: A hostile PDF could make the parser and the text extractor do far more work, and hold far more memory, than the size of its input suggested, because the existing limits counted things rather than bytes. CMap mappings were capped by entry count only โ€” one bfchar destination could carry an arbitrarily long string, a bfrange could copy such a string once per expanded code, and the builder appended a whole mapping per character code with no ceiling โ€” so bounded input said nothing about output; the bytes one mapping may hold and the total a CMap may store are now capped, and the builder has an output ceiling it stops at instead of trusting the content stream. Range lookup walked backwards over the table once per character whenever the nearest candidate had the wrong code width; ranges are grouped by width with a running maximum so a search can tell at once whether an overlap could still answer. The object limit counted only non-tiny objects and never saw those unpacked from an object stream, so a hard total is added, the deadline is checked while unpacking, and the marker occurrences collected before the timer used to start are budgeted. Text extraction had no deadline of its own and re-ran the filter chain for every reference to a shared stream, so decodes are memoised, the parsed cmap is cached on the stream rather than per font, and the text is budgeted. The trailer limit compared the trailer's offset against a byte count, so a large file with a short trailer was skipped whole โ€” taking /Encrypt detection with it โ€” while a small file with an enormous trailer passed; the trailing bytes are compared instead. max_extraction_size becomes 1 Mb, which is the ceiling streams have really been decoded under for years now that the binding enforces it exactly, and timeout_processing is carried across to the output object so a deadline tripping late actually reaches the caller and PDF_TIMEOUT can fire at all

๐Ÿ”„ Improved

  • Bounded XML scanner shared across extractors: The strict XML scanner moved out of the OOXML module so other attachment formats can reuse it. It is templated on its handler, which supplies its own namespace policy, so each format keeps its namespace vocabulary local, while entity decoding mode, DOCTYPE acceptance and a declared-encoding fallback for non-UTF-8 input become scanner limits set by the caller โ€” OOXML keeps its previous strict behaviour (#6240)
  • LRU hash constructor with an initial size: The existing constructor always preallocated 128 buckets, a sensible warm start for the handful of long-lived caches it serves but a waste for tables created per object that usually stay tiny. A sized constructor takes the initial bucket count, with zero allocating nothing until the first insertion (#6248)
  • Symbol cache levels, hoisting and hard dependencies documented: How priorities act as levels for prefilters and postfilters, that a prefilter may depend on a normal symbol and thereby hoist it, the hard flag of register_dependency, and the configdump option that shows the execution plan (#6225)

This major release replaces the per-class symbol schedulers with a single plan over the dependency graph, so a dependency edge now moves its target between stages instead of executing it from the wrong one, and prefilters may finally depend on filters โ€” with selectors declaring the symbols they need so a ratelimit bucket keyed by `symbol(DKIM_CHECK)` gets a real value. URL handling moves from a 10.5k-pattern hyperscan database to a proper public suffix lookup with full PSL semantics, exposed to Lua, selectors and the RBL composition maps; check any multimap using the `top` filter, whose maps must now list full suffixes. Attachment content extraction grows to SVG, XLSX and PPTX alongside DOCX, with relationship-level facts for macros, OLE objects, remote templates and external data, and decoded payloads injected back through the regular HTML parser. Parsing of untrusted input is bounded throughout โ€” the DNS reply parser is hardened against malformed packets, UCL gains real structural limits and an untrusted parser used for every network-facing call site, and zlib, zstd and PDF extraction no longer amplify a small hostile input into unbounded work. Several long-standing false positive sources are gone: CSS compound selectors and combinators are evaluated instead of being applied by their first part, font shorthand and viewport units no longer turn ordinary mail invisible, and forged sender and recipient checks compare mailbox identities rather than raw strings. Recommended upgrade for all users; review the incompatible changes above, especially the public suffix semantics and the fuzzy hash counting rework, before rolling out.