Major Release with Public Suffix Lookup, a Single Symbol Scheduler, Office and SVG Content Extraction, and Bounded Parsing of Untrusted Input
suppressed (settings, disable_all_symbols, a pre-result โ no cascade, can be re-enabled) and skipped (passthrough, score limit, passed stage โ cascades to hard dependents); a symbol that already ran is no longer re-executed by task:enable_symbol, running items are not clobbered by disable calls, process_all pre-results allow further processing as documented, and the idempotent bit of the pre-result skip mask is honoured. Timeout accounting follows the plan: the longest dependency chain within a bucket, buckets summed within a stage, stages summed. The plan is visible through rspamadm configdump -d, the new rspamadm configdump -e execution-order dump and rspamd_config:get_symbol_type() (#6225)rspamd_url_find_tld and the URL parse path no longer scan each host with a multipattern; they probe label suffixes right to left against a flat hash map, which is about seven times faster per host and involves no hyperscan scratch. Two behaviours change as a result: ! exception rules are honoured, having previously been skipped outright by the URL scanner, and a host that is itself a public suffix resolves to the whole host instead of being attributed to some shorter suffix (#6213)fin deduplication and the newline cursor of the processing callbacks rely on. The upshot is that the URL scanner no longer builds the largest hyperscan database in rspamd โ 10.5k patterns, 1.3 s to compile and 6.5 MiB serialised โ and no longer registers it in the pending compilation queue of hs_helper (#6213)top filter returns the full public suffix (incompatible): The filter used to return the last label of the eSLD, conflating the uk and co.uk namespaces; it now returns the actual public suffix through the new lookup, falling back to the old derivation when no suffix list is loaded or nothing matches. Maps used with this filter must list full suffixes โ com.au rather than au โ so review any top-filtered map before upgrading (#6213)rspamd_tld_lookup rule set with a little flag handling on top: exceptions keep the default eSLD, wildcard rules keep the whole host, and since compose wildcards match zero or more labels the bare parent is registered alongside each wildcard rule (#6213)<prefix>_count inside the update script โ incremented on every add including a re-learn of a digest already stored, decremented on every delete including deletes of digests that were not there, and never decremented when a hash expired โ so the reported number of stored hashes only ever grew, and a value that went negative was displayed as an enormous one. Updates no longer touch the counter. Instead the first fuzzy worker walks the keyspace with a slow SCAN matching digest keys only (the prefix followed by 64 bytes) and publishes the result, which every worker keeps reading as before. The scan is pinned to one read server, preferring a replica when configured, paced by a duty cycle that backs off when Redis is slow, and checkpointed so a restarted worker resumes its pass; the lock and the last completion time live in Redis, so storages sharing one Redis run a single pass per interval. Defaults are a pass at most every 4 hours, SCAN COUNT 1000 and a 10% duty cycle, all configurable in the count_scan section of the fuzzy worker. Negative counters left behind by older versions are reported as 0 until the first pass completes (#6259)text, tspan, title, desc and foreignObject HTML; hyperlinks and remote resources from image, use, script, iframe and CSS url(); data: URIs with their media types, decoding HTML and nested SVG payloads within a size budget; and the constructs a picture never needs โ script elements, on* handlers, javascript: URLs, animation elements rewriting href, foreignObject, forms and password inputs, meta refresh, embedded documents โ plus a keyword scan of script bodies for atob, Blob, createObjectURL, location and friends. An external DOCTYPE is accepted since most editors emit one; internal subsets are rejected. On the Lua side gzip-compressed SVGZ is supported, text and URLs are injected into the task, decoded HTML payloads are injected as HTML parts and nested SVG payloads are folded into the outer document, all under a per-task budget and timeout configured in lua_content { svg { ... } }. New symbols: SVG_CONTENT, SVG_SUSPICIOUS, SVG_SCRIPT, SVG_FOREIGN_OBJECT, SVG_DATA_URI, SVG_EXTERNAL_LINKS, SVG_EXTERNAL_RESOURCES, SVG_FORM and SVG_REDIRECT, with small default weights on the scripting and embedding indicators (#6240)hlinkClick now yield URLs too. The Lua side owns the configuration, the per-task budget and a per-format story map two relationship levels deep (workbook โ worksheet โ drawing, presentation โ slide โ notes); relationship-only parts such as Word settings and Excel external links are fetched for their relationships alone. The real format comes from the main part's content type, with the MIME hint recorded as declared_format when the two disagree, and story selection truncates on the part budget instead of marking a large workbook suspicious for having many sheets. New symbols XLSX_CONTENT, XLSX_EXTERNAL_LINKS, XLSX_SUSPICIOUS, PPTX_CONTENT, PPTX_EXTERNAL_LINKS, PPTX_SUSPICIOUS, OOXML_MACROS, OOXML_OLE_OBJECT, OOXML_REMOTE_TEMPLATE and OOXML_EXTERNAL_DATA ship with zero weight by default, so you can observe them before scoring them (#6238)task:inject_part() accepts "html" next to "text". The injected part is marked as HTML and goes through the regular HTML parser, so its URLs, features and CTA links are collected exactly as for a real text/html part. Content extractors use this to feed decoded payloads โ HTML smuggled inside an SVG, for instance โ back into the scan (#6240)rspamd_tld_lookup: Custom suffix-like rule sets โ exact, wildcard and exception rules with longest-match semantics โ can be built through the C API and the new Lua module, which also queries the suffix list rspamd itself loaded. Modules that used to reimplement this with regexps get the same per-label hash matching the URL scanner uses (#6213)url:get_public_suffix() and the get_public_suffix selector transform: The URL method returns the public suffix of the host together with the match flags, so a plugin can tell wildcard, exception and private-section suffixes apart from plain ICANN ones, and the selector transform makes the same value available to multimap, ratelimit, reputation and every other selector pipeline (#6213)asn and country need ASN_CHECK, symbol(NAME) needs NAME โ either statically or as a function of its arguments. lua_selectors.get_dependencies() collects them for a selector string and lua_selectors.register_dependencies() registers them for the symbol evaluating that selector, skipping composites and classifiers and warning about symbols belonging to a later stage. The ratelimit, multimap, rbl and generic reputation rules register the dependencies of their selectors, so a ratelimit bucket keyed by symbol(DKIM_CHECK) finally gets DKIM_CHECK executed before the ratelimit check rather than reading whatever happened to be there (#6225)rspamadm configdump -e, so the cost of such a dependency is visible rather than implicit (#6225)div.mainbox) or a combinator (div p, div > p, h1 + p, h1 ~ p) was either ignored or, in a grouped list, wrongly applied by its first part โ and mail templates hide spacers and preheaders with exactly these rules, so hidden text detection missed them. The parser now builds a subject compound plus a right-to-left chain of combinator links and drops whole any selector it cannot evaluate (pseudo classes, attribute selectors, unknown elements, dangling combinators) so the rest of the list still applies. The style sheet indexes each selector by the most specific part of its subject and evaluates the remainder against the tag tree on lookup, and matching rules are applied by specificity with later source order winning ties, instead of the old fixed id/class/tag order (#6252)strict for an exact match, relaxed when only the organisational domains agree โ as the R_DKIM_ALIGNED symbol and in the task mempool. Signatures that only tempfailed are tracked separately, since a policy must not report a definitive failure while an aligned signature is still unresolved, and DMARC consumes these facts and drops both of its own comparisons, keeping the DKIM_TRACE walk only for the per-domain lists its reports need (#6197)mailbox_identity() returns a canonical key โ lowercased, plus tags stripped, gmail dots removed, domain folded through equivalent domain classes โ and canonical_domain() folds a domain alone. Equivalent domains are those delivering into the same mailbox namespace (googlemail.com and gmail.com), supplied by a kv map or an inline table through init_equivalent_domains(), with a builtin Google class as the fallback. The rewrite path deliberately keeps the transmitted domain, since the domains stay distinct for SPF, DKIM and DMARC: the identity is only ever for comparing addresses and must never be written back to a task (#6228)count_scan.stats_sample (default 10) each SCAN batch runs inside a read-only Redis script that also reads every Nth digest found โ chosen by two digest bytes, so the sample is stable across resumed passes โ and returns aggregates: hash counts, average and maximum weight per flag, multi-flag and shingled hashes with their shingle slots, and creation age buckets. The scanner accumulates them, persists them with the checkpoint, scales them to the full count at the end of a pass and publishes a JSON document. Fuzzy workers read it on the stats timer and expose it as a storage object in the /fuzzystat control reply, and rspamadm fuzzystat prints it as a block; the wire STAT reply still carries only the count, so clients see nothing new. Cost is roughly 3 ยตs of Redis time per sampled digest within the existing duty cycle, and stats_sample = 0 restores the plain SCAN (#6260)max_ips_per_key (still 1024 by default) and per key through a max_ips extension, where 0 disables tracking; the default key tracks nothing unless it has an explicit max_ips, since it is the one key whose source set a bounded table cannot represent. A table can still overflow its cap, so evicting insertions are counted per hour and once they exceed eight times the cap the table is dropped and the key flagged for the rest of its life. The stats output gains ips_inserted and ips_overflow, and rspamadm fuzzystat says when per-IP stats were disabled for a key instead of printing nothing (#6248)ucl.untrusted_parser() in the Lua API: Lua had no way to bound UCL parsing โ ucl.parser() gives the libucl defaults, which only guard nesting depth, and every one of the roughly 88 call sites in tree used it bare. That is fine in config mode, but most of those parse JSON or msgpack arriving from somewhere we do not control. The new parser applies a depth of 64, a million elements, 64 MB of tree, 1 KB keys and 16 MB strings; the caps are absolute rather than derived from the input, since a Lua caller builds the parser before it has the text, and any of them can be overridden per call site with zero still meaning unlimited. parser:set_limits() and parser:get_limits() are exposed on any parser, with set_limits overlaying only the fields it is given so tightening one does not silently clear the rest, and an unknown key being an error rather than a quiet no-op. ucl.parser() is unchanged (#6209)redis_list pusher: Mirrors the existing redis_stream pusher but uses RPUSH, so exports can be consumed by clients that only read a plain list โ logstash's redis input, for one, which cannot read a stream. Per-recipient routing and an optional max_len cap are supported; since RPUSH cannot cap the list itself, max_len needs a separate LTRIM whose scheduling result and callback error are both checked and logged at error level, because a persistently failing trim would otherwise silently defeat the advertised cap. A trim failure deliberately does not defer: the RPUSH has already succeeded, so the message is exported, and deferring would re-run the exporter and push a duplicate โ a breached memory cap is recoverable, a duplicated export is not (#6200)lua_redis.request accepts attrs.upstream (and optionally attrs.host) to send a request to a specific server rather than selecting one โ needed to continue a SCAN cursor, which is only meaningful on the server that issued it (#6259)fuzzy_check.ping_storage_all() Lua API pings every configured server of a rule โ the deduplicated union of the read and write server lists โ using each upstream's current address in a dedicated UDP session per server, leaving upstream rotation, DNS side effects and aliveness bookkeeping untouched; dead and not-yet-resolved upstreams are probed too, with a missing address reporting a sync error and kicking a one-shot re-resolve. The GET-only /plugins/fuzzy/status controller route, which works with the read-only password, pings every server of every rule and replies once all expected results have landed, with a deadline flushing a partial reply should a callback ever be lost. In the Status tab the storage tooltip shows each server address with its liveness mark and check time, storages with failed servers carry an "N/M down" badge visible without hovering, and a Check button in the card header re-probes on demand so the state does not wait for a refresh cycle that may never come with auto-refresh off (#6249)/healthy and /ready, an expandable per-server details row, load rate and request latency columns, last-seen time for down servers, majority-based version and config_id drift detection, and a per-server Writable badge (#6212)git_id in /stat and rendered in a dedicated column, hidden when no live server reports one, with a drift badge like version and configuration mismatches so dev builds of different commits can be told apart (#6216)rspamd_symcache_get_max_timeout(), which both configtest and worker startup call. Cycles are now broken right after dependencies are resolved: a depth-first walk drops the back edge of every cycle along with its reverse and reports it as an error naming both symbols, with the traversal keeping the current path as a safety net (#6225)rspamadm configtest reports it with the stages of both symbols, and virtual symbols are checked against their parent's stage. Every rejection path also actually drops its edge now: add_dependency() pre-sets the dependency item, so rejected and self-dependency edges used to survive the "remove empty deps" pass and reach the runtime anyway (#6225)ignore_passthrough symbols run after a pre-result: task:set_pre_result() marked the classifier stages as processed to skip classification, and since task processing resumes from the highest processed stage that also jumped over the filters stage โ so filters flagged ignore_passthrough never ran after a prefilter pre-result, contrary to the flag's documented meaning. The processed stages are left alone: the symbol cache already suppresses non-exempt symbols, and classification is skipped by task processing when the result carries a passthrough that stops further checks, with least and process_all results and disabled actions not counting (#6225)apply block. Custom apply keys are an extension point โ a settings block may carry arbitrary values that Lua rules read back through task:get_settings() โ and with a single layer they survived, because that path returns the layer object as is, but as soon as a second layer appeared (a static block plus a layer pushed by the Redis settings handler, an external map, or any prefilter) they vanished, so a rule keyed off apply.tenant or apply.mode silently changed behaviour depending on how many layers matched. Unknown keys are now merged per key with the higher layer winning and the value taken whole, with _merge_info treated as known so a layer cannot clobber the merge metadata. The merged path also shares the single-layer action application code now, which fixes two further bugs: a freshly created action slot never had its flags initialised and could be skipped depending on what the uninitialised memory held, and actions { foo = null } for an action the task does not have created it with a zero threshold, turning a request to disable an action into one that enabled it on everything (#6230)ip_map and client_ip_map actually match in settings (#6205).a {font-size:0} .b {font-size:14px} .a {color:red} hid a .b .a element. A repeated selector now forms a separate cascade entry. Lookup used to compile every matching rule into a pool block, including those the cascade then discarded, growing the pool by rules times elements; the winner is now the only pool allocation and the rest compile into a stack temporary, filling only what the winner leaves undefined with set semantics rather than parent-propagation ones, so a less specific display:none no longer overrides a more specific display:block (#6252)div.mainbox ul li.spacer, div.mainbox ol li.spacer therefore registered a rule for the bare tag div and applied its declarations to every div in the document, with no class having to match anything. Grouped descendant rules are ubiquitous in mail templates, and the ones carrying font-size: 0, opacity: 0, color: transparent or display: none are exactly the spacer and mobile-only rules, so a legitimate message could have its entire body moved to the invisible buffer: one confirmation mail lost all 81 words of its text part, HIDDEN_TEXT scored 4.06 on 411 invisible characters, and with the word count at zero R_SUSPICIOUS_IMAGES reached its ceiling of 5.0 off a single logo (#6252)font shorthand walked every value and set the font size from each one that parsed as a dimension, so the last won and unitless numbers were accepted as lengths โ in font: 400 1rem/1.5 <family> the line-height became the size, the block got a 1px font and everything inheriting from it compiled as invisible; webmail clients emit exactly that on body, so an ordinary reply's whole body moved to the invisible buffer, HIDDEN_TEXT fired at full weight on ham, and Bayes, the neural network and every text rule saw a nearly empty part. vw and vh were spelled wv and wh in the dimension map, so the two most common viewport units never matched and the bare number was read as pixels, turning font-size: 2vw into 2px. And a percent size introduced by a stylesheet block stayed negative and never met the visibility limits, because the block is merged after the parent has already propagated, while sizes above 128% were clamped to 100% by an int8 field and the fallback parent size was 10px where the rest of the parser and every browser use more. Unitless numbers are rejected for the shorthand, parsing stops at the first length, unknown units leave the size unset, the field is widened, sizes are resolved again once the stylesheet block is in place, and the fallback is 16px (#6245)font: 0 Arial renders text invisible. A zero is let through so the hiding trick is still detected, while the first-length rule keeps a zero line-height (16px/0) from being taken as the size (#6246)html_tag::get_content picked the invisible buffer for any block that was not visible, but transparent text is written to the parsed buffer as spaces, so the offsets of a transparent tag were applied to the wrong buffer and the content came back empty โ or as a slice of unrelated display:none text when the message contained some. The destination buffer is now recorded on the tag when its offsets are computed and read from there, and the length passed to Lua comes from the recorded offsets rather than from whatever survived in the buffer after a block margin trimmed the trailing spaces. R_WHITE_ON_WHITE now fires on white-on-white text whether or not the message also carries hidden text (#6262)max_payloads was enforced per document by the native extractor and per task only for injection, so a nested SVG brought its own full quota and the part specific ended up holding more payload content than the budget advertised. The remaining task budget is now passed to the extractor, a payload is kept only while budget remains with payloads_truncated recording the cut, and nested payloads are charged to the same counter before being folded into the outer document (#6240)part:get_urls() stayed empty and CTA and candidate-link processing was skipped โ and bare URLs in the visible text of an injected HTML part were never extracted either, since only the plain text branch ran the text extractor. Injected parts now get their URLs array allocated and the strict text extraction runs after HTML processing, mirroring what a real text/html part receives (#6240)truncated=true but without the OOXML_EXTERNAL_DATA and OOXML_REMOTE_TEMPLATE evidence. Relationship-only entries are now selected first within each level, since they cost a single part and carry the indicators, with content stories picked in package order from the remaining budget. xlIntlMacrosheet is also registered next to xlMacrosheet in the workbook story map, so international XLM macrosheets are parsed for text and formula URLs instead of merely being counted as macros (#6238)mktime: strptime leaves the time-of-day and DST fields untouched when parsing DD-MM-YYYY, so an expired key could pick up a far-future expiry from stack contents. The fields are initialised before the conversion and failed date parses are rejected (#6255)max_ips_per_key = 0 or after an overflow โ that record is NULL for every request and the aggregate checked/matched/added/deleted/errors counters froze. The bucket is now updated whenever the request is unkeyed and the bucket exists (#6248)/stat (#6243)na, which is checked before permerror and would have masked the error. Domains publishing one SPF record alongside unrelated TXT records โ vendor verification tokens and the like โ are unaffected (#6196)V=SPF1 escaped the one-record limit, while v=spf10, which the RFC discards, counted as an SPF record and could permerror a domain publishing a single valid one beside it. The comparison also no longer reads past a short TXT string: rdns exposes TXT data as a bare NUL-terminated pointer with no length, and the previous helper read the first four bytes of both operands before comparing any of them, which overran a legal one-character or empty TXT string (#6196)lua_aliases.mailbox_identity: equivalent domains are folded, gmail dots and plus tags stripped, and the authenticated user and Delivered-To checks use the same key, while the symbol options still show the wire addresses and the addresses on the task are untouched. Equivalent domain classes come from a new rspamd/equivalent_domains.inc map covering Google, Proton, Apple and Yandex, fetched from maps.rspamd.com with the usual local.d/maps.d hook and a shipped fallback snapshot, configured through the equivalent_domains option (#6228, #6227)name: value regardless, so a header folded right at the colon (Message-ID:\r\n <id>) gained a space that had never been there and simple DKIM canonicalisation started to fail. SMFIP_HDR_LEADSPC is now negotiated whenever the MTA offers it, making header values travel verbatim in both directions and covering Header:value with no space at all, with the leading space supplied by rspamd on add, change and insert actions โ empty values left unpadded, since that is how a header removal is expressed. For MTAs that do not offer the flag, the space is still added unless the value starts with CR/LF, the one folded case still distinguishable from a space the MTA ate (#6194)redirectors_only: map:get_key() returns false rather than nil on a miss for glob, regexp and set maps, so the gate written as get_key(...) ~= nil was always true and every redirect target was followed regardless of the configured host map. A truthiness-based check is now applied consistently at the three sites that can issue an HTTP request for a hop โ the live 30x follow decision, the mid-walk cache-miss bridge and the cached nested bridge โ closing map-churn windows where a cached chain ends on a host delisted after being cached. An unrelated bug on the same nested bridge is fixed too: the seen table was keyed by the raw cached string while the walk keys by the URL's string form, so the cycle guard could false-fire and silently abort the extension for most URLs. A debug line is now logged before every request and for each host map lookup, giving a single grep anchor for whether a hop was really fetched (#6198)vær sød, Grüße aus München, réservé aux clients all matched, so URL_OBFUSCATED_TEXT fired on routine non-English newsletters. Latin-1 letters live at 192โ255, well clear of the ASCII range the technique uses, so restricting the pattern below 128 keeps the detection and drops the false positives; multi-byte entities were already outside the pattern and are unaffected (#6206)map_add_from_ucl() caches maps so modules pointed at the same feed share one object, but a map given as a list of URLs was keyed by the digest of the list alone, so the type requested by the first caller won and every later caller silently received a map of a foreign type. url_redirector asks for a glob map of redirector_hosts_map while the multimap redirector rule asks for a hash map of the same URLs; multimap registers first, so url_redirector ran on a hash map and no glob pattern in the feed could ever match. The type is now part of the cache key, and a type prefix inside the list (glob;http://...) is stripped before the lookup so the key always carries the effective type rather than the requested one (#6226)/stat success callback, so overlapping refreshes were ordered by completion โ an older request finishing after a newer one received the larger id and re-rendered Credentials, the Status table and the chart with its stale captured parameters, such as the selected server. The id is now assigned and captured before the query, so the later-started cycle always supersedes the earlier one and obsolete cycles drop their state entirely (#6212)rspamadm fuzzy_ping help fixed: The -n option's description was wrong (#6237)size_t rather than an 8-bit counter that wrapped after 255 segments, partially parsed allocations are freed on every error path and every allocation is checked. TCP reads drain iteratively under a per-event packet budget instead of recursing, and the read counter is widened so a maximal 65535-byte DNS-over-TCP frame no longer overflows a 16-bit fielducl_parser_set_limits() is added for callers handling untrusted input โ max depth, nodes, allocation, key length and string length, with zero meaning unlimited and only the depth limit on by default โ and both the UCL and msgpack parsers charge every element they create, including the array wrapper a duplicate key builds. Several latent defects are fixed along the way: an allocation-failure path that left a value object with a stale union pointer, a sibling chain loop that released a sibling's children before checking its refcount so a shared object could have its children freed underneath it, an unchecked allocation and two ignored append results, a NULL container unconditionally reported as a syntax error and masking the real cause, and a msgpack container unwind by tail recursion that made parse depth a stack depth problem of its own (#6207)max_message could still describe a tree orders of magnitude larger than its wire size โ 50 MB of [1,1,1,...] builds gigabytes of objects. An untrusted parser now applies a depth of 64, a million elements, 1 KB keys, 16 MB strings and a tree cap of min(96 ร input + 256 KB, 64 MB), the factor coming from the cost of an element against the densest JSON that can describe one, so it sits above anything legitimate while the absolute cap bounds a large body. It is used for checkv3 metadata in both JSON and msgpack form, for both controller body parsers and for both proxy parsers (#6207)ucl.untrusted_parser() so a hostile reply cannot turn into an unbounded object tree โ most directly the HTTP request headers parsed by http_headers and settings, and otherwise replies from network services: the LLM providers and gpt, the six lua_scanners backends, bimi, contextal, the updates and openphish feeds, the external neural service, the anonymiser in lua_mime and the vault lookup in lua_dkim_tools. Settings is split rather than converted wholesale โ the request header and external map parsers are untrusted, the settings map and Redis ones stay on the ordinary parser โ and clickhouse, elastic, lua_cache, history_redis, the neural model paths and everything in config mode or rspamadm are deliberately left alone, since they handle data we produce ourselves and can legitimately be large enough to reach the caps (#6209)Z_STREAM_END returned whatever it had produced, and a buffer that did fill doubled past the limit rather than being clamped โ a caller asking for 512 Kb could be handed 1 Mb, having allocated twice what it asked for. A single ceiling is now kept, allocated one byte above it so a full buffer proves the data does not fit, and checked after the loop as well as during it. zstd took the decompressed size from the frame header and passed it straight to g_malloc: that value is attacker controlled, a few dozen bytes suffice to claim terabytes, and g_malloc aborts rather than returning NULL, so a worker decompressing an untrusted body died where it stood. The header is now an allocation hint capped against the size of the input, with the existing growth loop finding the real size. The inflate loop also resumes from where inflate stopped rather than rewinding the output pointer and overwriting what had already been produced/Encrypt detection with it โ while a small file with an enormous trailer passed; the trailing bytes are compared instead. max_extraction_size becomes 1 Mb, which is the ceiling streams have really been decoded under for years now that the binding enforces it exactly, and timeout_processing is carried across to the output object so a deadline tripping late actually reaches the caller and PDF_TIMEOUT can fire at allhard flag of register_dependency, and the configdump option that shows the execution plan (#6225)This major release replaces the per-class symbol schedulers with a single plan over the dependency graph, so a dependency edge now moves its target between stages instead of executing it from the wrong one, and prefilters may finally depend on filters โ with selectors declaring the symbols they need so a ratelimit bucket keyed by `symbol(DKIM_CHECK)` gets a real value. URL handling moves from a 10.5k-pattern hyperscan database to a proper public suffix lookup with full PSL semantics, exposed to Lua, selectors and the RBL composition maps; check any multimap using the `top` filter, whose maps must now list full suffixes. Attachment content extraction grows to SVG, XLSX and PPTX alongside DOCX, with relationship-level facts for macros, OLE objects, remote templates and external data, and decoded payloads injected back through the regular HTML parser. Parsing of untrusted input is bounded throughout โ the DNS reply parser is hardened against malformed packets, UCL gains real structural limits and an untrusted parser used for every network-facing call site, and zlib, zstd and PDF extraction no longer amplify a small hostile input into unbounded work. Several long-standing false positive sources are gone: CSS compound selectors and combinators are evaluated instead of being applied by their first part, font shorthand and viewport units no longer turn ordinary mail invisible, and forged sender and recipient checks compare mailbox identities rather than raw strings. Recommended upgrade for all users; review the incompatible changes above, especially the public suffix semantics and the fuzzy hash counting rework, before rolling out.