Patch Release Fixing a Controller Authentication Fail-Open, Regexp Engine Resource Bounds, and jemalloc Startup Segfaults
list_extractors, list_transforms and check_selector endpoints are now open to read-only users, matching the policy already used for maps — they only return static catalogs or parse a selector without executing it. check_message remains privileged, since it runs a user-supplied selector against a user-supplied message (#6157)/errors endpoint and its WebUI table were gated behind the privileged password. Any authenticated user can now view them: the endpoint returns only Rspamd's internal operational error log (timestamp, pid, level, module, message) — no message content, PII or secrets — while read-only users already see strictly more sensitive data in the History tab. Authentication is still required and the endpoint mutates nothing (#6156)jemalloc_pic before jemalloc, so on Debian it preferred the static libjemalloc_pic.a over the shared library and linked it into the common library list used by nine shared objects. Each of them ended up with a complete private allocator holding its own arenas and extent map, so memory obtained through one copy could not be released through another — and jemalloc-enabled packages segfaulted at startup. CMake now searches for the shared library only, fails the configure step when just a static archive is available (except in fully static builds), and attaches jemalloc per target: librspamd-server — the only code calling mallctl() — plus each executable, pinned with --no-as-needed so the linker keeps the otherwise-unreferenced dependency and libc cannot win the malloc() lookup. The malloc_conf tuning moves into the server library, where it actually takes effect with a shared jemalloc (an executable exports nothing without -rdynamic, so it had been silently lost) and now applies to every rspamd binary rather than just the daemon. The jemalloc configuration is also built and tested in CI now, mirroring the shipped Debian/Ubuntu amd64 packages, which had never been covered before (#6155, #6153)rspamd_regexp_search() allocates the PCRE2 match data before the JIT UTF-8 validation guard, but the early return on invalid input skipped the free at the function tail, leaking one match-data block (sized to the capture count) per rejected input. The guard is entered by any ordinary regexp — it tests whether a separate raw pattern was compiled, not whether raw matching was actually selected — and the regexp cache deliberately routes raw matching of UTF-marked classes through PCRE, so a message with a non-UTF-8 text part reached the leak through the normal scan pipeline, once per such regexp per partpcre2_set_depth_limit over the deprecated recursion-limit call where available. The PCRE1 path had no explicit limits at all — they live in the pcre_extra block, and pcre_study legitimately returns NULL when there is nothing to optimise — so a block is now allocated when needed and both match and recursion limits are set, with a much lower depth since PCRE1 backtracks on the C stack. Two PCRE1 drive-bys: study data is freed unconditionally rather than only in JIT builds, and the raw pattern is now studied for the raw matcher instead of handing it study data from a different compiled patternre:search(), re:split() and the regexp cache loop all guard against this, but re:matchn() did not — with unlimited max matches, a pattern that can match the empty string spun forever and hung the worker. This was reachable from configuration: the spamassassin plugin passes unlimited for any multiple rule without maxhits, so one SA rule with an empty-matchable pattern wedged the process. The same cursor-progress guard is now appliedrspamd_regexp_new_len() parses within the given pointer and length, but id generation then called strlen() on the same pointer. Callers passing a slice of a larger buffer — the composites expression parser and the symcache delayed symbols — read past the end of the pattern, a heap-buffer-overflow under ASAN whenever the slice is not NUL-terminated. The identity was wrong even when the read stayed in bounds: the same regexp got different ids as a slice and as a standalone string, while two different slices sharing a start address got the same id and collided in the regexp cache, which compares ids only. The length is now passed down to id generation, and three error paths that printed the raw pointer are bounded as well. Ids produced by rspamd_regexp_new() and the regexp cache are unchangedregexp.max_size was applied to the default scope only, so regexps registered in a named scope — multimap regexp_rules, for instance — were matched against unbounded input while the default scope was capped at 1 MiB. Named scope caches are now created inheriting the default scope limit, and setting the limit updates every scope registered so far, which covers both orderings: Lua scopes registered before the regexp module is configured, and plugins registering scopes during filters initmax_email_user, the parser consulted the Lua URL filter on every subsequent byte, and each call re-scanned the whole prefix — control characters, UTF-8 validation, @ counting, custom filters. The 512-byte user limit in Lua never applied here because the mid-parse fragment contains no @ yet, so this ran up to the 16 KB threshold: quadratic scanning plus thousands of C-to-Lua transitions, turning a single 16 KB URL into ~0.6 s of CPU. The filter is now consulted at most twice per user field — once when the limit is first exceeded, and once when the field ends at : or @ so custom filters still see the complete userinfo. Parsing results are unchanged; the same URL now takes about a millisecondnext_token() called itself after each comment, so an input made of many comments in a row consumed stack proportional to their count. The parser's own recursion limit does not cover this path, and the self-call is only elided when the compiler applies the tail call — a debug or ASAN build keeps a real call and overflows the stack. The tokenisation loop is now iterative, continuing from the offset past each comment, with doctest coverage for leading, trailing, interleaved, nested and unterminated comments plus a 100k-comment stress caseacism_create() allocated its buffers with GLib but released them with libc free(), which only works while both resolve to the same allocator. With the duplicated per-object jemalloc copies described above, free() inside the actrie library ran one jemalloc copy's deallocator on a pointer owned by whichever allocator GLib had bound to, segfaulting inside rspamd_url_init() during startup. The whole lifecycle now stays inside GLib, so the pair resolves to one allocator no matter how symbol interposition falls out (#6153)servers = "service=fuzzy+example.com" creates a placeholder that is only selectable after asynchronous resolution — which was never scheduled. Every fuzzy storage configured that way looked empty: rspamadm fuzzy_ping -l printed no servers and pinging failed with "no fuzzy storage upstream available". The library is now configured right after the resolver is created, before any command loads its configuration, and since one-shot commands issue their first request before the resolve timer fires, a new lua_fuzzy.wait_for_storages() — used by fuzzy_ping and fuzzy_hash — waits for the selected rules to have servers, bounded by the request timeout; an explicit -s/--server override skips the wait as it bypasses the configured upstreams. A ping that cannot start now also reports the rule name instead of nilrspamd_is_encrypted_password() validated only the $<id> prefix of a configured password, not that a salt and a key follow it. A hash such as $1$ therefore passed the check, reached the verification with both components absent, skipped the comparison block entirely and returned its initial value of TRUE — any supplied password authenticated, and was then cached as the valid one. The result is now initialised to FALSE and set only after a completed KDF comparison, and the malformed hash is logged instead of silently accepted. The component extraction also indexed three bytes into the string without checking its length, reading past the terminator for short inputs and making the fail-open depend on adjacent heap bytes; it now takes the length and bails out when a component is missing. The startup sanity check re-ran the same prefix-only test, so a malformed password also passed configtest silently — it now decodes the salt and the key and validates their lengths against the pbkdf matching the hash id, which additionally fixes a hardcoded reference to the first pbkdf in the listThis release closes a critical controller authentication fail-open — a malformed password hash such as `$1$` accepted any password, and configtest did not catch it — and fixes the startup segfault in jemalloc-enabled packages by linking one shared allocator instance per process instead of a private copy per shared object, a configuration that is now also exercised in CI. The regexp engine gets a broad hardening pass: an explicit heap bound for a single match on both PCRE2 and PCRE1, a match-data leak on invalid UTF input reachable from the normal scan pipeline, an infinite loop in `re:matchn()` on empty-matchable patterns, an out-of-bounds read when generating ids for bounded patterns, and the data limit finally applied to named scopes. Rounding it out: quadratic URL parsing with per-byte Lua filter calls is gone, tokenizer word retention is bounded by a message-wide budget, the CSS tokeniser no longer overflows the stack on comment floods, and rspamadm handles SRV-based fuzzy upstreams. Recommended upgrade for all users, and strongly recommended for anyone running the controller with worker passwords.