Hardening Release with DKIM/SPF Correctness Fixes, Parser Resource Bounds, Fuzzy Hash Observability, and a jQuery-Free WebUI
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 codessqrt(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 curveS 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 deleterspamadm 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 synchronouslyallow_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-C), and queries storages for explicit hex digests (-H) β printing found and queried digests, probability, flag, timestamp and whether the storage confirmed the matched digestFuzzy.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 forensicsmilter_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 existrspamd_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 = falsemessage/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 blockXMLHttpRequest, 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)<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 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 sitesrspamd_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>, 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 decoysrspamd_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 toomax_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 recordrec->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 disableddns_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 requiresparse_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 existsblocked_requests, decrypt_errors and ratelimited_requests are reported in fuzzystat so the drops that are now silent stay observable%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 passedfcall, 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 allocationmax_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_MAXnewlines_truncated in textpart:get_stats(), and the budget is now task-global rather than per-partIS_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 populatedmax_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 entryhtml_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 divsrspamd_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 successkCodersUnPackSize 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 mempoolmax_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 removedlength_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 theremax_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 conventionmax_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 pathsmax_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 proxymax_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 knobsrspamd_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/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)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 workermax_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(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 wellclose() 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-opsTd (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-zerorspamd_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/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 endpointswindow.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 behaviourfuzzy_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 droppedparse_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 caseShm-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 requestset_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 fromThis 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.