Skip to main content
πŸ› Bug Fix Release

Rspamd 4.1.3

Hardening Release with DKIM/SPF Correctness Fixes, Parser Resource Bounds, Fuzzy Hash Observability, and a jQuery-Free WebUI

βœ… Added

  • Structured fuzzy match results and a diagnostics API: Every fuzzy match now produces a structured record β€” rule, symbol, upstream, stored and queried digests, match type, probability, score, flag, value and hash timestamp β€” kept in the fuzzy_matches mempool variable and exposed to Lua via task:get_fuzzy_results(). All queried hashes, misses included, are recorded in fuzzy_checked. The symbol option for non-exact matches now carries both hash prefixes (flag:found:prob:type:queried), so a match is diagnosable from a scan report alone. The storage sets a flag bit in the first reserved byte of the reply when the digest field holds the digest actually resolved by the backend, and the client propagates it as a confirmed field, removing the guesswork about echoed queries when talking to legacy storages. A new fuzzy_check.check(task, cb, rule, timeout[, hashes][, server]) Lua API queries a storage either with the hashes generated from the task message (shingles included) or with an explicit list of hex digests, reporting per-reply results including misses and error codes
  • Configurable fuzzy probability weight curve: The score multiplier for non-exact matches used to be sqrt(prob), a concave curve anchored at zero. Since the storage never returns a probability below the shingle match threshold of 0.5, the whole reachable range collapsed into roughly (0.73 .. 1.0], so a marginal 17/32 shingle overlap scored almost as high as an exact match β€” combined with high-weight deny lists this turned weak matches into instant rejects. The curve is now ((prob - prob_bias) / (1 - prob_bias)) ^ prob_power, anchored at the match threshold (prob_bias 0.5). With the default prob_power of 1.0 the multiplier is simply the fraction of the way from the threshold to an exact match (19/32 β†’ 0.19, 24/32 β†’ 0.50, 28/32 β†’ 0.75); raise it per rule for harsher discounting of weak matches. The old zero-anchored curve is not preserved. Applies to text and HTML hashes; small images keep their existing normalized curve
  • Persistent shingle sets in the redis fuzzy backend: Shingle key suffixes are now stored as the S field of the digest hash on every ADD, making each digest self-describing and closing two long-standing blind spots. Digest-only deletions (for example periodic deletions driven by a hash list) previously left the digest's shingle slots orphaned; they are now reconstructed from the persisted set and removed when still owned by the deleted digest. REFRESH used to touch the shingle slots of the message that happened to trigger it β€” a different message by definition β€” and now refreshes the slots the digest still owns and reclaims the vacant ones, so a hash that keeps matching keeps its full shingle anchor instead of decaying. The sqlite backend needs no changes, since its shingles table already cascades on delete
  • Per-hash storage introspection: rspamadm control fuzzyhash <128 hex digest> asks all fuzzy workers for storage-side diagnostics of a specific hash: flag slots and values, creation time, remaining TTL and β€” using the newly persisted shingle set β€” slot ownership (owned/foreign/vacant out of total). Ownership directly answers the central false-positive triage question: can this hash still produce fuzzy matches, or has its shingle anchor decayed? The redis backend gathers everything in a single server-side script round trip; sqlite queries the digests and shingles tables synchronously
  • Unkeyed client statistics: Traffic from clients without a key (for instance those permitted by allow_update) was invisible in the key statistics, which made storage writers untraceable. Such clients are now tracked under a dedicated unkeyed pseudo-key with the same aggregate and per-IP counters as regular keys
  • rspamadm fuzzy_hash command: Computes the fuzzy hashes of a message per configured rule, matching the scanner tokenization bit for bit, optionally checks them against the storage with shingles included (-C), and queries storages for explicit hex digests (-H) β€” printing found and queried digests, probability, flag, timestamp and whether the storage confirmed the matched digest
  • ClickHouse fuzzy match export: New Fuzzy.Hash/Fuzzy.Queried/Fuzzy.Rule/Fuzzy.Prob/Fuzzy.Flag nested columns (schema version 11) filled from the structured fuzzy results, so false-positive investigations can start from the scanner-side journal instead of storage forensics
  • Annotated X-Rspamd-Fuzzy header: milter_headers now builds the header from the structured fuzzy results, annotating every matched hash with the rule, flag, probability, the queried hash for non-exact matches and the storage timestamp. It falls back to the legacy fuzzy_hashes pool variable when no structured results exist
  • lua_http forbid_local: rspamd_http.request() used to connect to whatever the URL or its DNS resolution yielded, loopback, link-local and RFC1918 destinations included. A new forbid_local option is enforced at the single connection chokepoint β€” after keepalive, upstream, numeric and DNS address selection β€” using the address class check plus the configurable local_addrs radix. It is enabled by default in url_redirector, where message URLs and their redirect targets are attacker-controlled and hop limits alone do not stop a crafted redirect from probing cloud metadata endpoints or internal services; operators resolving internal redirectors can set forbid_local = false
  • ARF report enrichment: Microsoft's JMRP feedback loop ships a well-formed feedback report whose message/feedback-report block only carries Feedback-Type, User-Agent and Version β€” Source-IP, Arrival-Date, Original-Mail-From and Original-Rcpt-To live only in the embedded original message. lua_feedback_parsers now recovers those fields from the original headers, filling only what the report itself left empty and recording provenance in result.derived so callers can tell reported data from inferred data: source IP from X-Originating-IP, then client-ip= of Received-SPF or Authentication-Results, then the first public IP while walking the Received chain; arrival date from the topmost Received timestamp, else Date; original mail from from Return-Path, else smtp.mailfrom; original recipient from Delivered-To, X-Delivered-To or envelope-to; and reported domain falling back to the envelope-from domain. Received-Date is also accepted as an Arrival-Date alias in the report block

πŸ”„ Changed

  • WebUI drops jQuery entirely (incompatible): Every WebUI module β€” the AJAX layer, common helpers, the leaf modules (graph, selectors, history, symbols), config, libft, stats, upload and the main module β€” is migrated to native DOM and XMLHttpRequest, and the vendored jQuery build is deleted. The rewritten query/queryServer preserve jQuery's semantics: neighbours fan-out and aggregation, per-server error dispatch, statusCode, timeout, GET data appended as a query string, the default Content-Type for POST bodies, NProgress download progress and jqXHR-compatible callback arguments. Bootstrap 5 moves from the jQuery bridge to the vanilla constructor API, the jquery.stickytabs plugin becomes an inline implementation using the BS5 Tab API, and D3Evolution and D3Pie are updated to versions that dropped their own jQuery dependency. Drive-by: the modalDialog backdrop attribute is renamed to its Bootstrap 5 name data-bs-backdrop, so the static-backdrop behaviour is actually applied (#6124)
  • Font Awesome replaced by a local SVG sprite (incompatible): The "SVG with JS" framework β€” roughly 900 KB of JavaScript plus its CSS β€” is removed in favour of a generated 35-glyph sprite. An initial sweep plus a MutationObserver replaces <i class="fas fa-name"> with an inline <svg><use href="img/icons.svg#fa-name"/></svg>, carrying over non-class attributes and modifier classes such as fa-spin. Each icon keeps its natural aspect ratio, and CI verifies that every icon referenced in the markup is listed in the manifest and that the generated sprite is not stale (#6145)
  • HAVE_ED25519 scoped to OpenSSL key parsing (incompatible): All Ed25519 crypto in the DKIM path is libsodium, which is a mandatory dependency β€” verification, signing, key sizes and the public/private match never touch OpenSSL. HAVE_ED25519 nevertheless probed OpenSSL for EVP_PKEY_ED25519 and gated that libsodium code, so an OpenSSL build without Ed25519 refused to verify ed25519 signatures even though libsodium was fully capable of it. The probe is kept at the single site that genuinely needs it β€” unwrapping a PEM/DER private key for signing β€” and dropped from the nine libsodium-only sites

πŸ”§ Fixed

  • DKIM public key size and modulus width: rspamd_dkim_make_key() took the p= tag of a DNS TXT record, checked only that it held at least three bytes, then allocated two buffers of that size and handed the decoded result to OpenSSL. Since rdns falls back to TCP on a truncated reply, a zone can serve roughly 64k of key material per selector, and the LRU key cache is capped by entry count rather than by bytes β€” so a hostile zone could pin hundreds of megabytes with one message per selector. p= is now capped at 4096 base64 characters before any allocation (a 4096-bit RSA key needs 736), and keys wider than 8192 bits are rejected once parsed, since verification cost grows with the modulus and RFC 8301 only requires verifiers to handle up to 4096 bits. Only verification is affected; signing keys take a different path
  • DKIM empty-line skipping read before the body slice: The backwards scan for trailing empty lines guarded its lookbehind, but the else arm of that guard then read one byte before the start of the slice anyway β€” reachable from any body consisting only of line endings. The byte in question is the last byte of the header/body separator, so it stayed inside the message allocation, but the canonicalised result depended on data outside the input. The probe is replaced by assigning the empty-body sentinel the function already uses; a differential run over every body up to four bytes drawn from CR, LF, space, tab and a letter gives identical results for both canonicalisation types across all 1560 cases, so no body hash changes
  • DKIM max_sigs applied before work, and every signature counted: The per-message signature limit was tested at the bottom of the loop body, after the DNS request for the current signature had already been started, and it incremented before comparing with >, so the default of five permitted six. Worse, the three continue paths above it β€” parse failure, trusted_only skip and a failed key lookup β€” jumped over the increment, so signatures taking those paths never counted at all and the real bound was max_mime_headers (100000). The check now runs at the top of the loop, before any work is done for the header, and counts every signature header examined. This shifts the meaning of max_sigs from "signatures whose key we looked up" to "signature headers examined": five junk signatures ahead of a valid one now push the valid one out, which was already reachable with cheap parseable decoys
  • DKIM h= header list bounded: rspamd_dkim_parse_hdrlist_common() counted every token in h=, preallocated that many pointers and then allocated a name string and a header record per non-empty token, so a size-limited message expanded into roughly 32 bytes of allocation per two input bytes. The two loops used different predicates, so an h= made only of colons reached the full preallocation while creating no items β€” the cheapest form of the amplification. The item count also drove the header selection loop, where each entry costs a hash lookup plus a duplicate walk. The list is now capped at 1000 items, checked before any allocation; the shipped default_sign_headers list has 27 entries. Both parsing directions share this function, so signing is bounded too
  • SPF returns permerror when a DNS limit is hit: Exceeding max_dns_requests or max_dns_nesting only left the offending element unparsed, so the record was still evaluated with whatever fitted in the budget and usually ended up as a definitive fail via its trailing -all. RFC 7208 Β§4.6.4 requires permerror instead, and rightly so: the record has not been evaluated to the end, hence its verdict is unknown rather than negative. Records carrying any flag are not put into the LRU cache, so a permerror is not stored for the TTL of the record
  • SPF include/redirect nesting limit enforced: rec->nested was checked but never incremented, so max_dns_nesting had no effect at all and an include chain was bounded only by the DNS request counter β€” which was itself compared with > while being incremented after the check, so 31 DNS elements were evaluated with a limit of 30. The depth is now stored per resolved element (resolution is asynchronous, so there is no call stack reflecting position in the tree) and checked when an include or redirect is about to be followed, and the request counter uses >= so exactly max_dns_requests elements are evaluated. Flattening a record recurses over the same chain and is bounded by a hard nesting limit that applies even when the configurable limits are relaxed or disabled
  • SPF address lookups from mx/ptr expansion bounded: Names returned by mx and ptr replies were expanded to A/AAAA requests while checking a DNS counter that never changed, and SPF uses the forced resolver API that bypasses dns_max_requests, so a single large RRset produced an unbounded number of queries. Both RFC 7208 Β§4.6.4 limits are now enforced β€” 10 address lookups per mx/ptr element β€” alongside a new per-record budget for all expansions, configurable as max_dns_expansions (100 by default). Exceeding them for mx, or running out of the record-wide budget, yields permerror for the whole record instead of silently dropping the element (which used to surface as R_SPF_FAIL via -all); extra ptr names are ignored as the RFC requires
  • SPF exists attached to the wrong element: parse_spf_exists picked the current record's last resolved element, which only holds while nothing else has appended an element of its own β€” an include earlier in the same record does exactly that, so from then on every exists in that record was attached to the include's element. The element is now passed down as the other mechanisms already do. Since the domain spec of an exists is expanded before this point, the visible effect so far was limited to a wrong domain in the diagnostics of an unresolvable exists
  • Fuzzy admission checked before parsing UDP commands: Every datagram used to be parsed, and decrypted when encrypted, before any admission check ran β€” the blocklist and the rate limit were only consulted well after a session allocation, an ECDH, a MAC verification and the Lua pre-handlers. The blocklist is now checked in the UDP read loop before allocating a session, so a blocked peer costs one radix lookup; a failed key lookup or MAC check is logged at debug rather than error level, since both are reachable by anyone who can send a datagram and an error line per packet turned a spoofed-source flood into a log volume attack. The per-source rate limit is extended from FUZZY_CHECK to PING and STAT, which are answered unauthenticated and were previously unmetered, and rate-limited PING/STAT are dropped rather than answered. The ratelimit bucket allocation is also skipped when rate or burst are unset, which previously allocated an LRU entry per masked source that could never limit anything. blocked_requests, decrypt_errors and ratelimited_requests are reported in fuzzystat so the drops that are now silent stay observable
  • Fuzzy sqlite backend id stack overread: The backend id was built with %xs, which treats its argument as a NUL-terminated string and calls strlen on it β€” but the hash is a raw 64-byte digest with no terminator, so building the id read past the end of the stack buffer on every sqlite backend open. An explicit length is now passed
  • Fuzzy error replies name the rule and the server: 403 (rate limit), 503 (access denied) and 415 (encryption required) replies used to insert their symbols silently, so with several rules configured there was no way to tell from the logs which rule and which server produced the error. Both the UDP and TCP reply paths now log the rule name and the upstream
  • Nested comment depth in the ragel header parsers: The Content-Disposition and SMTP date grammars parse RFC 5322 nested comments via fcall, which pushes an entry onto a heap-allocated state stack per nesting level. The stack had no depth limit, so a single header allocated memory proportional to its own length β€” roughly 12 bytes per input byte, with unbalanced opening parens as the worst case β€” and bounded only by max_message, a crafted header could push a worker to around 530 Mb of transient allocation, where a realloc failure hit an assertion and aborted the process. Nesting is now capped at 8 levels and the parser drops into the error state once it is exceeded, so it stops after a fixed number of bytes rather than consuming the whole header; realloc failure now fails the parse and keeps the old allocation
  • MIME parser, MIME headers and archive metadata resource bounds: All three now bound the resources a single message can make them allocate
  • Content-Type parameters per header, and RFC 2231 ordering: A single Content-Type or Content-Disposition field can carry an unbounded number of parameters, each materialising a pool object plus a hash or list entry, so a message bounded at max_message could amplify into millions of allocations from one folded header. The existing header-count cap limits header fields, not parameters within a field. Parameters are now capped at 1024 per header, counted rather than deduplicated so a same-name flood is bounded too; Content-Type flags the truncation as broken. Also fixed: rspamd_cmp_pieces subtracted two unsigned RFC 2231 continuation ids and truncated to int32_t, reversing the reconstruction order when the ids differ by more than INT32_MAX
  • Per-part newline metadata bounded, budget made task-global: Newline normalization recorded every newline as a pointer array entry plus a mempool exception object and a GList node, then sorted the whole exceptions list, so a newline-dense body turned a few MiB of input into hundreds of MiB of heap. Recorded newline positions are capped at 100k per part β€” text normalization and line counters are unaffected β€” with a part flag on truncation exposed as newlines_truncated in textpart:get_stats(), and the budget is now task-global rather than per-part
  • Plain text parts linked to their HTML alternative: The alternative part search compared the raw IS_TEXT_PART_HTML flag value (0 or 4) against the boolean want_html argument (0 or 1), so a text/plain part never matched its text/html sibling and only the html-to-plain direction of alt_text_part was ever populated
  • Alt-part linking and fasttext langdet cost bounded: Alternative text part linking used one subtree scan per text part, so a crafted multipart/alternative with thousands of sibling text parts made linking quadratic β€” around 100M sibling visits at the MIME part cap. All searches of a task now share a single visit budget, and the remaining parts are left unlinked with a warning once it is exhausted. Fasttext language detection fed up to 1M words into the model with no bound on word length or resulting token count; since the UTF tokenizer intentionally does not enforce max_word_len, a body made of huge unspaced words expanded into three or four subword ngram ids per character. Overlong words are now skipped, the total token count is capped, and the shim refuses OOV subword expansion for words longer than any real dictionary entry
  • HTML DOM recursion eliminated: html_append_tag_content, traverse_block_tags and html_debug_structure are converted from native-stack recursion to explicit heap stacks, and html_tag::depth is maintained incrementally instead of walking the parent chain per tag. With max_tags at 8192 a message can legally nest around 8190 elements, which overflowed 512KB worker-thread stacks; a regression test parses 8190 nested divs
  • HTML attributes bounded per tag and per task, and synthetic tags capped with a fix to the tag balance loop
  • HTML image style dimension parsing: The parser found a digit but called rspamd_strtoul from the start of the substring, ignored its return value and assigned the result regardless. For substrings longer than 22 characters rspamd_strtoul returns before writing the value, leaving image height and width set from an uninitialized variable; shorter CSS parsed to zero because the substring began with a colon or whitespace. The digit run is now parsed from the located digit with a bounded length, and assigned only on success
  • Quadratic Content-ID image linking: Linking images to their parts no longer scales quadratically with the number of parts
  • 7zip folder count bounded and parser progress guaranteed: The folder-count limit was applied only in the internal-folders branch, so the external branch preserved an unchecked 64-bit count; the later kCodersUnPackSize loop's error branch then logged once per folder without consuming input, allowing a non-progressing CPU and log loop that the synchronous parser cannot be interrupted from by the task timeout. The limit now applies immediately after reading the folder count, external-folder metadata is rejected rather than accepted with inconsistent downstream state, the error branch returns early so every loop iteration advances or bails out, and the folder tables move from stack allocation to the task mempool
  • max_urls enforced at the central insertion boundary: All URL sources β€” HTML links, query URLs, images, displayed URLs, subject URLs and Lua-injected URLs β€” now respect the configured max_urls limit rather than just plain-text callbacks, and the text pre-checks are exact rather than off by one. A duplicate part-url add for newly-seen text URLs is also removed
  • url_suspect dead branch and mailto URLs: Both arms of the length_thresholds.suspicious branch inserted the very same URL_USER_PASSWORD finding, so the threshold never affected anything; the branch and its now-unused setting are removed. mailto URLs are also rejected explicitly in the same check, since every email address carries a local part and the presence of a user is meaningless there
  • Task zstd decompression bounded by max_message: The legacy compressed-message path allocated the output buffer straight from the frame-advertised decompressed size and doubled it without any ceiling, so a zstd bomb within the HTTP size limit could balloon memory far beyond max_message; the decompression-error return also leaked the output buffer, which was registered with the task pool only on success. Frames advertising more than max_message are now rejected upfront, the growth loop enforces the limit, and the buffer is freed on all error returns. The same upfront clamp is applied to the v3 multipart path, which now also honours max_message == 0 as unlimited, matching the HTTP layer convention
  • One bounded zstd decompression helper across HTTP, proxy and maps: The streaming decompression loop was copy-pasted in seven places with inconsistent bounding. The proxy had no output ceiling at all β€” on request and response bodies and on the v3 multipart result and body parts, a zstd bomb from a client or backend could balloon memory without limit. The task and v3 protocol paths were bounded but enforced the limit only when the buffer filled, letting output overshoot max_message up to twofold, and never validated the final length; the map shm-cache, file-cache and static paths grew their buffers with no ceiling. A single rspamd_zstd_decompress_bounded now validates the frame-advertised size before any allocation, caps buffer growth strictly at the limit, drains pending decoder output and frees the buffer on every error path. All seven sites are converted: max_message bounds the task, protocol v3 and proxy paths, max_map_size the map cache and static paths
  • HTTP request bodies bounded on the controller, proxy and control sockets: The normal worker caps HTTP bodies at max_message, but the controller router, the proxy client connection and the main control socket never set a maximum β€” a declared Content-Length caused an unbounded preallocation and chunked input could grow until the connection ended, all before routing or authentication. The HTTP router gains a max_size knob applied to every accepted connection, with max_message set as the cap on all three; oversized requests are rejected with 413 on the controller and a closed connection on the proxy
  • HTTP read deadline enforced when data is pending at timer expiry: The read timer is a one-shot timer. When it expired while data was already waiting in the socket, the handler drained and parsed that data, and if the message remained incomplete the connection continued with only the I/O watcher active and no deadline at all. A 408 is now reported instead, unless the drained bytes completed the message
  • Remote HTTP map sizes bounded, compressed and decompressed: The map client connections never set a maximum body size, so a map server could feed an arbitrarily large body that is fully retained in shared memory, and the zstd path allocated the output buffer from the frame-advertised size and then doubled it without limit. A new max_map_size option (256 Mb by default, 0 disables) is applied as the HTTP body limit on both map client connections, frames advertising a larger decompressed size are rejected upfront, and the same ceiling is enforced in the streaming growth loop. It can be overridden per map with max_size in the maps { } block, like the existing timeout and keepalive knobs
  • Map signature file mapped as a file: rspamd_map_check_file_sig() receives a filesystem path and maps the .pub counterpart as a file, but reached for the .sig one via the shared memory helper β€” which on any platform with POSIX shared memory means shm_open("/path/to/map.sig"), rejected outright by the flat shmem namespace, so verification of a signed file-backed map could never succeed there
  • Credentials redacted from map error logs: Map URIs may carry HTTP basic-auth userinfo, which was logged verbatim in critical messages that feed the /errors ring buffer exposed via the controller, leaking credentials to anyone reading it. Error logs now use a redacted copy with the userinfo replaced; the real URI is kept intact, since it serves as the configuration key and carries the userinfo needed for the Authorization header (#6146)
  • Shared memory mapping helpers hardened: rspamd_shmem_xmap() mapped whatever the name resolved to, so a fifo, a device or a directory ended up in mmap(2) with a meaningless size, and the size output was left untouched on failure while its file counterpart has always reported a defined value. Non-regular and empty segments are now rejected explicitly, the 32-bit address space overflow is guarded, the failing errno is kept intact across close(2), and the size output has the same meaning as in the file variant. rspamd_shmem_mkstemp() also retried shm_open(2) forever on EEXIST, so the loop is now bounded and a permanently colliding namespace cannot spin a worker
  • lua_http responses bounded by default: Lua HTTP client responses were unlimited unless a plugin explicitly passed max_size, and a negative max_size was converted straight to an unsigned type, silently producing an effectively unlimited cap. A new max_lua_http_response option (256 Mb by default, 0 disables) is applied whenever a request does not specify max_size; an explicit max_size of 0 still disables the limit per request, and negative values are rejected with a logged error instead of wrapping
  • lua_http errors delivered to coroutine callers: DNS-stage failures β€” resolve error, no records, connection refused β€” went through a path that unconditionally invoked the callback reference, but coroutine-style requests have no callback, so the error was swallowed and the yielded thread never resumed. The thread is now resumed with (err, nil), mirroring the connection error path. Synchronous failures (unparseable URL, blocked session, immediate connection or DNS-send failure) returned a bare false, which coroutine callers read as err = false, response = nil β€” a crash on response.code for every caller checking if not err; those now return (err, nil) as well
  • lua_tcp double free on connection release: A Lua callback calling close() sets a sticky finished flag observed by more than one release site, dropping the connection ownership reference twice and freeing the connection data while the handler was still running. The ownership drop is now guarded by a one-shot flag so the first site to observe termination wins and the rest become no-ops
  • PDF text line breaks: The Td/TD text positioning operators never produced a newline β€” the grammar did not capture the operator name, so the handler compared a numeric operand against the string Td (never true) and additionally read the wrong operand. Consecutive text lines were therefore concatenated, so for example "…June 1, 2026." followed by "You may obtain…" became "2026.You", which the URL parser mis-detected as http://2026.you and raised false positives. The operator is now captured and a newline rendered when the vertical displacement is non-zero
  • Duplicate module sections warned about: rspamd_config_get_module_opt() returns the rule or option object from the first section only when a module section is duplicated at the top level β€” for example by a stray file in modules.d β€” so the remaining sections are silently ignored and their rules are never loaded. An explicit warning is now emitted per configured C module, so configtest and the startup log surface the problem instead of silently dropping rules. Lua modules are unaffected, since they flatten the whole duplicate chain
  • WebUI: malformed /stat response routes to the login dialog: The connect probe swallowed JSON parse errors, so a 2xx /stat with a non-JSON body β€” truncated by a proxy, a captive-portal page, corruption β€” loaded an empty, half-loaded main UI with a "Cannot get server status" alert and no login path. The body is now parsed explicitly and any failure shows the connect dialog, matching the pre-migration behaviour. The unreachable 304 success branch inherited from jQuery's default success range is also removed, since the controller sends Cache-Control: no-store and never emits Last-Modified or ETag for these endpoints
  • WebUI: smooth scroll restored on the Scan tab: The jQuery removal replaced the animated scroll with window.scrollTo({behavior:"smooth"}), which honours prefers-reduced-motion and is interruptible by Tabulator's reflows, so the scroll frequently did not happen. A requestAnimationFrame-based implementation (400 ms ease-in-out, matching jQuery's default) is used for both Scan scrolls, preserving the previous behaviour
  • WebUI: refresh spinner no longer restarts mid fan-out: The active-request counter was decremented at the top of the completion handler, before the success callback, so during an "All SERVERS" query the count dipped to zero between the neighbours probe and the per-neighbour requests, re-firing the start hook and restarting the spinner's CSS animation. The decrement moves to the end, after the success and complete callbacks
  • WebUI: jQuery-removal regressions: The optional fuzzy_hashes key is guarded in the fuzzy storage stats, which threw on every Status refresh when fuzzy_check was disabled or a neighbour was down; the hide and show slide animations are cancelled once the final state is committed, so a pinned height no longer makes a later re-show animate 0β†’0; a single element exposing a numeric length (such as <select> or <form>) is treated as one node rather than a collection; a null neighbours body is tolerated; the active-tab lookup is null-guarded in two spots; and a dead upload parameter is dropped

πŸ›‘οΈ Security

  • DKIM bh= length not bounded before the body hash comparison (out-of-bounds read): The bh= length validation covered RSA and ECDSA but omitted ed25519, so an ed25519 signature carried a fully attacker-controlled body hash length β€” parse_bodyhash() sizes the buffer from the base64 input and stores whatever length decoding yields. That length was then passed to memcmp() against buffers that are always EVP_MAX_MD_SIZE, reading out of bounds once bh= decodes to more than 64 bytes. The comparison runs before the key type and signature algorithm compatibility checks, so a mismatched key record does not prevent it; an attacker only needs a parseable DKIM key at a domain and selector under their control, and since the overread scales with the header size it can walk off the stack rather than merely tripping a sanitizer. Ed25519 is added to the parse-time length check, and the comparison site is additionally guarded by an algorithm-agnostic length equality test so a future algorithm cannot reopen the same hole by missing a parse-time case
  • Fuzzy TCP session use-after-free on aborted connections: Every terminal condition on a fuzzy TCP connection β€” EOF, read error, invalid frame length and the timeout β€” simply released the session, which is not enough: each in-flight command retains the session too, so with commands outstanding the refcount stayed nonzero, the destructor did not run and both watchers remained armed. EOF is permanently readable, so the next poll re-entered the I/O handler and released the owner reference a second time, freeing the session while the command callbacks still pointed at it; the timeout had the same defect, being a repeating timer. A single guarded termination path now stops the watchers, closes and poisons the descriptor and drops the owner reference exactly once, and late replies for a closed session are discarded instead of resurrecting I/O on a dead connection. A fatal write error, previously conflated with a retryable short write, left the write watcher armed on a permanently writable error condition and spun forever; such a session is now closed. Reproduced under ASAN by aborting a connection while its backend lookup was still pending
  • Shared memory segment bounds not validated (protocol): Shm-Offset and Shm-Length were each checked against the segment size on their own but never together, so an offset near the end combined with a full length passed both checks and produced a body running past the mapping β€” SIGBUS on a page-multiple segment, adjacent memory in the scan result otherwise. Both protocol loaders carried their own copy of the code and hence of the bug. On platforms without POSIX shared memory the segment name is an ordinary path, and open(2) on a name that happens to be a fifo blocked the whole worker until somebody opened the writing end. Both versions now share one validated helper that opens non-blocking and rejects empty, over-long and control-character names, non-numeric or overflowing offsets and lengths, non-regular and empty segments, offset plus length beyond the segment, and payloads over max_message. An offset without a length now means "up to the end of the segment" rather than "the whole segment", and the v3 metadata path rejects negative values and no longer leaks an fstring plus a token per request
  • Shared HTTP body storage lifecycle (double free, descriptor leak, closing an unrelated socket): The proxy cleared the shared-memory flag by hand before installing a new body, so the following set_body() cleaned up the storage as if it were an fstring and freed the refcounted shared storage the union aliases, while the segment descriptor and its mapping leaked. A new helper releases the shared storage while the flags still describe it. The storage cleanup also skipped a segment sitting on descriptor 0, leaking both the descriptor and the mapping whenever the kernel handed us that one β€” and releasing it uncovered why that looked harmless: a message can carry the shared flag before a segment exists for it, since a body-less copy inherits the flag from its origin, and the zero then read as a valid descriptor and closed whatever occupied slot 0, which in a proxy worker is the accepted client socket. The descriptor is now initialised to -1 so it is only ever valid for a real segment. Also: the name is reset after release so the next cleanup cannot release it twice, an fstat(2) hidden inside an assertion is dropped, non-regular and empty descriptors are rejected, the length overflow in body growth is guarded, and a copied message's body is validated to fit the segment it is mapped from

This release is dominated by hardening: a critical out-of-bounds read in DKIM body hash comparison reachable with an ed25519 signature, a use-after-free on aborted fuzzy TCP connections, unvalidated shared memory segment bounds in the protocol, and a shared HTTP body storage lifecycle bug that could close an unrelated socket in a proxy worker β€” alongside a broad sweep of resource bounds across the MIME, HTML, archive, URL, zstd, HTTP and map paths that previously let a size-limited message amplify into hundreds of megabytes of allocation. SPF and DKIM also gain real correctness fixes: nesting and DNS limits that were checked but never enforced, permerror where a fail used to be reported, and ed25519 verification that now works on OpenSSL builds lacking it. On the feature side, fuzzy hashing becomes genuinely observable β€” structured match results in Lua, ClickHouse and the milter header, per-hash storage introspection, persistent shingle sets and a rebalanced probability weight curve β€” and the WebUI completes its migration off jQuery and Font Awesome. Recommended upgrade for all users.