]> git.ipfire.org Git - thirdparty/rspamd.git/log
thirdparty/rspamd.git
10 days ago[Feature] fuzzy: track unkeyed clients in fuzzystat 6151/head
Vsevolod Stakhov [Fri, 24 Jul 2026 18:18:50 +0000 (19:18 +0100)] 
[Feature] fuzzy: track unkeyed clients in fuzzystat

Traffic from clients without a key (e.g. permitted by allow_update)
was invisible in the key statistics, which makes storage writers
untraceable. Track such clients under a dedicated 'unkeyed'
pseudo-key with the same aggregate and per-IP counters as regular
keys.

10 days ago[Feature] fuzzy: per-hash introspection via rspamadm control fuzzyhash
Vsevolod Stakhov [Fri, 24 Jul 2026 18:18:47 +0000 (19:18 +0100)] 
[Feature] fuzzy: per-hash introspection via rspamadm control fuzzyhash

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 persisted shingle set,
slot ownership (owned/foreign/vacant out of total). Ownership directly
answers the key false-positive triage question: can this hash still
produce fuzzy matches, or has its shingle anchor decayed?

Implemented as a new inspect backend API: the redis backend gathers
everything in a single server-side script round trip; sqlite queries
the digests and shingles tables synchronously. The worker control
handler replies asynchronously from the backend callback (the control
pipe is persistent) using the same fd-attachment mechanism as
fuzzy_stat.

10 days ago[Feature] fuzzy: persist shingle sets with digests in redis
Vsevolod Stakhov [Fri, 24 Jul 2026 18:18:44 +0000 (19:18 +0100)] 
[Feature] fuzzy: persist shingle sets with digests in redis

Store the shingle key suffixes as the 'S' field of the digest hash on
every ADD, making each digest self-describing. This fixes two
long-standing blind spots:

- DEL: digest-only deletions (e.g. periodic deletions by a hash list)
  carried no shingle information, so the digest's shingle slots were
  left orphaned; now they are reconstructed from the persisted set and
  removed when still owned by the deleted digest.
- REFRESH: the refresh used to touch the shingle slots of the message
  that happened to trigger it (a different message by definition);
  now it 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: its shingles table references
digests with ON DELETE CASCADE and foreign keys are enforced.

10 days agoMerge pull request #6150 from rspamd/vstakhov-fuzzy-observability
Vsevolod Stakhov [Fri, 24 Jul 2026 17:57:20 +0000 (18:57 +0100)] 
Merge pull request #6150 from rspamd/vstakhov-fuzzy-observability

Fuzzy check: hash observability (structured results, matched-digest flag, rspamadm fuzzy_hash)

10 days agoMerge pull request #6148 from rspamd/vstakhov-jmrp-arf-enrichment
Vsevolod Stakhov [Fri, 24 Jul 2026 17:48:23 +0000 (18:48 +0100)] 
Merge pull request #6148 from rspamd/vstakhov-jmrp-arf-enrichment

[Feature] lua_feedback_parsers: enrich sparse ARF reports from original headers

10 days agoMerge pull request #6149 from rspamd/vstakhov-fuzzy-prob-curve
Vsevolod Stakhov [Fri, 24 Jul 2026 17:48:05 +0000 (18:48 +0100)] 
Merge pull request #6149 from rspamd/vstakhov-fuzzy-prob-curve

Fuzzy check: configurable probability weight curve and better diagnostics

10 days ago[Feature] rspamadm: add fuzzy_hash command 6150/head
Vsevolod Stakhov [Fri, 24 Jul 2026 17:29:11 +0000 (18:29 +0100)] 
[Feature] rspamadm: add fuzzy_hash command

rspamadm fuzzy_hash 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/queried digests, probability, flag, timestamp and whether the
storage confirmed the matched digest.

10 days ago[Feature] clickhouse: export fuzzy match details
Vsevolod Stakhov [Fri, 24 Jul 2026 17:29:10 +0000 (18:29 +0100)] 
[Feature] clickhouse: export fuzzy match details

Add Fuzzy.Hash/Fuzzy.Queried/Fuzzy.Rule/Fuzzy.Prob/Fuzzy.Flag nested
columns (schema version 11) filled from structured fuzzy results, so
false positive investigations can start from the scanner-side journal
instead of storage forensics.

10 days ago[Feature] milter_headers: annotate fuzzy hashes header
Vsevolod Stakhov [Fri, 24 Jul 2026 17:29:07 +0000 (18:29 +0100)] 
[Feature] milter_headers: annotate fuzzy hashes header

X-Rspamd-Fuzzy now uses structured fuzzy results: every matched hash
is annotated with the rule, flag, probability, the queried hash for
non-exact matches and the storage timestamp. Falls back to the legacy
fuzzy_hashes pool variable when no structured results exist.

10 days ago[Feature] fuzzy_check: structured match results and diagnostics API
Vsevolod Stakhov [Fri, 24 Jul 2026 17:29:04 +0000 (18:29 +0100)] 
[Feature] fuzzy_check: structured match results and diagnostics API

Every fuzzy match now produces a structured record (rule, symbol,
upstream, stored and queried digests, type, probability, score, flag,
value, 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 the fuzzy_checked variable. 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 contains the digest actually resolved by the
backend; the client propagates it as the 'confirmed' field, removing
the guesswork about echoed queries when talking to legacy storages.

Add 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.

10 days ago[Feature] fuzzy_check: anchor probability weight curve at the match threshold 6149/head
Vsevolod Stakhov [Fri, 24 Jul 2026 15:34:34 +0000 (16:34 +0100)] 
[Feature] fuzzy_check: anchor probability weight curve at the match threshold

The score multiplier for non-exact matches was sqrt(prob): a concave
curve anchored at zero. Since the storage never returns prob below the
shingle match threshold (0.5), the whole reachable range collapsed into
(~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.

Replace it with ((prob - prob_bias) / (1 - prob_bias)) ^ prob_power,
anchored at the match threshold (prob_bias 0.5). prob_power defaults to
1.0: the multiplier is 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
sqrt curve is not preserved: it was simply broken. Applies to text and
HTML hashes; small images keep their existing normalized curve.

11 days ago[Fix] fuzzy_check: log rule and server for error replies
Vsevolod Stakhov [Fri, 24 Jul 2026 15:27:56 +0000 (16:27 +0100)] 
[Fix] fuzzy_check: log rule and server for error replies

403 (rate limit), 503 (access denied) and 415 (encryption required)
replies used to insert their symbols silently; with several rules
configured there was no way to tell from the logs which rule and which
server produced the error. Log an info message with the rule name and
the upstream in both the UDP and TCP reply paths.

11 days ago[Fix] cfg: warn when a module section is defined multiple times
Vsevolod Stakhov [Fri, 24 Jul 2026 15:27:46 +0000 (16:27 +0100)] 
[Fix] cfg: warn when a module section is defined multiple times

rspamd_config_get_module_opt() returns the 'rule'/option object from the
first section only when a module section is duplicated at the top level
(e.g. a stray file in modules.d), so the remaining sections are silently
ignored and their rules are never loaded. Lua modules are not affected
as get_all_opt() flattens the whole duplicate chain.

Emit an explicit warning per configured C module so configtest and the
startup log surface the problem instead of silently dropping rules.

11 days ago[Fix] lua_tcp: make connection ref release one-shot to avoid double free
Vsevolod Stakhov [Fri, 24 Jul 2026 11:30:40 +0000 (12:30 +0100)] 
[Fix] lua_tcp: make connection ref release one-shot to avoid double free

A Lua callback calling close() sets the sticky LUA_TCP_FLAG_FINISHED
flag, which is then observed by more than one release site (the checks
in lua_tcp_push_data/lua_tcp_push_error, the post-helper check in
lua_tcp_handler, the fatal-error caller drops and the handlers queue
drain in lua_tcp_plan_handler_event), dropping the connection ownership
reference twice. That freed the cbd while lua_tcp_handler was still
running and crashed on the subsequent TCP_RELEASE (heap-use-after-free
on ref.refcount).

Guard the ownership drop with LUA_TCP_FLAG_FIN_RELEASED via the
lua_tcp_release_conn() helper so the first site to observe termination
wins and the rest become no-ops.

11 days ago[Fix] lua_content/pdf: emit newlines for Td/TD line breaks
Vsevolod Stakhov [Fri, 24 Jul 2026 09:30:43 +0000 (10:30 +0100)] 
[Fix] lua_content/pdf: emit newlines for Td/TD 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. As a result consecutive text lines were
concatenated, e.g. "...June 1, 2026." + "You may obtain..." became
"2026.You", which the URL parser mis-detected as http://2026.you and
raised false positives.

Capture the Td/TD operator, split the graphics ternary operators (d/m/l)
into their own dropped branch, and render a newline when the vertical
displacement (ty) is non-zero. Adds a regression unit test.

12 days ago[Feature] lua_feedback_parsers: enrich sparse ARF reports 6148/head
Vsevolod Stakhov [Thu, 23 Jul 2026 14:15:06 +0000 (15:15 +0100)] 
[Feature] lua_feedback_parsers: enrich sparse ARF reports

Microsoft's JMRP feedback loop ships a well-formed
multipart/report;report-type=feedback-report whose
message/feedback-report block only carries
Feedback-Type/User-Agent/Version. Source-IP, Arrival-Date,
Original-Mail-From and Original-Rcpt-To are omitted; that data lives
only in the embedded original-message headers.

parse_arf 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: X-Originating-IP, then client-ip= of Received-SPF /
    Authentication-Results, then the first public IP while walking the
    Received chain (is_nonpublic_ip skips RFC1918/CGNAT/ULA hops, which
    ip:is_local() does not).
  * arrival_date: topmost Received timestamp, else Date.
  * original_mail_from: Return-Path, else smtp.mailfrom.
  * original_rcpt_to: Delivered-To / X-Delivered-To / envelope-to.
  * reported_domain: falls back to the envelope-from domain.

Also accept Received-Date as an Arrival-Date alias in the report block.
extract_original_message now returns the raw header maps alongside the
parsed subset; DSN parsing is unchanged. Adds unit tests for the
Received-SPF path and the public-IP-in-Received-chain fallback.

12 days ago[Fix] html: correct image style dimension parsing
Vsevolod Stakhov [Thu, 23 Jul 2026 13:31:34 +0000 (14:31 +0100)] 
[Fix] html: correct image style dimension parsing

The style dimension parser found a digit but called rspamd_strtoul from
the start of the substring, ignored its return value, and assigned the
result. For substrings longer than 22 chars rspamd_strtoul returns
before writing *value, leaving img->height/width set from an
uninitialized variable; shorter CSS parsed to zero because the substring
began with ':' or whitespace.

Parse the digit run starting at the located digit, bound the length to
that run, and only assign on a successful parse.

12 days ago[Feature] lua_http: forbid_local option to block requests to local networks
Vsevolod Stakhov [Thu, 23 Jul 2026 13:13:18 +0000 (14:13 +0100)] 
[Feature] lua_http: forbid_local option to block requests to local networks

rspamd_http.request() connects to whatever the URL or its DNS resolution
yields, including loopback, link-local and RFC1918 destinations. Add a
forbid_local option enforced at the single connection chokepoint (after
keepalive, upstream, numeric and DNS address selection) using
rspamd_ip_is_local_cfg, i.e. the address class check plus the
configurable local_addrs radix.

Enable it by default in url_redirector: 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.

12 days ago[Fix] lua_http: deliver errors to coroutine callers
Vsevolod Stakhov [Thu, 23 Jul 2026 13:13:06 +0000 (14:13 +0100)] 
[Fix] lua_http: deliver errors to coroutine callers

DNS-stage failures (resolve error, no records, connection refused) went
through lua_http_push_error, which unconditionally invoked the callback
reference; coroutine-style requests have no callback (cbref == -1), so
the error was swallowed and the yielded thread was never resumed. Resume
the thread with (err, nil) instead, 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'. Return (err, nil) there as well.

12 days ago[Fix] http: enforce read deadline when data is pending at timer expiry
Vsevolod Stakhov [Thu, 23 Jul 2026 12:57:19 +0000 (13:57 +0100)] 
[Fix] http: enforce read deadline when data is pending at timer expiry

The read timer is a one-shot ev_timer. When it expired while data was
already waiting in the socket, the handler drained and parsed that data;
if the message remained incomplete, the connection continued with only
the I/O watcher active and no deadline at all. Report a 408 instead,
unless the drained bytes completed the message (finished or reset into
the keepalive pool).

Add a deterministic lifecycle test driving the expiry window via the
fake clock plus a max-priority ev_check writer, covering the incomplete,
salvaged and no-data cases.

12 days ago[Fix] lua_http: bound HTTP responses by default
Vsevolod Stakhov [Thu, 23 Jul 2026 12:42:40 +0000 (13:42 +0100)] 
[Fix] lua_http: bound HTTP responses 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 gsize, silently producing an effectively unlimited cap.

Add a max_lua_http_response option (256Mb by default, 0 disables)
applied as the reply body limit whenever a request does not specify
max_size; an explicit max_size of 0 still disables the limit per
request. Reject negative max_size values with a logged error instead
of letting them wrap.

12 days ago[Fix] zstd: share one bounded decompression helper across HTTP, proxy and maps
Vsevolod Stakhov [Thu, 23 Jul 2026 11:55:31 +0000 (12:55 +0100)] 
[Fix] zstd: share one bounded decompression helper across HTTP, proxy and maps

The streaming zstd decompression loop was copy-pasted in seven places
with inconsistent bounding. The proxy had no output ceiling at all: on
request/response bodies (proxy_request_decompress) 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 the
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.

Add rspamd_zstd_decompress_bounded that validates the frame-advertised
size before any allocation, caps buffer growth strictly at the limit,
drains pending decoder output after the input is consumed and frees the
buffer on every error path. Convert all seven sites to it: max_message
bounds the task, protocol v3 and proxy paths; max_map_size bounds the
map cache and static paths (the remote HTTP map path keeps its per-map
override). Cover the helper with unit tests including advertised-size
and unknown-size zstd bombs.

12 days ago[Fix] map: bound remote HTTP map sizes, compressed and decompressed
Vsevolod Stakhov [Thu, 23 Jul 2026 11:39:06 +0000 (12:39 +0100)] 
[Fix] map: bound remote HTTP map sizes, compressed and decompressed

Remote HTTP maps had no size ceiling: the map client connections never
called rspamd_http_connection_set_max_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 straight from the
frame-advertised decompressed size, then doubled it without limit, so a
small zstd bomb could balloon memory indefinitely.

Add a max_map_size option (256Mb by default, 0 disables) applied as the
HTTP body limit on both map client connections, reject frames
advertising a larger decompressed size upfront and enforce the same
ceiling in the streaming growth loop. The limit can be overridden per
map with max_size in the maps { } block, like the existing timeout and
keepalive knobs.

12 days ago[Fix] http: bound request bodies on controller, proxy and control sockets
Vsevolod Stakhov [Thu, 23 Jul 2026 11:26:25 +0000 (12:26 +0100)] 
[Fix] http: bound request bodies on controller, proxy and control sockets

The normal worker caps HTTP bodies at cfg->max_message, but the
controller router, the proxy client connection and the main control
socket never called rspamd_http_connection_set_max_size. A declared
Content-Length caused an unbounded preallocation and chunked input
could grow until the connection ended, all before routing or
authentication.

Add a max_size knob to the HTTP router applied to every accepted
connection, and set cfg->max_message as the body cap on the
controller, proxy and control sockets. Oversized requests are now
rejected with 413 (controller) or a closed connection (proxy).

12 days ago[Fix] task: bound zstd decompression by max_message and plug error-path leak
Vsevolod Stakhov [Thu, 23 Jul 2026 11:18:42 +0000 (12:18 +0100)] 
[Fix] task: bound zstd decompression by max_message and plug error-path leak

The legacy compressed-message path in rspamd_task_load_message allocated
the output buffer straight from the frame-advertised decompressed size
and doubled it without any ceiling, so a zstd bomb within the HTTP
max_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.

Reject frames advertising more than max_message upfront, enforce
max_message in the buffer growth loop, and free the buffer on all error
returns. Apply the same upfront clamp to the v3 multipart path and honor
max_message == 0 as unlimited there, matching the HTTP layer convention.

12 days ago[Fix] html: bound attributes per tag and per task
Vsevolod Stakhov [Thu, 23 Jul 2026 10:29:42 +0000 (11:29 +0100)] 
[Fix] html: bound attributes per tag and per task

12 days ago[Fix] url: enforce max_urls at the central insertion boundary
Vsevolod Stakhov [Thu, 23 Jul 2026 10:22:52 +0000 (11:22 +0100)] 
[Fix] url: enforce max_urls at the central insertion boundary

All URL sources (HTML links, query URLs, images, displayed URLs,
subject URLs, Lua-injected URLs) now respect the configured max_urls
limit via rspamd_url_set_add_or_increase/add_or_return, not just
plain-text callbacks. Text pre-checks fixed from > to >= so the cap
is exact. Also remove a duplicate part-url add for newly-seen text
URLs introduced in 80f7567fb.

12 days ago[Fix] message: make newline metadata budget task-global, fix flag collision
Vsevolod Stakhov [Thu, 23 Jul 2026 10:10:56 +0000 (11:10 +0100)] 
[Fix] message: make newline metadata budget task-global, fix flag collision

12 days ago[Fix] html: cap synthetic tags and fix balance loop
Vsevolod Stakhov [Thu, 23 Jul 2026 10:01:21 +0000 (11:01 +0100)] 
[Fix] html: cap synthetic tags and fix balance loop

12 days ago[Fix] 7zip: bound folder count and guarantee parser progress
Vsevolod Stakhov [Thu, 23 Jul 2026 09:52:50 +0000 (10:52 +0100)] 
[Fix] 7zip: bound folder count and guarantee parser progress

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/log loop that
the synchronous parser cannot be interrupted from by the task timeout.

- Apply the > 8192 limit immediately after reading NumFolders, before
  the external/internal split.
- Reject external-folder metadata instead of accepting it with
  inconsistent downstream state.
- Replace the non-progressing error branch with an early return so every
  parsing-loop iteration advances input or bails out.
- Bounds-check *p (the External flag) after the varint read.
- Make the uint64 -> unsigned int folder-count narrowing explicit.
- Move both folder_nstreams tables from g_alloca/g_malloc to the task
  mempool to reduce stack risk and drop manual frees.

12 days ago[Fix] content_type: bound parameters per header
Vsevolod Stakhov [Thu, 23 Jul 2026 09:32:23 +0000 (10:32 +0100)] 
[Fix] content_type: bound parameters per header

A single Content-Type or Content-Disposition field can carry an
unbounded number of parameters. Each one materialises a separate pool
object plus a hash/list entry, so a message bounded at max_message (50
MiB by default) could amplify into millions of allocations from one
folded header (~20x for Content-Type, more for Content-Disposition
which copies name and value per param). The existing header-count cap
limits header *fields*, not parameters within a field, so it does not
help here.

Cap the number of parameters materialised per header at 1024 (real
messages use a handful). Content-Type flags the truncation as BROKEN;
Content-Disposition has no flags field and simply stops. The counter
counts parameters, not distinct names, so a same-name flood is bounded
too.

Also fix rspamd_cmp_pieces: it subtracted two unsigned RFC 2231
continuation ids and truncated to int32_t, which overflows when the
ids differ by more than INT32_MAX and reverses the reconstruction
order. Use an explicit comparison instead.

Adds a C++ unit suite covering the parameter caps and the ordering fix.

12 days ago[Fix] message: bound per-part newline metadata
Vsevolod Stakhov [Wed, 22 Jul 2026 21:38:08 +0000 (22:38 +0100)] 
[Fix] message: bound per-part newline metadata

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: a newline-dense body turned a few MiB of input
into hundreds of MiB of heap and millions of allocations. Cap the
recorded newline positions at 100k per part (text normalization and
line counters are unaffected), set a part flag on truncation and
expose it as newlines_truncated in textpart:get_stats().

13 days ago[Fix] Bound alt-part linking and fasttext langdet cost
Vsevolod Stakhov [Wed, 22 Jul 2026 13:37:46 +0000 (14:37 +0100)] 
[Fix] Bound alt-part linking and fasttext langdet cost

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 (~100M sibling visits at the MIME part cap).
All searches of a task now share a single visit budget; when it is
exhausted the remaining parts are left unlinked with a warning.

Fasttext language detection fed up to 1M words into the model with no
bound on word length or on the resulting token count. As the UTF
tokenizer intentionally does not enforce max_word_len, a crafted body
made of huge unspaced words expanded into 3-4 subword ngram ids per
character: hundreds of megabytes of heap and matching vector-add work
in predict() from one message. Skip overlong words, cap the total
tokens fed to the model, and refuse OOV subword expansion in the shim
for words longer than any real dictionary entry.

13 days ago[Fix] message: link plain text parts to their HTML alternative
Vsevolod Stakhov [Wed, 22 Jul 2026 13:37:02 +0000 (14:37 +0100)] 
[Fix] message: link plain text parts to their HTML alternative

The alternative part search compared the raw IS_TEXT_PART_HTML flag
value (0 or 4) with the boolean want_html argument (0 or 1), so a
text/plain part never matched its text/html sibling: only the
html-to-plain direction of alt_text_part was ever populated.

13 days ago[Fix] html: eliminate DOM recursion to survive deeply nested messages
Vsevolod Stakhov [Wed, 22 Jul 2026 12:44:09 +0000 (13:44 +0100)] 
[Fix] html: eliminate DOM recursion to survive deeply nested messages

Convert html_append_tag_content, traverse_block_tags and
html_debug_structure from native-stack recursion to explicit heap
stacks, and maintain html_tag::depth incrementally instead of walking
the parent chain per tag. With max_tags = 8192 a message can legally
nest ~8190 elements, which overflowed 512KB worker-thread stacks.

Add a regression test parsing 8190 nested divs.

13 days ago[Fix] images: avoid quadratic Content-ID linking
Vsevolod Stakhov [Wed, 22 Jul 2026 12:20:05 +0000 (13:20 +0100)] 
[Fix] images: avoid quadratic Content-ID linking

13 days ago[Fix] archives: bound metadata resource usage
Vsevolod Stakhov [Wed, 22 Jul 2026 12:07:02 +0000 (13:07 +0100)] 
[Fix] archives: bound metadata resource usage

13 days ago[Fix] mime_headers: bound parser resource usage
Vsevolod Stakhov [Wed, 22 Jul 2026 11:35:19 +0000 (12:35 +0100)] 
[Fix] mime_headers: bound parser resource usage

13 days ago[Fix] mime_parser: bound parser resource usage
Vsevolod Stakhov [Wed, 22 Jul 2026 11:20:32 +0000 (12:20 +0100)] 
[Fix] mime_parser: bound parser resource usage

13 days agoMerge pull request #6124 from moisseev/jquery
Vsevolod Stakhov [Wed, 22 Jul 2026 09:10:26 +0000 (10:10 +0100)] 
Merge pull request #6124 from moisseev/jquery

[Rework] Remove jQuery from the WebUI

13 days agoRevert "[Test] Fix race in history reset e2e test"
Vsevolod Stakhov [Wed, 22 Jul 2026 09:09:55 +0000 (10:09 +0100)] 
Revert "[Test] Fix race in history reset e2e test"

This reverts commit 8d934a9790956fd21b89532e5ecfe2e00115285c.

13 days ago[Minor] Update version to 4.1.3
Vsevolod Stakhov [Tue, 21 Jul 2026 17:54:26 +0000 (18:54 +0100)] 
[Minor] Update version to 4.1.3

13 days agoRelease 4.1.2 4.1.2
Vsevolod Stakhov [Tue, 21 Jul 2026 17:53:03 +0000 (18:53 +0100)] 
Release 4.1.2

2 weeks agoMerge pull request #6142 from WRMSRwasTaken/master
Vsevolod Stakhov [Tue, 21 Jul 2026 16:14:26 +0000 (17:14 +0100)] 
Merge pull request #6142 from WRMSRwasTaken/master

Fix synchronous context_augment firing two GPT requests for one model

2 weeks agoMerge pull request #6144 from rspamd/vstakhov-settings-policy
Vsevolod Stakhov [Tue, 21 Jul 2026 16:03:45 +0000 (17:03 +0100)] 
Merge pull request #6144 from rspamd/vstakhov-settings-policy

[Feature] Settings: policy option for additive symbols_enabled + strict explicit_enable gating

2 weeks ago[CritFix] mime_parser: bound message/rfc822 recursion depth
Vsevolod Stakhov [Tue, 21 Jul 2026 16:01:25 +0000 (17:01 +0100)] 
[CritFix] mime_parser: bound message/rfc822 recursion depth

A chain of bare "Content-Type: message/rfc822" wrappers recursed through
rspamd_mime_parse_message's own MESSAGE branch without ever tripping the
max_nested (64) limit. When descending into an embedded message the
parser allocates a fresh runtime (nst) and seeds nst->nesting from the
parent's value *before* incrementing, then increments the parent runtime
st which is not the one carried into the recursion. The multipart branch
and rspamd_mime_process_multipart_node bump the descended-through runtime
(and push it onto the stack so the recursive call's cleanup pops and
decrements it), but the message branch did neither, so nst->nesting
stayed at its initial value at every level and the entry guard never
fired.

The result was recursion bounded only by message size (~34 bytes per
level): deeply nested messages exhaust the worker stack (remote,
unauthenticated DoS via HTTP submission or SMTP/milter delivery), and the
per-level boundary pre-scan made even non-crashing depths quadratic in
CPU. The earlier S/MIME fix (f6536945) only covered the pkcs7-mime
re-entry path and did not address this.

Push npart onto nst->stack and bump nst->nesting in the message branch,
mirroring the multipart branch; the recursive call's existing cleanup
balances both. Bounding the depth to max_nested also caps the number of
preprocess passes, eliminating the quadratic-CPU vector.

Add a Lua unit regression test that feeds a 500-level message/rfc822
chain and asserts the parsed part count stays capped near max_nested.

Reported by @gronke.

2 weeks ago[Feature] Configdump: show symcache flags in --symbol-details 6144/head
Vsevolod Stakhov [Mon, 20 Jul 2026 10:15:30 +0000 (11:15 +0100)] 
[Feature] Configdump: show symcache flags in --symbol-details

Add a `flags` array (fine, empty, explicit_disable, explicit_enable,
ignore_passthrough, nostat, idempotent, mime, trivial, skip, composite,
ghost, coro) to the per-symbol details, so it is possible to check
whether a symbol requires explicit enabling; structural types are
already covered by the `type` field.

2 weeks ago[Test] Settings: cover explicit_enable gating and implicit_allow policy
Vsevolod Stakhov [Mon, 20 Jul 2026 10:02:02 +0000 (11:02 +0100)] 
[Test] Settings: cover explicit_enable gating and implicit_allow policy

New functional test cases:
  * explicit_enable symbols do not run without settings, under an
    implicit-allow settings id, or with raw settings that do not list
    them
  * whitelist settings and task:enable_symbol() unlock them
  * policy = implicit_allow keeps all symbols enabled while unlocking
    listed explicit_enable symbols and disabling listed symbols, both
    for a registered settings id and for raw settings

2 weeks ago[Feature] Settings: add policy option and strict explicit_enable gating
Vsevolod Stakhov [Mon, 20 Jul 2026 10:01:51 +0000 (11:01 +0100)] 
[Feature] Settings: add policy option and strict explicit_enable gating

Make symbols with the explicit_enable flag actually require explicit
enabling: previously they were executed whenever any settings were
applied to a task, as the implicit-allow settings branch in
cache_item::is_allowed never consulted the flag, and any non-null
task->settings unlocked such symbols regardless of content.

Now explicit_enable symbols run only when:
  * listed in symbols_enabled of the applied settings (any policy), or
  * force-enabled via task:enable_symbol()

To make it possible to combine the default-allow behaviour with
selectively enabled explicit_enable symbols and disabled symbols,
introduce a settings policy option in the apply block:

  apply {
    policy = "implicit_allow";
    symbols_enabled = ["MY_EXPLICIT_SYM"]; # additive, unlocks
    symbols_disabled = ["SOME_SYM"];       # subtractive
  }

With policy = implicit_allow, symbols_enabled no longer switches the
settings to whitelist mode: all symbols stay enabled, the listed ones
are merely unlocked. The policy is also honoured for raw settings
passed via the Settings header.

Additionally, symbols listed in symbols_enabled are now always tracked
as force-enabled (previously only when the settings element was already
attached to the task, which missed the map-driven settings path where
the id is set after settings are applied).

2 weeks ago[Fix] Fuzzy: skip injected (computed) parts on learn and check
Vsevolod Stakhov [Sat, 18 Jul 2026 09:26:39 +0000 (10:26 +0100)] 
[Fix] Fuzzy: skip injected (computed) parts on learn and check

2 weeks agoMerge pull request #6141 from rspamd/vstakhov-aliases-from-rewrite
Vsevolod Stakhov [Fri, 17 Jul 2026 10:59:21 +0000 (11:59 +0100)] 
Merge pull request #6141 from rspamd/vstakhov-aliases-from-rewrite

Preserve the sender identity from alias rewriting (DMARC/SPF, issue 6137)

2 weeks agoMerge pull request #6143 from rspamd/vstakhov-semicolon-addr-list
Vsevolod Stakhov [Fri, 17 Jul 2026 10:59:01 +0000 (11:59 +0100)] 
Merge pull request #6143 from rspamd/vstakhov-semicolon-addr-list

Split MIME address lists on semicolons as well as commas (issue 6139)

2 weeks ago[Test] Add unit tests for the MIME address list parser 6143/head
Vsevolod Stakhov [Thu, 16 Jul 2026 11:28:26 +0000 (12:28 +0100)] 
[Test] Add unit tests for the MIME address list parser

Covers single addresses, display names (plain, quoted, UTF-8),
comments, comma and semicolon lists, separators inside quoted strings
and unquoted display names, group constructs and no-address inputs.

Issue: #6139

2 weeks ago[Fix] Split MIME address lists on semicolons as well as commas
Vsevolod Stakhov [Thu, 16 Jul 2026 11:28:23 +0000 (12:28 +0100)] 
[Fix] Split MIME address lists on semicolons as well as commas

A semicolon is not an RFC 5322 address-list separator (it only
terminates a group construct), but Outlook-style ;-separated recipient
lists are pervasive in real mail and most MTAs (e.g. Postfix) split
them. Previously such a list was parsed as a single malformed address
whose local part swallowed the semicolon and the next address, breaking
recipient counts and producing false invalid-address signals.

Treat ';' exactly like ',' in the list parser. This mirrors the
existing comma behaviour in every edge case: semicolons inside quoted
strings are preserved, an unquoted semicolon in a display name drops
the preceding phrase just like a comma does, an empty group
(undisclosed-recipients:;) now yields no addresses like the comma
variant, and the trailing ';' of a group construct no longer leaks
into the last member's domain.

Issue: #6139

2 weeks agoFix synchronous context_augment firing two GPT requests for one model 6142/head
Marcel Menzel [Thu, 16 Jul 2026 10:55:55 +0000 (10:55 +0000)] 
Fix synchronous context_augment firing two GPT requests for one model

When your context_augment function calls the callback synchronously
(e.g., return function(task, content, cb) cb('some context') end),
the pending_fetches counter drops to 0 inside the callback,
triggering maybe_proceed() which calls proceed().

Then control returns to the main flow where the now-redundant guard
also sees pending_fetches == 0 and calls proceed(nil) a second time
— this time without the context snippet.

The else branch (line 1324) already handles the case where none
of the context features are enabled, so the removed fallback
was purely a duplicate path for synchronous callbacks.

2 weeks ago[Test] Use unique envelope senders in aliases From tests 6141/head
Vsevolod Stakhov [Thu, 16 Jul 2026 10:31:09 +0000 (11:31 +0100)] 
[Test] Use unique envelope senders in aliases From tests

The three From-identity tests shared one envelope From/Rcpt pair, so the
greylist module (meta key = envelope from + rcpts + ip) soft-rejected
the third scan of the pair within its retry window, skipping the filters
stage and losing the GET_FROM symbol. This only manifested with tight
test pacing (pabot shard in CI); give each test a unique envelope
sender, as the rest of the suite does.

2 weeks ago[Minor] Selectors: document the 'orig' address flavour
Vsevolod Stakhov [Thu, 16 Jul 2026 10:13:10 +0000 (11:13 +0100)] 
[Minor] Selectors: document the 'orig' address flavour

The from/rcpts extractors pass their arguments through to task:get_from
and task:get_recipients, so from('mime', 'orig') already selects the
address as it was seen in the message; document it and add a functional
test exercising the flavour through the selector pipeline.

2 weeks ago[Fix] Forged recipients: compare addresses as they were transmitted
Vsevolod Stakhov [Thu, 16 Jul 2026 09:47:27 +0000 (10:47 +0100)] 
[Fix] Forged recipients: compare addresses as they were transmitted

The rule compared the rewritten envelope view against the wire MIME
headers, so a recipient alias mapping to another domain (legal for
recipients) could make a perfectly matching To header look forged.
Compare the envelope and the headers at the same altitude: both as they
were seen in the message, which is also what this rule checked before
alias rewriting was applied to tasks.

Issue: #6137

2 weeks ago[Test] Add functional tests for From identity preservation
Vsevolod Stakhov [Thu, 16 Jul 2026 09:02:40 +0000 (10:02 +0100)] 
[Test] Add functional tests for From identity preservation

- aliases: googlemail.com From keeps its domain while the user part is
  canonicalized; a cross-domain virtual alias on the From is discarded;
  a same-domain alias on the From local part still applies
- get_from: the 'orig' flavour returns the wire From (and only it) after
  a task:set_from rewrite, and is a no-op without one
- dmarc: alignment and policy lookup use the wire From domain even when
  a prefilter rewrote the MIME From

The REWRITE_MIME_FROM test prefilter is gated on a request header so it
stays inert in merged-suite tests that do not opt in.

Issue: #6137

2 weeks ago[Fix] SPF: check the original envelope sender
Vsevolod Stakhov [Thu, 16 Jul 2026 09:02:27 +0000 (10:02 +0100)] 
[Fix] SPF: check the original envelope sender

SPF evaluates the RFC5321.MailFrom as it was transmitted (including
local-part macros), so resolve the SPF record and credentials from the
original envelope sender even if the envelope from has been rewritten,
e.g. by the aliases module.

Issue: #6137

2 weeks ago[Fix] DMARC: evaluate the From and envelope domains as seen in the message
Vsevolod Stakhov [Thu, 16 Jul 2026 09:02:24 +0000 (10:02 +0100)] 
[Fix] DMARC: evaluate the From and envelope domains as seen in the message

DMARC must align identifiers and look up the policy for the RFC5322.From
domain as it appears in the message, and check SPF alignment against the
RFC5321.MailFrom as transmitted. Use the 'orig' address flavour so that
rewrites done via task:set_from (e.g. by the aliases module) do not
affect alignment, policy lookup or rua report attribution.

Issue: #6137

2 weeks ago[Rework] Lua API: make the 'orig' address flavour return wire addresses
Vsevolod Stakhov [Thu, 16 Jul 2026 09:02:08 +0000 (10:02 +0100)] 
[Rework] Lua API: make the 'orig' address flavour return wire addresses

Previously task:get_from({'mime', 'orig'}) returned both the preserved
original addresses and the rewritten ones appended by task:set_from,
making the flavour unusable for consumers that need the message as it
arrived (a duplicate-From check would misfire on the combined list, and
Lua could not tell the entries apart). Now, when the 'orig' flavour is
requested and originals were preserved by a rewrite, only the original
addresses are returned; without a rewrite the behaviour is unchanged.

Also expose the 'aliased' and 'original' email address flags to Lua and
document the flavour.

2 weeks ago[Fix] Aliases: refuse to rewrite From domains
Vsevolod Stakhov [Thu, 16 Jul 2026 09:01:55 +0000 (10:01 +0100)] 
[Fix] Aliases: refuse to rewrite From domains

The From address is the sender's identity: SPF, DKIM and DMARC all key
on its domain. Service rules and alias resolution (e.g. a virtual alias
mapping to another domain) may only touch the local part of the From
address; any rewrite that would change the domain is now discarded.
Recipient rewriting is unaffected: recipients are interpreted by the
receiving host, and no authentication protocol keys on them.

Also fix a spurious task:set_from call: the set_from decision used the
callback-wide alias_resolved flag, so a rewrite of any earlier address
(e.g. an envelope recipient) caused the unmodified MIME From to be
marked as aliased and duplicated.

Issue: #6137

2 weeks ago[Fix] Aliases: never rewrite googlemail.com domain to gmail.com
Vsevolod Stakhov [Thu, 16 Jul 2026 09:01:40 +0000 (10:01 +0100)] 
[Fix] Aliases: never rewrite googlemail.com domain to gmail.com

googlemail.com and gmail.com are DNS-distinct domains with independent
SPF and DMARC records (gmail.com publishes p=none while googlemail.com
publishes p=quarantine), so substituting one for the other breaks DKIM
alignment, changes the effective DMARC policy and misattributes DMARC
reports. Keep the gmail.com user-part canonicalization (dots and plus
tags) for googlemail.com addresses, but preserve the domain.

The domain equivalence remains available in lua_util.remove_email_aliases
for dedup-only consumers such as email hash lookups in RBL checks.

Issue: #6137

2 weeks agoMerge pull request #6132 from errror/master 5807/head
Patrick C. [Wed, 15 Jul 2026 16:20:21 +0000 (18:20 +0200)] 
Merge pull request #6132 from errror/master

Fix for PR #5985: sleep in callback context

2 weeks agoMerge pull request #6133 from rspamd/dependabot/pip/contrib/neural-embedding-service...
Vsevolod Stakhov [Wed, 15 Jul 2026 16:20:02 +0000 (17:20 +0100)] 
Merge pull request #6133 from rspamd/dependabot/pip/contrib/neural-embedding-service/transformers-5.5.0

Bump transformers from 5.3.0 to 5.5.0 in /contrib/neural-embedding-service

3 weeks agoBump transformers in /contrib/neural-embedding-service 6133/head
dependabot[bot] [Mon, 13 Jul 2026 20:55:09 +0000 (20:55 +0000)] 
Bump transformers in /contrib/neural-embedding-service

Bumps [transformers](https://github.com/huggingface/transformers) from 5.3.0 to 5.5.0.
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.3.0...v5.5.0)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.5.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
3 weeks ago[Fix] Lua 5.5 compatibility: do not assign to for-loop variables
Vsevolod Stakhov [Sat, 11 Jul 2026 09:30:34 +0000 (10:30 +0100)] 
[Fix] Lua 5.5 compatibility: do not assign to for-loop variables

Lua 5.5 makes for-loop control variables read-only, so assigning to
them is now a compile-time error. Rebind the value to a local (or pass
it as a closure parameter) in all remaining places; found by
parse-checking the whole tree with luac 5.5.

3 weeks ago[Test] Fix race in history reset e2e test
Vsevolod Stakhov [Sat, 11 Jul 2026 09:19:38 +0000 (10:19 +0100)] 
[Test] Fix race in history reset e2e test

Register waitForResponse listeners before clicking the reset button:
on localhost the historyreset response arrives before a listener
registered after the click can see it, which made the legacy
(Playwright 1.45.3) CI leg fail deterministically.

3 weeks agoMerge pull request #6130 from AmateurECE/bugfix/lua-lupa-5.5-compatibility-word-loop...
Vsevolod Stakhov [Sat, 11 Jul 2026 08:39:22 +0000 (09:39 +0100)] 
Merge pull request #6130 from AmateurECE/bugfix/lua-lupa-5.5-compatibility-word-loop-variable

fix(lua-lupa): Use local loop variable

3 weeks agoMerge pull request #6123 from moisseev/test
Vsevolod Stakhov [Sat, 11 Jul 2026 07:39:11 +0000 (08:39 +0100)] 
Merge pull request #6123 from moisseev/test

[Test] Update dev dependencies

3 weeks agoMerge pull request #6126 from rspamd/vstakhov-composite-conditions
Vsevolod Stakhov [Sat, 11 Jul 2026 07:38:56 +0000 (08:38 +0100)] 
Merge pull request #6126 from rspamd/vstakhov-composite-conditions

[Feature] composites: per-symbol Lua conditions and explicit dependencies

3 weeks agoMerge pull request #6127 from rspamd/vstakhov-selectors-anchor-parse
Vsevolod Stakhov [Sat, 11 Jul 2026 07:38:43 +0000 (08:38 +0100)] 
Merge pull request #6127 from rspamd/vstakhov-selectors-anchor-parse

[Fix] lua_selectors: reject selectors that parse only as a prefix

3 weeks agofix(lua-lupa): Use local loop variable 6130/head
Ethan D. Twardy [Fri, 10 Jul 2026 23:03:58 +0000 (18:03 -0500)] 
fix(lua-lupa): Use local loop variable

Lua 5.5 makes `for` expression variables constant. The recent upgrade to
lua 5.5 broke lua-lupa, which previously attempted to modify the loop
variable. This caused an unrecoverable error on startup:

rspamd_lua_require_function: require of lua_util.jinja_template failed: error loading module 'lupa' from file '/usr/share/rspamd/lualib/lupa.lua':\x0A\x09/usr/share/rspamd/lualib/lupa.lua:1858: attempt to assign to const variable 'word'

3 weeks ago[Minor] WebUI: drop unreachable 304 success branch 6124/head
Alexander Moisseev [Fri, 10 Jul 2026 16:31:49 +0000 (19:31 +0300)] 
[Minor] WebUI: drop unreachable 304 success branch

The Rspamd controller sends "Cache-Control: no-store" on every WebUI
response (controller.c, issue #3330) and never emits Last-Modified/ETag
for these endpoints, so a browser never revalidates and a 304 response
is impossible. The `|| xhr.status === 304` in queryServer was dead code
copied from jQuery's default success range.

Remove it so queryServer, the /stat connect probe and the legacy /auth
probe all treat 200-299 as success. Treating 304 as an error is also
safer: a 304 body is empty, and success would have fed it to the JSON
parser.

3 weeks ago[Fix] WebUI: route a malformed /stat response to the login dialog
Alexander Moisseev [Fri, 10 Jul 2026 16:13:34 +0000 (19:13 +0300)] 
[Fix] WebUI: route a malformed /stat response to the login dialog

The connect probe swallowed JSON parse errors (parseJsonSafe returned {}),
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 former jQuery content-type
parse raised parsererror and fell through to the login form.

Parse the body explicitly and, on any failure, show the connect dialog —
matching the pre-migration behaviour and covering the broad case (any
non-JSON 2xx, not only application/json). Removes the now-unused
parseJsonSafe helper.

3 weeks ago[Fix] WebUI: restore smooth scroll on the Scan tab
Alexander Moisseev [Fri, 10 Jul 2026 15:28:13 +0000 (18:28 +0300)] 
[Fix] WebUI: restore smooth scroll on the Scan tab

The jQuery-removal replaced .animate({scrollTop}) with
window.scrollTo({behavior:"smooth"}), which honours prefers-reduced-motion
and is interruptible by Tabulator's reflows. The former jQuery tween
ignored the preference and re-set scrollTop every frame, so it was always
smooth.

Add a requestAnimationFrame-based smoothScrollTo (400ms easeInOut, matching
jQuery's default) and use it for both Scan scrolls (scan result and the
"Clean" reset). Like the rest of the UI's animations it does not honour
prefers-reduced-motion, preserving the previous behaviour.

3 weeks ago[Fix] WebUI: stop refresh spinner restarting mid fan-out
Alexander Moisseev [Fri, 10 Jul 2026 15:18:42 +0000 (18:18 +0300)] 
[Fix] WebUI: stop refresh spinner restarting mid fan-out

fireAjaxComplete (the active-request counter decrement) ran at the top of
finish(), before the success callback. During an "All SERVERS" query the
count therefore dipped to 0 between the neighbours probe and the
per-neighbour requests: ajaxStart re-fired and re-added fa-spin,
restarting the spinner's CSS animation. jQuery decremented after success,
keeping the count above zero for the whole fan-out.

Move the decrement to the end of finish(), after the success/complete
callbacks. This also removes the rapid fa-spin class churn that was
interrupting the Scan tab's smooth scroll.

3 weeks ago[Fix] WebUI: address jQuery-removal review comments
Alexander Moisseev [Fri, 10 Jul 2026 14:03:25 +0000 (17:03 +0300)] 
[Fix] WebUI: address jQuery-removal review comments

- stats.js: guard the optional fuzzy_hashes key in addFuzzyStorage
  (Object.entries/keys of undefined threw on every Status refresh when
  fuzzy_check was disabled or a neighbour was down), matching the guard
  addStatfiles already had.
- common.js: cancel the hide/show slide animations once the final state
  is committed, so the fill:"forwards" no longer pins height:0 — a later
  re-show was measuring the pinned height and animating 0→0.
- common.js: treat a single Element (incl. <select>/<form>, which expose
  a numeric .length) as one node in toElements, not a collection.
- common.js: tolerate a null neighbours body before Object.keys.
- rspamd.js: null-guard the active-tab lookup in two spots, where a
  missing active tab used to throw.
- upload.js: drop the dead params:{processData:false} (queryServer no
  longer form-encodes).
- common.js: document the statusCode handler signature.

3 weeks ago[Test] selectors: cases for partially-parsed selectors 6127/head
Vsevolod Stakhov [Fri, 10 Jul 2026 13:16:22 +0000 (14:16 +0100)] 
[Test] selectors: cases for partially-parsed selectors

Negative cases: a second ':' method, a stray closing paren and junk
after a ';' list element must be rejected. Control cases: valid
method/transform chains, selector lists and trailing whitespace must
keep parsing.

3 weeks ago[Fix] lua_selectors: reject selectors that parse only as a prefix
Vsevolod Stakhov [Fri, 10 Jul 2026 13:16:11 +0000 (14:16 +0100)] 
[Fix] lua_selectors: reject selectors that parse only as a prefix

The selector grammar was not anchored to the end of input, so lpeg
matched the longest valid prefix and silently dropped the rest of the
string. E.g. 'from("smtp"):domain:lower' (a second ':' cannot parse)
was accepted and evaluated as 'from("smtp"):domain', and any trailing
garbage after a selector or a ';' list element was ignored. All
consumers (multimap, rbl, ratelimit, settings, reputation, the
controller selector check used by the WebUI) reported such selectors
as valid while evaluating only the prefix.

Append an lpeg.Cp() capture after the grammar (plus optional trailing
whitespace) and make parse_selector require the whole input to be
consumed, logging the position and the unparsed tail otherwise.

User-visible change: selectors that previously loaded thanks to the
silent truncation now fail configuration load with an error pointing
at the offending token. Such selectors were never evaluating as
written, so failing loudly is the correct behaviour.

3 weeks ago[Fix] rspamd_symcache.h: keep C linkage of lua.h for C++ includers 6126/head
Vsevolod Stakhov [Fri, 10 Jul 2026 07:38:03 +0000 (08:38 +0100)] 
[Fix] rspamd_symcache.h: keep C linkage of lua.h for C++ includers

The raw lua.h include sat outside extern "C", so any C++ unit that
pulled rspamd_symcache.h before lua/lua_common.h got C++-mangled
declarations of the Lua C API and failed to link. Including
lua_common.h itself is not an option due to the include cycle via
rspamd.h -> cfg_file.h -> rspamd_symcache.h, so wrap lua.h the same
way lua_common.h does. This also lets composites sources include
lua_common.h in the natural position instead of first.

3 weeks ago[Test] composites: functional cases for Lua conditions
Vsevolod Stakhov [Fri, 10 Jul 2026 07:32:18 +0000 (08:32 +0100)] 
[Test] composites: functional cases for Lua conditions

Cover: option-inspecting condition, cross-symbol join on a shared
option value, numeric return as atom weight combined with an
expression limit, false condition, and depends_on deferring a
composite whose condition consults a postfilter symbol.

3 weeks ago[Feature] composites: per-symbol Lua conditions and explicit dependencies
Vsevolod Stakhov [Fri, 10 Jul 2026 07:32:07 +0000 (08:32 +0100)] 
[Feature] composites: per-symbol Lua conditions and explicit dependencies

Allow gating composite atoms with synchronous Lua functions, by analogy
with re_conditions of mime expressions:

  rspamd_config:add_composite('C', {
    expression = 'SYM_A & SYM_B',
    conditions = {
      SYM_A = function(task, symbol) ... end,
    },
    depends_on = { 'SOME_POSTFILTER_SYM' },
  })

A condition is called as f(task, symbol) where symbol is a table in the
task:get_symbol() layout; it may return true/false or a number used as
the atom weight. The condition is ANDed with option filters of the atom
and a failed condition is treated exactly as a missing symbol, so
removal policies are not applied. Since a condition can consult symbols
invisible to the expression, the optional depends_on list feeds the
first/second pass placement as if those symbols were expression atoms.

rspamd_config:add_composite() now also accepts a full definition table,
and rspamd_lua_push_symbol_result() is exported for reuse.

3 weeks ago[Test] phishing: no FP for same label under different suffix
Vsevolod Stakhov [Wed, 8 Jul 2026 12:59:27 +0000 (13:59 +0100)] 
[Test] phishing: no FP for same label under different suffix

Regression test: brand.co.za displayed over a brand.com href must not
fire PHISHING (uses co.za since the functional test tld file has no
co.uk)

3 weeks ago[Fix] phishing: don't penalise same label under another TLD
Vsevolod Stakhov [Wed, 8 Jul 2026 12:59:15 +0000 (13:59 +0100)] 
[Fix] phishing: don't penalise same label under another TLD

Three fixes for cross-TLD brand false positives
(e.g. brand.co.uk displayed over a brand.com href):

- Normalise the DMARC_POLICY_ALLOW domain to eSLD before comparing it
  with the link target tld: DMARC reports the raw From domain (possibly
  a subdomain), so the existing exclusion for authenticated mail never
  matched
- Compare registrable labels instead of stripping the last dot
  component: the old code only handled single-label suffixes, so
  brand.co.uk vs brand.com was levenshtein-compared as full strings and
  scored full weight; now the identical label yields weight 0
- Check strict_domains regardless of the computed weight (gated on
  tld ~= ptld, equivalent to the old reachable behaviour), so displaying
  a strictly protected domain over a same-label different-suffix target
  still fires at full weight

3 weeks ago[Test] lua_cryptobox: unit tests for secretbox nonce padding
Vsevolod Stakhov [Wed, 8 Jul 2026 11:55:20 +0000 (12:55 +0100)] 
[Test] lua_cryptobox: unit tests for secretbox nonce padding

Guard against the regression fixed in #6122: a short nonce must produce
the same ciphertext as the explicitly zero-padded 24-byte nonce, and
short/padded forms must decrypt each other. Both assertions fail on the
unpatched code as they read past the nonce buffer.

3 weeks ago[Fix] lua_cryptobox: pass padded nonce to secretbox encrypt/decrypt
Jamon Camisso [Wed, 8 Jul 2026 11:54:31 +0000 (07:54 -0400)] 
[Fix] lua_cryptobox: pass padded nonce to secretbox encrypt/decrypt

Both secretbox encrypt and decrypt built a zero-padded 24-byte real_nonce
but passed the original short nonce pointer to libsodium, reading up to
23 bytes out of bounds and producing ciphertexts that depend on adjacent
memory contents.

Fixes #6121

3 weeks agoMerge pull request #6119 from rspamd/dependabot/pip/contrib/neural-embedding-service...
Vsevolod Stakhov [Wed, 8 Jul 2026 11:38:22 +0000 (12:38 +0100)] 
Merge pull request #6119 from rspamd/dependabot/pip/contrib/neural-embedding-service/transformers-5.3.0

Bump transformers from 5.0.0rc3 to 5.3.0 in /contrib/neural-embedding-service

3 weeks ago[Minor] maps: move glob pattern anchoring into rspamd_str_regexp_escape
Vsevolod Stakhov [Wed, 8 Jul 2026 09:09:41 +0000 (10:09 +0100)] 
[Minor] maps: move glob pattern anchoring into rspamd_str_regexp_escape

Add RSPAMD_REGEXP_ESCAPE_ANCHOR flag that wraps the escaped pattern
into ^(?:...)$ within the same allocation instead of a second
g_strdup_printf in the glob map insertion path.

3 weeks ago[Fix] maps: anchor glob map patterns to match the whole subject
Vsevolod Stakhov [Wed, 8 Jul 2026 08:52:53 +0000 (09:52 +0100)] 
[Fix] maps: anchor glob map patterns to match the whole subject

Glob map entries were compiled into unanchored regexps and matched
with substring search semantics, so a `t.co` entry matched
`walmart.com` and `*.bit.ly` matched `foo.bit.ly.evil.com`.
Wrap the translated pattern into `^(?:...)$` at map load time so
glob entries match the subject as a whole: bare names match exactly,
wildcards match only what they say.

This affects all glob maps: multimap glob/glob_multi, url_redirector
redirector_hosts_map, dkim_signing/arc signing_table and key_table,
mx_check exclusions and the rbl glob returncodes matcher. Maps that
relied on the accidental substring behaviour must now use explicit
wildcards.

Fixes #6125

4 weeks ago[Test] WebUI: fix race in history reset E2E test
Alexander Moisseev [Sun, 5 Jul 2026 19:17:18 +0000 (22:17 +0300)] 
[Test] WebUI: fix race in history reset E2E test

Register the historyreset/history response listeners before the click
via Promise.all. waitForResponse only catches responses arriving after
it is set up, and the fast local historyreset round-trip could beat a
listener attached post-click, causing a flaky 10s timeout under CI load.

4 weeks agoMerge pull request #6120 from moisseev/tabulator
Vsevolod Stakhov [Sun, 5 Jul 2026 17:47:12 +0000 (18:47 +0100)] 
Merge pull request #6120 from moisseev/tabulator

WebUI: migrate from FooTable to Tabulator

4 weeks ago[Rework] WebUI: drop jQuery dependency
Alexander Moisseev [Sun, 5 Jul 2026 16:23:36 +0000 (19:23 +0300)] 
[Rework] WebUI: drop jQuery dependency

Remove jQuery from the RequireJS config (paths, app/bootstrap shims) and
delete the vendored jquery-3.7.1.min.js. All app modules now load without
jQuery; the bootstrap shim's stale "Popovers require jQuery" comment and
deps entry are removed.

Drive-by: rename the modalDialog data-backdrop attribute to its Bootstrap
5 name data-bs-backdrop, so the static-backdrop behaviour is actually
applied (the BS4 attribute has been silently inert).

4 weeks ago[Rework] WebUI: remove jQuery from libft module
Alexander Moisseev [Sun, 5 Jul 2026 15:53:54 +0000 (18:53 +0300)] 
[Rework] WebUI: remove jQuery from libft module

Migrate libft.js off jQuery. $("<tag>", {hash}) element construction
becomes common.el, $.extend becomes spread, $.each becomes forEach.
The column-options dropdown's namespaced hidden.bs.dropdown.rspamd
rebind (jQuery namespaces) becomes a common.data-tracked
addEventListener that removes the previous handler before re-binding;
the delegated fuzzy-hash buttons (re-bound every render) use a cleanup
map so common.delegate doesn't stack. $(arr).not(arr) set-diffs become
Array.some/ includes, and .siblings()/.attr()/.html()/.val() move to
native DOM.

4 weeks ago[Rework] WebUI: remove jQuery from main module
Alexander Moisseev [Sun, 5 Jul 2026 14:31:39 +0000 (17:31 +0300)] 
[Rework] WebUI: remove jQuery from main module

Migrate rspamd.js off jQuery. The /stat connect probe moves to a raw
XHR (avoiding common.query's error logging on the normal "not logged
in" path); $.ajaxSetup and $(document).ajaxStart/Complete are dropped
since no $.ajax calls remain. tabClick disables/enables the nav bar
controls via attribute+class helpers (li/a need both for Bootstrap);
:radio + .val([v]) and .data("value") become checkRadio / activeMenuValue
helpers; delegated handlers move to common.delegate; BS5 events move to
addEventListener (with {once:true} replacing self-removing .on/.off);
the popover content uses cloneNode and the outside-click dismiss uses
.contains(). Form/input re-binds become onsubmit/oninput properties.

4 weeks ago[Rework] WebUI: remove jQuery from stats module
Alexander Moisseev [Sun, 5 Jul 2026 13:51:58 +0000 (16:51 +0300)] 
[Rework] WebUI: remove jQuery from stats module

Migrate stats.js off jQuery. The delegated mouseenter/mouseleave hover
highlighting (which doesn't bubble natively) is reimplemented on
mouseover/mouseout with a relatedTarget-containment check; the jQuery
sibling-traversal chains (prevAll/nextAll/slice/find) move to native
sibling walks. $.each becomes Object.entries/forEach, $(html).appendTo
becomes insertAdjacentHTML, and $.wrapAll becomes common.el DOM
construction. The legacy /auth fan-out keeps a raw XHR to preserve its
custom error message and alerted-session dedup; $.when().always becomes
Promise.all().finally. Adds common.getAjaxTimeout so direct XHRs honour
the configured timeout.

4 weeks ago[Rework] WebUI: remove jQuery from upload module
Alexander Moisseev [Sun, 5 Jul 2026 13:18:43 +0000 (16:18 +0300)] 
[Rework] WebUI: remove jQuery from upload module

Migrate upload.js off jQuery: $.Deferred/$.when to Promise/Promise.all,
$.trim to trim, :selected to selectedIndex, multi-arg :not() to CSS,
.val/.prop/.attr/.find/.append/.empty/.children/.closest/.parent to
native DOM, .animate({scrollTop}) to scrollTo, and $("<option>") to
common.el. Option metadata stored via .data() (objects/arrays) moves
to common.data; the fuzzy-picker change handler is bound once instead
of re-bound on every refresh.

4 weeks ago[Rework] WebUI: remove jQuery from config module
Alexander Moisseev [Sun, 5 Jul 2026 17:24:31 +0000 (20:24 +0300)] 
[Rework] WebUI: remove jQuery from config module

Migrate config.js off jQuery: HTML-string/$("<tag>") element building
to common.el, $.each to forEach, the :input/:checked pseudos to CSS
selectors, .data() round-trips (object values) to common.data, the
delegated .map-link handler to common.delegate, and .attr()/.val()/
.on()/.html()/.remove() to native DOM and addEventListener.

Map cells and the modal title now escape server data via textContent
(consistent with the leaf-modules hardening).

4 weeks ago[Rework] WebUI: remove jQuery from leaf modules
Alexander Moisseev [Sun, 5 Jul 2026 12:12:52 +0000 (15:12 +0300)] 
[Rework] WebUI: remove jQuery from leaf modules

Migrate graph.js, selectors.js, history.js, and symbols.js off
jQuery: $.extend/$.each/$.trim and $().val/prop/attr/hasClass/is/
addClass/removeClass/change/click/on to native DOM and the shared
common.el / common.delegate helpers added in the common.js stage.

Delegated handlers on Tabulator-rendered DOM (.scorebar) move to
common.delegate; the repeated button disable/enable patterns
collapse into small per-module helpers.

jQuery remains loaded for the remaining modules (config, libft,
stats, upload, rspamd) — removed in later stages.